Compare commits

..
5 Commits
Author SHA1 Message Date
jjbrunton fc515a19b0 Fix Select empty value error in DarePacksPage 2026-01-08 21:39:44 +00:00
jjbrunton b933c3c421 Add dares feature schema and admin portal 2026-01-08 21:37:34 +00:00
jjbrunton aff0bc0888 Add admin pagination and update configs 2026-01-08 21:15:34 +00:00
jjbruntonandClaude Opus 4.5 b60b72a289 Update icons, screenshots, and UX improvements
- Refactor icon picker to use full Ionicons set dynamically
- Add date sort toggle to My Answers screen
- Fix match deduplication and show total count on home
- Clear stale data when switching chats
- Update app store screenshots
- Bump build number to 25

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 19:58:29 +00:00
jjbrunton c48936686e Remove E2EE and switch to plaintext messaging
🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-01-08 18:04:48 +00:00
128 changed files with 4305 additions and 7489 deletions

No files matched your search

-37
View File
@@ -179,40 +179,3 @@ backgroundColor: 'rgba(18, 18, 18, 0.85)',
The `BlurView` in `tabBarBackground` is unreliable and can fail during re-renders/transitions, causing a fully transparent tab bar. The solid rgba background acts as a fallback while still allowing the blur effect on top. The `BlurView` in `tabBarBackground` is unreliable and can fail during re-renders/transitions, causing a fully transparent tab bar. The solid rgba background acts as a fallback while still allowing the blur effect on top.
**Location:** `apps/mobile/app/(app)/_layout.tsx` - `tabBarStyle` **Location:** `apps/mobile/app/(app)/_layout.tsx` - `tabBarStyle`
## E2EE (End-to-End Encryption) Implementation
Chat messages (text, images, videos) are encrypted client-side using AES-256-GCM with triple-wrapped RSA keys.
### Architecture
- **Full documentation:** See `docs/e2ee-implementation.md` for complete details
- **Encryption library:** `apps/mobile/src/lib/encryption/`
- **React hooks:** `apps/mobile/src/hooks/useEncryptionKeys.ts`, `useDecryptedMessage.ts`
- **Edge functions:** `handle-key-rotation`, `reencrypt-pending-messages`
### Key Concepts
1. **Message versions:** v1 = plaintext (legacy), v2 = E2EE
2. **Triple-wrapped keys:** Each message's AES key is RSA-encrypted for sender, recipient, and admin
3. **Device recovery:** When user gets new device, admin key re-wraps messages for new user key
### Database Tables
- `messages.version` - Encryption version (1 or 2)
- `messages.encrypted_content` - Base64 ciphertext
- `messages.encryption_iv` - Base64 IV for AES-GCM
- `messages.keys_metadata` - JSONB with wrapped keys
- `profiles.public_key_jwk` - User's RSA public key
- `master_keys` - Admin RSA public keys
### Setup Required
1. Run `node scripts/generate-admin-keys.mjs` to generate admin keypair
2. Insert public key into `master_keys` table
3. Store private key in `ADMIN_PRIVATE_KEY_JWK` environment variable for edge functions
4. Install crypto dependency: `npm install react-native-quick-crypto@^0.7.x`
5. Run `npx expo prebuild --clean` after installation
### Edge Functions for E2EE
| Function | verify_jwt | Purpose |
|----------|------------|---------|
| `handle-key-rotation` | false | Re-wraps message keys when user gets new device |
| `reencrypt-pending-messages` | false | Wraps keys for recipient after they generate keys |
+1
View File
@@ -32,6 +32,7 @@
"openai": "^4.24.0", "openai": "^4.24.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-icons": "^4.12.0",
"react-router-dom": "^6.21.0", "react-router-dom": "^6.21.0",
"recharts": "^3.6.0", "recharts": "^3.6.0",
"sonner": "^1.3.1", "sonner": "^1.3.1",
+18
View File
@@ -7,6 +7,8 @@ import { DashboardPage } from '@/pages/DashboardPage';
import { CategoriesPage } from '@/pages/content/CategoriesPage'; import { CategoriesPage } from '@/pages/content/CategoriesPage';
import { PacksPage } from '@/pages/content/PacksPage'; import { PacksPage } from '@/pages/content/PacksPage';
import { QuestionsPage } from '@/pages/content/QuestionsPage'; import { QuestionsPage } from '@/pages/content/QuestionsPage';
import { DarePacksPage } from '@/pages/content/DarePacksPage';
import { DaresPage } from '@/pages/content/DaresPage';
import { UsersPage } from '@/pages/users/UsersPage'; import { UsersPage } from '@/pages/users/UsersPage';
import { UserDetailPage } from '@/pages/users/UserDetailPage'; import { UserDetailPage } from '@/pages/users/UserDetailPage';
import { MatchChatPage } from '@/pages/users/MatchChatPage'; import { MatchChatPage } from '@/pages/users/MatchChatPage';
@@ -64,6 +66,22 @@ function App() {
</ProtectedRoute> </ProtectedRoute>
} }
/> />
<Route
path="/dare-packs"
element={
<ProtectedRoute requiredPermission={PERMISSION_KEYS.MANAGE_PACKS}>
<DarePacksPage />
</ProtectedRoute>
}
/>
<Route
path="/dare-packs/:packId/dares"
element={
<ProtectedRoute requiredPermission={PERMISSION_KEYS.MANAGE_QUESTIONS}>
<DaresPage />
</ProtectedRoute>
}
/>
{/* User management */} {/* User management */}
<Route <Route
@@ -4,7 +4,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { EmojiPicker } from '@/components/ui/emoji-picker'; import { IconPicker } from '@/components/ui/icon-picker';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { import {
Dialog, Dialog,
@@ -98,9 +98,9 @@ export function PackFormDialog({
{/* Icon */} {/* Icon */}
<div className="space-y-2"> <div className="space-y-2">
<Label>Icon</Label> <Label>Icon</Label>
<EmojiPicker <IconPicker
value={formData.icon} value={formData.icon}
onChange={(emoji) => setField('icon', emoji)} onChange={(iconName) => setField('icon', iconName)}
/> />
</div> </div>
@@ -18,6 +18,7 @@ import {
Home, Home,
Activity, Activity,
AlertTriangle, AlertTriangle,
Zap,
} from 'lucide-react'; } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
@@ -47,6 +48,12 @@ const contentNav: NavItem[] = [
icon: <LayoutGrid className="h-5 w-5" />, icon: <LayoutGrid className="h-5 w-5" />,
requiredPermission: PERMISSION_KEYS.MANAGE_CATEGORIES, requiredPermission: PERMISSION_KEYS.MANAGE_CATEGORIES,
}, },
{
label: 'Dare Packs',
href: '/dare-packs',
icon: <Zap className="h-5 w-5" />,
requiredPermission: PERMISSION_KEYS.MANAGE_PACKS,
},
]; ];
const usersNav: NavItem[] = [ const usersNav: NavItem[] = [
@@ -0,0 +1,150 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import type { IconType } from 'react-icons';
import * as Io5Icons from 'react-icons/io5';
import { Button } from './button';
import { Input } from './input';
interface IconOption {
name: string;
Icon: IconType;
}
/**
* Convert PascalCase component name to kebab-case Ionicon name.
* e.g., "IoHeartOutline" -> "heart-outline"
*/
function toKebabCase(componentName: string): string {
// Remove "Io" prefix
const withoutPrefix = componentName.slice(2);
// Convert PascalCase to kebab-case
return withoutPrefix
.replace(/([a-z])([A-Z])/g, '$1-$2')
.replace(/([A-Z])([A-Z][a-z])/g, '$1-$2')
.toLowerCase();
}
// Build icon options from all Io5 icons dynamically
const ICON_OPTIONS: IconOption[] = Object.entries(Io5Icons)
.filter(([name]) => name.startsWith('Io'))
.map(([componentName, Icon]) => ({
name: toKebabCase(componentName),
Icon: Icon as IconType,
}))
.sort((a, b) => a.name.localeCompare(b.name));
const ICONS_BY_NAME = ICON_OPTIONS.reduce<Record<string, IconOption>>((acc, option) => {
acc[option.name] = option;
return acc;
}, {});
interface IconPreviewProps {
value?: string | null;
fallback?: string;
className?: string;
}
export function IconPreview({ value, fallback = '?', className }: IconPreviewProps) {
const resolved = value ?? fallback;
const Icon = resolved ? ICONS_BY_NAME[resolved]?.Icon : undefined;
if (Icon) {
return <Icon className={className} />;
}
return <span className={className}>{resolved}</span>;
}
interface IconPickerProps {
value: string;
onChange: (iconName: string) => void;
}
export function IconPicker({ value, onChange }: IconPickerProps) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setOpen(false);
}
}
if (open) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [open]);
const filteredIcons = useMemo(() => {
const normalized = query.trim().toLowerCase();
if (!normalized) return ICON_OPTIONS;
return ICON_OPTIONS.filter((option) => option.name.includes(normalized));
}, [query]);
const selectedOption = ICONS_BY_NAME[value];
const SelectedIcon = selectedOption?.Icon;
const buttonLabel = selectedOption ? value : value ? 'Legacy icon' : 'Select an icon';
return (
<div className="relative" ref={containerRef}>
<Button
type="button"
variant="outline"
className="h-14 w-full justify-start gap-3 px-3"
onClick={() => setOpen(!open)}
>
{SelectedIcon ? (
<SelectedIcon className="h-6 w-6" />
) : (
<span className="text-2xl">{value || '?'}</span>
)}
<span className="text-sm text-muted-foreground">{buttonLabel}</span>
</Button>
{open && (
<div className="absolute top-full left-0 mt-2 w-[22rem] rounded-md border bg-background p-3 shadow-lg z-50">
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search icons..."
className="mb-3"
/>
<div className="grid grid-cols-4 gap-2 max-h-64 overflow-y-auto pr-1">
{filteredIcons.map(({ name, Icon }) => {
const isSelected = name === value;
return (
<button
key={name}
type="button"
onClick={() => {
onChange(name);
setOpen(false);
}}
className={`flex flex-col items-center gap-1 rounded-md border p-2 text-xs transition-colors hover:bg-muted ${
isSelected
? 'border-primary ring-1 ring-primary/40'
: 'border-border'
}`}
>
<Icon className="h-5 w-5" />
<span className="text-[10px] leading-tight text-muted-foreground">
{name}
</span>
</button>
);
})}
{filteredIcons.length === 0 && (
<div className="col-span-4 text-xs text-muted-foreground">
No icons found
</div>
)}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,97 @@
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
type PaginationControlsProps = {
page: number;
pageSize: number;
totalCount: number;
onPageChange: (page: number) => void;
onPageSizeChange?: (pageSize: number) => void;
pageSizeOptions?: number[];
showPageSize?: boolean;
className?: string;
};
export function PaginationControls({
page,
pageSize,
totalCount,
onPageChange,
onPageSizeChange,
pageSizeOptions = [10, 25, 50],
showPageSize = true,
className,
}: PaginationControlsProps) {
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
const safePage = Math.min(Math.max(page, 1), totalPages);
const start = totalCount === 0 ? 0 : (safePage - 1) * pageSize + 1;
const end = totalCount === 0 ? 0 : Math.min(safePage * pageSize, totalCount);
return (
<div
className={cn(
'flex flex-col gap-2 text-sm text-muted-foreground sm:flex-row sm:items-center sm:justify-between',
className
)}
>
<div>
{totalCount === 0
? 'No results'
: `Showing ${start} to ${end} of ${totalCount}`}
</div>
<div className="flex flex-wrap items-center gap-2">
{showPageSize && onPageSizeChange && (
<div className="flex items-center gap-2">
<span className="text-xs uppercase tracking-wide text-muted-foreground">Rows</span>
<Select
value={String(pageSize)}
onValueChange={(value) => onPageSizeChange(Number(value))}
>
<SelectTrigger className="h-8 w-[90px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{pageSizeOptions.map((size) => (
<SelectItem key={size} value={String(size)}>
{size}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(Math.max(1, safePage - 1))}
disabled={safePage <= 1}
>
<ChevronLeft className="mr-1 h-4 w-4" />
Prev
</Button>
<span className="text-xs text-muted-foreground">
Page {safePage} of {totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(Math.min(totalPages, safePage + 1))}
disabled={safePage >= totalPages}
>
Next
<ChevronRight className="ml-1 h-4 w-4" />
</Button>
</div>
</div>
</div>
);
}
+2 -2
View File
@@ -86,10 +86,10 @@ export const auditedSupabase = {
/** /**
* Update a single record with audit logging * Update a single record with audit logging
*/ */
async update<T extends { id: string }>( async update<T extends object = Record<string, unknown>>(
table: string, table: string,
id: string, id: string,
updates: Partial<T> updates: Partial<T> | Record<string, unknown>
): Promise<SingleResult<T>> { ): Promise<SingleResult<T>> {
// Fetch old values before update // Fetch old values before update
let oldValues: Record<string, unknown> | null = null; let oldValues: Record<string, unknown> | null = null;
+2 -2
View File
@@ -43,8 +43,8 @@ import { useState, useCallback } from 'react';
* ``` * ```
*/ */
export function useEntityForm< export function useEntityForm<
TFormData extends Record<string, unknown>, TFormData extends object,
TEntity extends Record<string, unknown> = TFormData TEntity extends object = TFormData
>( >(
initialFormData: TFormData, initialFormData: TFormData,
/** /**
+2 -1
View File
@@ -144,7 +144,8 @@ ${explicitInstruction}${crudeLangInstruction}${inspirationInstruction}${avoidIns
Return a JSON object with an "ideas" array containing 5 objects, where each object has: Return a JSON object with an "ideas" array containing 5 objects, where each object has:
- name: Pack name (catchy, 3-6 words) - should evoke activities/experiences, not questions - name: Pack name (catchy, 3-6 words) - should evoke activities/experiences, not questions
- description: Brief description (1-2 sentences) focusing on the activities/experiences in the pack - description: Brief description (1-2 sentences) focusing on the activities/experiences in the pack
- icon: A single descriptive emoji - icon: An Ionicon name from this list: heart-outline, flame-outline, sparkles-outline, gift-outline, wine-outline, airplane-outline, home-outline, key-outline, flash-outline, sunny-outline, flower-outline, star-outline, dice-outline, compass-outline, bulb-outline
Make them engaging and specific to "${categoryName}".`; Make them engaging and specific to "${categoryName}".`;
+48 -20
View File
@@ -1,7 +1,8 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { supabase } from '@/config'; import { supabase } from '@/config';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { PaginationControls } from '@/components/ui/pagination';
import { import {
Table, Table,
TableBody, TableBody,
@@ -34,28 +35,42 @@ const actionColors: Record<string, string> = {
export function AuditLogsPage() { export function AuditLogsPage() {
const [logs, setLogs] = useState<AuditLog[]>([]); const [logs, setLogs] = useState<AuditLog[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(50);
const [totalCount, setTotalCount] = useState(0);
const fetchLogs = useCallback(async () => {
setLoading(true);
try {
const from = (page - 1) * pageSize;
const to = from + pageSize - 1;
const { data, error, count } = await supabase
.from('audit_logs')
.select('*', { count: 'exact' })
.order('created_at', { ascending: false })
.range(from, to);
if (error) throw error;
setLogs(data || []);
setTotalCount(count || 0);
} catch (error) {
console.error('Failed to load audit logs:', error);
} finally {
setLoading(false);
}
}, [page, pageSize]);
useEffect(() => { useEffect(() => {
const fetchLogs = async () => {
setLoading(true);
try {
const { data, error } = await supabase
.from('audit_logs')
.select('*')
.order('created_at', { ascending: false })
.limit(100);
if (error) throw error;
setLogs(data || []);
} catch (error) {
console.error('Failed to load audit logs:', error);
} finally {
setLoading(false);
}
};
fetchLogs(); fetchLogs();
}, []); }, [fetchLogs]);
useEffect(() => {
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
if (page > totalPages) {
setPage(totalPages);
}
}, [page, pageSize, totalCount]);
if (loading) { if (loading) {
return ( return (
@@ -141,6 +156,19 @@ export function AuditLogsPage() {
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
{logs.length > 0 && (
<PaginationControls
page={page}
pageSize={pageSize}
totalCount={totalCount}
onPageChange={setPage}
onPageSizeChange={(size) => {
setPage(1);
setPageSize(size);
}}
/>
)}
</div> </div>
); );
} }
+118 -81
View File
@@ -3,7 +3,9 @@ import { supabase } from '@/config';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { PaginationControls } from '@/components/ui/pagination';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription'; import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator'; import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
@@ -84,9 +86,12 @@ const statusConfig: Record<FeedbackStatus, { label: string; variant: 'default' |
export function FeedbackPage() { export function FeedbackPage() {
const [feedback, setFeedback] = useState<Feedback[]>([]); const [feedback, setFeedback] = useState<Feedback[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [typeFilter, setTypeFilter] = useState<string>('all'); const [typeFilter, setTypeFilter] = useState<string>('all');
const [statusFilter, setStatusFilter] = useState<string>('all'); const [statusFilter, setStatusFilter] = useState<string>('all');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(25);
// Detail Dialog State // Detail Dialog State
const [selectedFeedback, setSelectedFeedback] = useState<Feedback | null>(null); const [selectedFeedback, setSelectedFeedback] = useState<Feedback | null>(null);
@@ -136,9 +141,14 @@ export function FeedbackPage() {
}, },
}); });
useEffect(() => { useEffect(() => {
fetchFeedback(); fetchFeedback();
}, [fetchFeedback]); }, [fetchFeedback]);
useEffect(() => {
setPage(1);
}, [search, typeFilter, statusFilter]);
const handleOpenDetail = (item: Feedback) => { const handleOpenDetail = (item: Feedback) => {
setSelectedFeedback(item); setSelectedFeedback(item);
@@ -173,19 +183,33 @@ export function FeedbackPage() {
} }
}; };
const filteredFeedback = feedback.filter(item => { const filteredFeedback = feedback.filter(item => {
if (typeFilter !== 'all' && item.type !== typeFilter) return false; if (typeFilter !== 'all' && item.type !== typeFilter) return false;
if (statusFilter !== 'all' && item.status !== statusFilter) return false; if (statusFilter !== 'all' && item.status !== statusFilter) return false;
if (!search) return true; if (!search) return true;
const query = search.toLowerCase(); const query = search.toLowerCase();
return ( return (
item.title.toLowerCase().includes(query) || item.title.toLowerCase().includes(query) ||
item.description.toLowerCase().includes(query) || item.description.toLowerCase().includes(query) ||
item.profile?.name?.toLowerCase().includes(query) || item.profile?.name?.toLowerCase().includes(query) ||
item.profile?.email?.toLowerCase().includes(query) item.profile?.email?.toLowerCase().includes(query)
); );
}); });
const totalCount = filteredFeedback.length;
const paginatedFeedback = filteredFeedback.slice(
(page - 1) * pageSize,
page * pageSize
);
useEffect(() => {
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
if (page > totalPages) {
setPage(totalPages);
}
}, [page, pageSize, totalCount]);
const getTypeBadge = (type: FeedbackType) => { const getTypeBadge = (type: FeedbackType) => {
const config = feedbackTypeConfig[type]; const config = feedbackTypeConfig[type];
@@ -267,67 +291,80 @@ export function FeedbackPage() {
</div> </div>
{/* Table */} {/* Table */}
<div className="rounded-md border"> <div className="rounded-md border">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Type</TableHead> <TableHead>Type</TableHead>
<TableHead>Title</TableHead> <TableHead>Title</TableHead>
<TableHead>User</TableHead> <TableHead>User</TableHead>
<TableHead>Status</TableHead> <TableHead>Status</TableHead>
<TableHead>Created</TableHead> <TableHead>Created</TableHead>
<TableHead className="w-[100px]">Actions</TableHead> <TableHead className="w-[100px]">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{loading ? ( {loading ? (
<TableRow> <TableRow>
<TableCell colSpan={6} className="text-center py-8"> <TableCell colSpan={6} className="text-center py-8">
<div className="flex justify-center"><Skeleton className="h-6 w-32" /></div> <div className="flex justify-center"><Skeleton className="h-6 w-32" /></div>
</TableCell> </TableCell>
</TableRow> </TableRow>
) : filteredFeedback.length === 0 ? ( ) : filteredFeedback.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground"> <TableCell colSpan={6} className="text-center py-8 text-muted-foreground">
{search || typeFilter !== 'all' || statusFilter !== 'all' {search || typeFilter !== 'all' || statusFilter !== 'all'
? 'No feedback matches your filters.' ? 'No feedback matches your filters.'
: 'No feedback submissions yet.'} : 'No feedback submissions yet.'}
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
filteredFeedback.map((item) => ( paginatedFeedback.map((item) => (
<TableRow key={item.id} className="cursor-pointer hover:bg-muted/50" onClick={() => handleOpenDetail(item)}> <TableRow key={item.id} className="cursor-pointer hover:bg-muted/50" onClick={() => handleOpenDetail(item)}>
<TableCell>{getTypeBadge(item.type)}</TableCell> <TableCell>{getTypeBadge(item.type)}</TableCell>
<TableCell> <TableCell>
<div className="max-w-[300px]"> <div className="max-w-[300px]">
<div className="font-medium truncate">{item.title}</div> <div className="font-medium truncate">{item.title}</div>
<div className="text-sm text-muted-foreground truncate"> <div className="text-sm text-muted-foreground truncate">
{item.description.slice(0, 80)} {item.description.slice(0, 80)}
{item.description.length > 80 && '...'} {item.description.length > 80 && '...'}
</div> </div>
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="text-sm"> <div className="text-sm">
<div className="font-medium">{item.profile?.name || 'Unknown'}</div> <div className="font-medium">{item.profile?.name || 'Unknown'}</div>
<div className="text-muted-foreground">{item.profile?.email}</div> <div className="text-muted-foreground">{item.profile?.email}</div>
</div> </div>
</TableCell> </TableCell>
<TableCell>{getStatusBadge(item.status)}</TableCell> <TableCell>{getStatusBadge(item.status)}</TableCell>
<TableCell className="text-muted-foreground text-sm"> <TableCell className="text-muted-foreground text-sm">
{format(new Date(item.created_at), 'MMM d, yyyy')} {format(new Date(item.created_at), 'MMM d, yyyy')}
</TableCell> </TableCell>
<TableCell> <TableCell>
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); handleOpenDetail(item); }}> <Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); handleOpenDetail(item); }}>
View View
</Button> </Button>
</TableCell> </TableCell>
</TableRow> </TableRow>
)) ))
)} )}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
{filteredFeedback.length > 0 && (
<PaginationControls
page={page}
pageSize={pageSize}
totalCount={totalCount}
onPageChange={setPage}
onPageSizeChange={(size) => {
setPage(1);
setPageSize(size);
}}
/>
)}
{/* Detail Dialog */} {/* Detail Dialog */}
<Dialog open={!!selectedFeedback} onOpenChange={(open) => !open && setSelectedFeedback(null)}> <Dialog open={!!selectedFeedback} onOpenChange={(open) => !open && setSelectedFeedback(null)}>
+72 -15
View File
@@ -1,9 +1,10 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { supabase } from '@/config'; import { supabase } from '@/config';
import { useNavigate } from 'react-router-dom'; 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 { Card } from '@/components/ui/card'; import { Card } from '@/components/ui/card';
import { PaginationControls } from '@/components/ui/pagination';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { import {
Table, Table,
@@ -65,10 +66,16 @@ export function FlaggedMessagesPage() {
// AI Flagged state // AI Flagged state
const [aiMessages, setAiMessages] = useState<FlaggedMessage[]>([]); const [aiMessages, setAiMessages] = useState<FlaggedMessage[]>([]);
const [aiLoading, setAiLoading] = useState(true); const [aiLoading, setAiLoading] = useState(true);
const [aiPage, setAiPage] = useState(1);
const [aiPageSize, setAiPageSize] = useState(25);
const [aiTotal, setAiTotal] = useState(0);
// User Reports state // User Reports state
const [userReports, setUserReports] = useState<UserReport[]>([]); const [userReports, setUserReports] = useState<UserReport[]>([]);
const [reportsLoading, setReportsLoading] = useState(true); const [reportsLoading, setReportsLoading] = useState(true);
const [reportsPage, setReportsPage] = useState(1);
const [reportsPageSize, setReportsPageSize] = useState(25);
const [reportsTotal, setReportsTotal] = useState(0);
// Shared state // Shared state
const [selectedMessage, setSelectedMessage] = useState<FlaggedMessage | null>(null); const [selectedMessage, setSelectedMessage] = useState<FlaggedMessage | null>(null);
@@ -82,30 +89,38 @@ export function FlaggedMessagesPage() {
const [describingInline, setDescribingInline] = useState<string | null>(null); const [describingInline, setDescribingInline] = useState<string | null>(null);
// Fetch AI flagged messages // Fetch AI flagged messages
const fetchAiMessages = async () => { const fetchAiMessages = useCallback(async () => {
setAiLoading(true); setAiLoading(true);
try { try {
const { data, error } = await supabase const from = (aiPage - 1) * aiPageSize;
const to = from + aiPageSize - 1;
const { data, error, count } = await supabase
.from('messages') .from('messages')
.select('id, created_at, user_id, match_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', { count: 'exact' })
.eq('moderation_status', 'flagged') .eq('moderation_status', 'flagged')
.order('created_at', { ascending: false }); .order('created_at', { ascending: false })
.range(from, to);
if (error) throw error; if (error) throw error;
setAiMessages(data || []); setAiMessages(data || []);
setAiTotal(count || 0);
} catch (error) { } catch (error) {
console.error('Failed to load flagged messages:', error); console.error('Failed to load flagged messages:', error);
toast.error('Failed to load messages'); toast.error('Failed to load messages');
} finally { } finally {
setAiLoading(false); setAiLoading(false);
} }
}; }, [aiPage, aiPageSize]);
// Fetch user reports // Fetch user reports
const fetchUserReports = async () => { const fetchUserReports = useCallback(async () => {
setReportsLoading(true); setReportsLoading(true);
try { try {
const { data, error } = await supabase const from = (reportsPage - 1) * reportsPageSize;
const to = from + reportsPageSize - 1;
const { data, error, count } = await supabase
.from('message_reports') .from('message_reports')
.select(` .select(`
id, id,
@@ -116,24 +131,40 @@ export function FlaggedMessagesPage() {
created_at, created_at,
message:messages(id, user_id, match_id, content, media_path, media_type), message:messages(id, user_id, match_id, content, media_path, media_type),
reporter_profile:profiles!reporter_id(name, email) reporter_profile:profiles!reporter_id(name, email)
`) `, { count: 'exact' })
.eq('status', 'pending') .eq('status', 'pending')
.order('created_at', { ascending: false }); .order('created_at', { ascending: false })
.range(from, to);
if (error) throw error; if (error) throw error;
setUserReports((data || []) as unknown as UserReport[]); setUserReports((data || []) as unknown as UserReport[]);
setReportsTotal(count || 0);
} catch (error) { } catch (error) {
console.error('Failed to load user reports:', error); console.error('Failed to load user reports:', error);
toast.error('Failed to load reports'); toast.error('Failed to load reports');
} finally { } finally {
setReportsLoading(false); setReportsLoading(false);
} }
}; }, [reportsPage, reportsPageSize]);
useEffect(() => { useEffect(() => {
fetchAiMessages(); fetchAiMessages();
fetchUserReports(); fetchUserReports();
}, []); }, [fetchAiMessages, fetchUserReports]);
useEffect(() => {
const totalPages = Math.max(1, Math.ceil(aiTotal / aiPageSize));
if (aiPage > totalPages) {
setAiPage(totalPages);
}
}, [aiPage, aiPageSize, aiTotal]);
useEffect(() => {
const totalPages = Math.max(1, Math.ceil(reportsTotal / reportsPageSize));
if (reportsPage > totalPages) {
setReportsPage(totalPages);
}
}, [reportsPage, reportsPageSize, reportsTotal]);
// 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) => {
@@ -296,8 +327,8 @@ export function FlaggedMessagesPage() {
if (error) throw error; if (error) throw error;
toast.success('Message marked as safe'); toast.success('Message marked as safe');
setAiMessages(prev => prev.filter(m => m.id !== messageId));
if (selectedMessage?.id === messageId) setSelectedMessage(null); if (selectedMessage?.id === messageId) setSelectedMessage(null);
fetchAiMessages();
} catch (error) { } catch (error) {
toast.error('Failed to update status'); toast.error('Failed to update status');
} finally { } finally {
@@ -316,8 +347,8 @@ export function FlaggedMessagesPage() {
if (error) throw error; if (error) throw error;
toast.success('Report dismissed'); toast.success('Report dismissed');
setUserReports(prev => prev.filter(r => r.id !== reportId));
if (selectedReport?.id === reportId) setSelectedReport(null); if (selectedReport?.id === reportId) setSelectedReport(null);
fetchUserReports();
} catch (error) { } catch (error) {
toast.error('Failed to dismiss report'); toast.error('Failed to dismiss report');
} finally { } finally {
@@ -336,8 +367,8 @@ export function FlaggedMessagesPage() {
if (error) throw error; if (error) throw error;
toast.success('Report marked as reviewed'); toast.success('Report marked as reviewed');
setUserReports(prev => prev.filter(r => r.id !== reportId));
if (selectedReport?.id === reportId) setSelectedReport(null); if (selectedReport?.id === reportId) setSelectedReport(null);
fetchUserReports();
} catch (error) { } catch (error) {
toast.error('Failed to update report'); toast.error('Failed to update report');
} finally { } finally {
@@ -491,6 +522,19 @@ export function FlaggedMessagesPage() {
</TableBody> </TableBody>
</Table> </Table>
</Card> </Card>
{aiTotal > 0 && (
<PaginationControls
page={aiPage}
pageSize={aiPageSize}
totalCount={aiTotal}
onPageChange={setAiPage}
onPageSizeChange={(size) => {
setAiPage(1);
setAiPageSize(size);
}}
/>
)}
</TabsContent> </TabsContent>
<TabsContent value="user-reports" className="space-y-4"> <TabsContent value="user-reports" className="space-y-4">
@@ -579,6 +623,19 @@ export function FlaggedMessagesPage() {
</TableBody> </TableBody>
</Table> </Table>
</Card> </Card>
{reportsTotal > 0 && (
<PaginationControls
page={reportsPage}
pageSize={reportsPageSize}
totalCount={reportsTotal}
onPageChange={setReportsPage}
onPageSizeChange={(size) => {
setReportsPage(1);
setReportsPageSize(size);
}}
/>
)}
</TabsContent> </TabsContent>
</Tabs> </Tabs>
+283 -205
View File
@@ -1,10 +1,13 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { supabase } from '@/config'; import { supabase } from '@/config';
import { auditedSupabase } from '@/hooks/useAuditedSupabase'; import { auditedSupabase } from '@/hooks/useAuditedSupabase';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { PaginationControls } from '@/components/ui/pagination';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { import {
Table, Table,
@@ -61,9 +64,13 @@ function generateCode(length: number = 8): string {
} }
export function RedemptionCodesPage() { export function RedemptionCodesPage() {
const [codes, setCodes] = useState<RedemptionCode[]>([]); const [codes, setCodes] = useState<RedemptionCode[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(25);
const [totalCount, setTotalCount] = useState(0);
// Add Code State // Add Code State
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false); const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
@@ -76,66 +83,114 @@ export function RedemptionCodesPage() {
}); });
// View Redemptions State // View Redemptions State
const [viewingCode, setViewingCode] = useState<RedemptionCode | null>(null); const [viewingCode, setViewingCode] = useState<RedemptionCode | null>(null);
const [redemptions, setRedemptions] = useState<CodeRedemption[]>([]); const [redemptions, setRedemptions] = useState<CodeRedemption[]>([]);
const [loadingRedemptions, setLoadingRedemptions] = useState(false); const [loadingRedemptions, setLoadingRedemptions] = useState(false);
const [redemptionsPage, setRedemptionsPage] = useState(1);
const [redemptionsPageSize, setRedemptionsPageSize] = useState(10);
const [redemptionsTotal, setRedemptionsTotal] = useState(0);
// Initial load
useEffect(() => {
fetchCodes();
}, []);
const fetchCodes = async () => { const fetchCodes = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
const { data, error } = await supabase const from = (page - 1) * pageSize;
.from('redemption_codes') const to = from + pageSize - 1;
.select('*') const trimmedSearch = search.trim();
.order('created_at', { ascending: false });
let query = supabase
.from('redemption_codes')
.select('*', { count: 'exact' })
.order('created_at', { ascending: false });
if (trimmedSearch) {
query = query.or(`code.ilike.%${trimmedSearch}%,description.ilike.%${trimmedSearch}%`);
}
const { data, error, count } = await query.range(from, to);
if (error) throw error;
setCodes(data || []);
setTotalCount(count || 0);
} catch (error: any) {
console.error('Failed to load codes:', error);
toast.error("Failed to load redemption codes");
} finally {
setLoading(false);
}
}, [page, pageSize, search]);
if (error) throw error; const fetchRedemptions = useCallback(async (codeId: string) => {
setCodes(data || []); setLoadingRedemptions(true);
} catch (error: any) { try {
console.error('Failed to load codes:', error); const from = (redemptionsPage - 1) * redemptionsPageSize;
toast.error("Failed to load redemption codes"); const to = from + redemptionsPageSize - 1;
} finally {
setLoading(false); const { data: redemptionData, error: redemptionError, count } = await supabase
} .from('code_redemptions')
}; .select('id, user_id, redeemed_at', { count: 'exact' })
.eq('code_id', codeId)
const fetchRedemptions = async (codeId: string) => { .order('redeemed_at', { ascending: false })
setLoadingRedemptions(true); .range(from, to);
try {
const { data: redemptionData, error: redemptionError } = await supabase if (redemptionError) throw redemptionError;
.from('code_redemptions')
.select('id, user_id, redeemed_at') setRedemptionsTotal(count || 0);
.eq('code_id', codeId)
.order('redeemed_at', { ascending: false }); if (redemptionData && redemptionData.length > 0) {
const userIds = redemptionData.map(r => r.user_id);
if (redemptionError) throw redemptionError; const { data: profileData } = await supabase
.from('profiles')
if (redemptionData && redemptionData.length > 0) { .select('id, name, email')
const userIds = redemptionData.map(r => r.user_id); .in('id', userIds);
const { data: profileData } = await supabase
.from('profiles') const joined = redemptionData.map(r => ({
.select('id, name, email') ...r,
.in('id', userIds); profile: profileData?.find(p => p.id === r.user_id)
}));
const joined = redemptionData.map(r => ({ setRedemptions(joined);
...r, } else {
profile: profileData?.find(p => p.id === r.user_id) setRedemptions([]);
})); }
setRedemptions(joined); } catch (error: any) {
} else { console.error('Failed to load redemptions:', error);
setRedemptions([]); toast.error("Failed to load redemptions");
} } finally {
} catch (error: any) { setLoadingRedemptions(false);
console.error('Failed to load redemptions:', error); }
toast.error("Failed to load redemptions"); }, [redemptionsPage, redemptionsPageSize]);
} finally {
setLoadingRedemptions(false); useEffect(() => {
} fetchCodes();
}; }, [fetchCodes]);
useEffect(() => {
setPage(1);
}, [search]);
useEffect(() => {
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
if (page > totalPages) {
setPage(totalPages);
}
}, [page, pageSize, totalCount]);
useEffect(() => {
if (viewingCode) {
fetchRedemptions(viewingCode.id);
}
}, [viewingCode, fetchRedemptions]);
useEffect(() => {
const totalPages = Math.max(1, Math.ceil(redemptionsTotal / redemptionsPageSize));
if (redemptionsPage > totalPages) {
setRedemptionsPage(totalPages);
}
}, [redemptionsPage, redemptionsPageSize, redemptionsTotal]);
const handleAddCode = async () => { const handleAddCode = async () => {
if (!formData.code.trim()) { if (!formData.code.trim()) {
@@ -224,19 +279,14 @@ export function RedemptionCodesPage() {
setIsAddDialogOpen(true); setIsAddDialogOpen(true);
}; };
const handleViewRedemptions = (code: RedemptionCode) => { const handleViewRedemptions = (code: RedemptionCode) => {
setViewingCode(code); setViewingCode(code);
fetchRedemptions(code.id); setRedemptionsPage(1);
}; };
const filteredCodes = codes.filter(code => { const filteredCodes = codes;
if (!search) return true;
const query = search.toLowerCase();
return (
code.code.toLowerCase().includes(query) ||
code.description?.toLowerCase().includes(query)
);
});
const getStatusBadge = (code: RedemptionCode) => { const getStatusBadge = (code: RedemptionCode) => {
if (!code.is_active) { if (!code.is_active) {
@@ -344,101 +394,115 @@ export function RedemptionCodesPage() {
</div> </div>
</div> </div>
<div className="rounded-md border"> <div className="rounded-md border">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Code</TableHead> <TableHead>Code</TableHead>
<TableHead>Description</TableHead> <TableHead>Description</TableHead>
<TableHead>Usage</TableHead> <TableHead>Usage</TableHead>
<TableHead>Expires</TableHead> <TableHead>Expires</TableHead>
<TableHead>Status</TableHead> <TableHead>Status</TableHead>
<TableHead>Created</TableHead> <TableHead>Created</TableHead>
<TableHead className="w-[150px]">Actions</TableHead> <TableHead className="w-[150px]">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{loading ? ( {loading ? (
<TableRow> <TableRow>
<TableCell colSpan={7} className="text-center py-8"> <TableCell colSpan={7} className="text-center py-8">
<div className="flex justify-center"><Skeleton className="h-6 w-32" /></div> <div className="flex justify-center"><Skeleton className="h-6 w-32" /></div>
</TableCell> </TableCell>
</TableRow> </TableRow>
) : filteredCodes.length === 0 ? ( ) : filteredCodes.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground"> <TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
{search ? 'No codes match your search.' : 'No redemption codes yet. Generate your first one!'} {search ? 'No codes match your search.' : 'No redemption codes yet. Generate your first one!'}
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
filteredCodes.map((code) => ( filteredCodes.map((code) => (
<TableRow key={code.id}> <TableRow key={code.id}>
<TableCell> <TableCell>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<code className="font-mono font-medium text-sm bg-muted px-2 py-1 rounded"> <code className="font-mono font-medium text-sm bg-muted px-2 py-1 rounded">
{code.code} {code.code}
</code> </code>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-6 w-6" className="h-6 w-6"
onClick={() => copyToClipboard(code.code)} onClick={() => copyToClipboard(code.code)}
title="Copy code" title="Copy code"
> >
<Copy className="h-3 w-3" /> <Copy className="h-3 w-3" />
</Button> </Button>
</div> </div>
</TableCell> </TableCell>
<TableCell className="text-muted-foreground"> <TableCell className="text-muted-foreground">
{code.description || '—'} {code.description || '—'}
</TableCell> </TableCell>
<TableCell> <TableCell>
<span className="font-medium">{code.current_uses}</span> <span className="font-medium">{code.current_uses}</span>
<span className="text-muted-foreground"> / {code.max_uses}</span> <span className="text-muted-foreground"> / {code.max_uses}</span>
</TableCell> </TableCell>
<TableCell className="text-muted-foreground text-sm"> <TableCell className="text-muted-foreground text-sm">
{code.expires_at {code.expires_at
? format(new Date(code.expires_at), 'MMM d, yyyy HH:mm') ? format(new Date(code.expires_at), 'MMM d, yyyy HH:mm')
: 'Never'} : 'Never'}
</TableCell> </TableCell>
<TableCell> <TableCell>
{getStatusBadge(code)} {getStatusBadge(code)}
</TableCell> </TableCell>
<TableCell className="text-muted-foreground text-sm"> <TableCell className="text-muted-foreground text-sm">
{format(new Date(code.created_at), 'MMM d, yyyy')} {format(new Date(code.created_at), 'MMM d, yyyy')}
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => handleViewRedemptions(code)} onClick={() => handleViewRedemptions(code)}
title="View redemptions" title="View redemptions"
> >
<Users className="h-4 w-4" /> <Users className="h-4 w-4" />
</Button> </Button>
<Switch <Switch
checked={code.is_active} checked={code.is_active}
onCheckedChange={() => handleToggleActive(code)} onCheckedChange={() => handleToggleActive(code)}
title={code.is_active ? "Deactivate" : "Activate"} title={code.is_active ? "Deactivate" : "Activate"}
/> />
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="text-destructive hover:text-destructive hover:bg-destructive/10" className="text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => handleDeleteCode(code)} onClick={() => handleDeleteCode(code)}
title="Delete code" title="Delete code"
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
</div> </div>
</TableCell> </TableCell>
</TableRow> </TableRow>
)) ))
)} )}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
{filteredCodes.length > 0 && (
<PaginationControls
page={page}
pageSize={pageSize}
totalCount={totalCount}
onPageChange={setPage}
onPageSizeChange={(size) => {
setPage(1);
setPageSize(size);
}}
/>
)}
{/* View Redemptions Dialog */} {/* View Redemptions Dialog */}
<Dialog open={!!viewingCode} onOpenChange={(open) => !open && setViewingCode(null)}> <Dialog open={!!viewingCode} onOpenChange={(open) => !open && setViewingCode(null)}>
@@ -451,42 +515,56 @@ export function RedemptionCodesPage() {
{viewingCode?.current_uses || 0} of {viewingCode?.max_uses} uses {viewingCode?.current_uses || 0} of {viewingCode?.max_uses} uses
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="max-h-[300px] overflow-y-auto"> <div className="max-h-[300px] overflow-y-auto">
{loadingRedemptions ? ( {loadingRedemptions ? (
<div className="flex justify-center py-8"> <div className="flex justify-center py-8">
<Skeleton className="h-6 w-32" /> <Skeleton className="h-6 w-32" />
</div> </div>
) : redemptions.length === 0 ? ( ) : redemptions.length === 0 ? (
<p className="text-center py-8 text-muted-foreground"> <p className="text-center py-8 text-muted-foreground">
No one has redeemed this code yet. No one has redeemed this code yet.
</p> </p>
) : ( ) : (
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>User</TableHead> <TableHead>User</TableHead>
<TableHead>Redeemed At</TableHead> <TableHead>Redeemed At</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{redemptions.map((r) => ( {redemptions.map((r) => (
<TableRow key={r.id}> <TableRow key={r.id}>
<TableCell> <TableCell>
<div> <div>
<div className="font-medium">{r.profile?.name || 'Unknown'}</div> <div className="font-medium">{r.profile?.name || 'Unknown'}</div>
<div className="text-xs text-muted-foreground">{r.profile?.email}</div> <div className="text-xs text-muted-foreground">{r.profile?.email}</div>
</div> </div>
</TableCell> </TableCell>
<TableCell className="text-muted-foreground text-sm"> <TableCell className="text-muted-foreground text-sm">
{format(new Date(r.redeemed_at), 'MMM d, yyyy HH:mm')} {format(new Date(r.redeemed_at), 'MMM d, yyyy HH:mm')}
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
</Table> </Table>
)} )}
</div> </div>
<DialogFooter>
{redemptionsTotal > 0 && (
<PaginationControls
page={redemptionsPage}
pageSize={redemptionsPageSize}
totalCount={redemptionsTotal}
onPageChange={setRedemptionsPage}
onPageSizeChange={(size) => {
setRedemptionsPage(1);
setRedemptionsPageSize(size);
}}
/>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setViewingCode(null)}>Close</Button> <Button variant="outline" onClick={() => setViewingCode(null)}>Close</Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -6,6 +6,7 @@ import { Skeleton } from '@/components/ui/skeleton';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription'; import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator'; import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
import { PaginationControls } from '@/components/ui/pagination';
import { import {
Table, Table,
TableBody, TableBody,
@@ -121,6 +122,18 @@ export function UserActivityPage() {
const [matches, setMatches] = useState<MatchActivity[]>([]); const [matches, setMatches] = useState<MatchActivity[]>([]);
const [messages, setMessages] = useState<MessageActivity[]>([]); const [messages, setMessages] = useState<MessageActivity[]>([]);
const [signups, setSignups] = useState<SignupActivity[]>([]); const [signups, setSignups] = useState<SignupActivity[]>([]);
const [responsesPage, setResponsesPage] = useState(1);
const [responsesPageSize, setResponsesPageSize] = useState(25);
const [responsesTotal, setResponsesTotal] = useState(0);
const [matchesPage, setMatchesPage] = useState(1);
const [matchesPageSize, setMatchesPageSize] = useState(25);
const [matchesTotal, setMatchesTotal] = useState(0);
const [messagesPage, setMessagesPage] = useState(1);
const [messagesPageSize, setMessagesPageSize] = useState(25);
const [messagesTotal, setMessagesTotal] = useState(0);
const [signupsPage, setSignupsPage] = useState(1);
const [signupsPageSize, setSignupsPageSize] = useState(25);
const [signupsTotal, setSignupsTotal] = useState(0);
const [loading, setLoading] = useState({ const [loading, setLoading] = useState({
responses: true, responses: true,
matches: true, matches: true,
@@ -132,7 +145,10 @@ export function UserActivityPage() {
const fetchResponses = useCallback(async () => { const fetchResponses = useCallback(async () => {
setLoading(prev => ({ ...prev, responses: true })); setLoading(prev => ({ ...prev, responses: true }));
try { try {
const { data, error } = await supabase const from = (responsesPage - 1) * responsesPageSize;
const to = from + responsesPageSize - 1;
const { data, error, count } = await supabase
.from('responses') .from('responses')
.select(` .select(`
id, user_id, answer, created_at, id, user_id, answer, created_at,
@@ -141,12 +157,14 @@ export function UserActivityPage() {
id, text, id, text,
pack:question_packs(name) pack:question_packs(name)
) )
`) `, { count: 'exact' })
.order('created_at', { ascending: false }) .order('created_at', { ascending: false })
.limit(50); .range(from, to);
if (error) throw error; if (error) throw error;
setResponsesTotal(count || 0);
// Transform data to match our types (Supabase returns nested objects, not arrays for 1:1 relations) // Transform data to match our types (Supabase returns nested objects, not arrays for 1:1 relations)
const transformed: ResponseActivity[] = (data || []).map((item: any) => ({ const transformed: ResponseActivity[] = (data || []).map((item: any) => ({
id: item.id, id: item.id,
@@ -167,23 +185,28 @@ export function UserActivityPage() {
} finally { } finally {
setLoading(prev => ({ ...prev, responses: false })); setLoading(prev => ({ ...prev, responses: false }));
} }
}, []); }, [responsesPage, responsesPageSize]);
// Fetch matches with couple profiles // Fetch matches with couple profiles
const fetchMatches = useCallback(async () => { const fetchMatches = useCallback(async () => {
setLoading(prev => ({ ...prev, matches: true })); setLoading(prev => ({ ...prev, matches: true }));
try { try {
const { data: matchesData, error: matchesError } = await supabase const from = (matchesPage - 1) * matchesPageSize;
const to = from + matchesPageSize - 1;
const { data: matchesData, error: matchesError, count } = await supabase
.from('matches') .from('matches')
.select(` .select(`
id, couple_id, match_type, is_new, created_at, id, couple_id, match_type, is_new, created_at,
question:questions(id, text) question:questions(id, text)
`) `, { count: 'exact' })
.order('created_at', { ascending: false }) .order('created_at', { ascending: false })
.limit(50); .range(from, to);
if (matchesError) throw matchesError; if (matchesError) throw matchesError;
setMatchesTotal(count || 0);
// Get unique couple IDs and fetch profiles // Get unique couple IDs and fetch profiles
const coupleIds = [...new Set((matchesData || []).map((m: any) => m.couple_id))]; const coupleIds = [...new Set((matchesData || []).map((m: any) => m.couple_id))];
if (coupleIds.length > 0) { if (coupleIds.length > 0) {
@@ -223,14 +246,17 @@ export function UserActivityPage() {
} finally { } finally {
setLoading(prev => ({ ...prev, matches: false })); setLoading(prev => ({ ...prev, matches: false }));
} }
}, []); }, [matchesPage, matchesPageSize]);
// Fetch messages // Fetch messages
const fetchMessages = useCallback(async () => { const fetchMessages = useCallback(async () => {
setLoading(prev => ({ ...prev, messages: true })); setLoading(prev => ({ ...prev, messages: true }));
try { try {
const from = (messagesPage - 1) * messagesPageSize;
const to = from + messagesPageSize - 1;
// Fetch messages with match data // Fetch messages with match data
const { data: messagesData, error: messagesError } = await supabase const { data: messagesData, error: messagesError, count } = await supabase
.from('messages') .from('messages')
.select(` .select(`
id, user_id, content, media_path, media_type, created_at, id, user_id, content, media_path, media_type, created_at,
@@ -238,12 +264,14 @@ export function UserActivityPage() {
id, id,
question:questions(text) question:questions(text)
) )
`) `, { count: 'exact' })
.order('created_at', { ascending: false }) .order('created_at', { ascending: false })
.limit(50); .range(from, to);
if (messagesError) throw messagesError; if (messagesError) throw messagesError;
setMessagesTotal(count || 0);
// Get unique user IDs and fetch profiles // Get unique user IDs and fetch profiles
const userIds = [...new Set((messagesData || []).map((m: any) => m.user_id))]; const userIds = [...new Set((messagesData || []).map((m: any) => m.user_id))];
let profilesMap: Record<string, { id: string; name: string | null; email: string | null }> = {}; let profilesMap: Record<string, { id: string; name: string | null; email: string | null }> = {};
@@ -283,27 +311,31 @@ export function UserActivityPage() {
} finally { } finally {
setLoading(prev => ({ ...prev, messages: false })); setLoading(prev => ({ ...prev, messages: false }));
} }
}, []); }, [messagesPage, messagesPageSize]);
// Fetch signups // Fetch signups
const fetchSignups = useCallback(async () => { const fetchSignups = useCallback(async () => {
setLoading(prev => ({ ...prev, signups: true })); setLoading(prev => ({ ...prev, signups: true }));
try { try {
const { data, error } = await supabase const from = (signupsPage - 1) * signupsPageSize;
const to = from + signupsPageSize - 1;
const { data, error, count } = await supabase
.from('profiles') .from('profiles')
.select('id, name, email, avatar_url, created_at, onboarding_completed, couple_id') .select('id, name, email, avatar_url, created_at, onboarding_completed, couple_id', { count: 'exact' })
.order('created_at', { ascending: false }) .order('created_at', { ascending: false })
.limit(50); .range(from, to);
if (error) throw error; if (error) throw error;
setSignups(data || []); setSignups(data || []);
setSignupsTotal(count || 0);
} catch (error) { } catch (error) {
console.error('Failed to load signups:', error); console.error('Failed to load signups:', error);
toast.error("Failed to load signups"); toast.error("Failed to load signups");
} finally { } finally {
setLoading(prev => ({ ...prev, signups: false })); setLoading(prev => ({ ...prev, signups: false }));
} }
}, []); }, [signupsPage, signupsPageSize]);
// Real-time subscriptions // Real-time subscriptions
const { status: responsesStatus } = useRealtimeSubscription({ const { status: responsesStatus } = useRealtimeSubscription({
@@ -338,6 +370,34 @@ export function UserActivityPage() {
fetchSignups(); fetchSignups();
}, [fetchResponses, fetchMatches, fetchMessages, fetchSignups]); }, [fetchResponses, fetchMatches, fetchMessages, fetchSignups]);
useEffect(() => {
const totalPages = Math.max(1, Math.ceil(responsesTotal / responsesPageSize));
if (responsesPage > totalPages) {
setResponsesPage(totalPages);
}
}, [responsesPage, responsesPageSize, responsesTotal]);
useEffect(() => {
const totalPages = Math.max(1, Math.ceil(matchesTotal / matchesPageSize));
if (matchesPage > totalPages) {
setMatchesPage(totalPages);
}
}, [matchesPage, matchesPageSize, matchesTotal]);
useEffect(() => {
const totalPages = Math.max(1, Math.ceil(messagesTotal / messagesPageSize));
if (messagesPage > totalPages) {
setMessagesPage(totalPages);
}
}, [messagesPage, messagesPageSize, messagesTotal]);
useEffect(() => {
const totalPages = Math.max(1, Math.ceil(signupsTotal / signupsPageSize));
if (signupsPage > totalPages) {
setSignupsPage(totalPages);
}
}, [signupsPage, signupsPageSize, signupsTotal]);
// Get current realtime status based on active tab // Get current realtime status based on active tab
const getCurrentRealtimeStatus = () => { const getCurrentRealtimeStatus = () => {
switch (activeTab) { switch (activeTab) {
@@ -461,6 +521,19 @@ export function UserActivityPage() {
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
{responsesTotal > 0 && (
<PaginationControls
page={responsesPage}
pageSize={responsesPageSize}
totalCount={responsesTotal}
onPageChange={setResponsesPage}
onPageSizeChange={(size) => {
setResponsesPage(1);
setResponsesPageSize(size);
}}
/>
)}
</TabsContent> </TabsContent>
{/* Matches Tab */} {/* Matches Tab */}
@@ -535,6 +608,19 @@ export function UserActivityPage() {
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
{matchesTotal > 0 && (
<PaginationControls
page={matchesPage}
pageSize={matchesPageSize}
totalCount={matchesTotal}
onPageChange={setMatchesPage}
onPageSizeChange={(size) => {
setMatchesPage(1);
setMatchesPageSize(size);
}}
/>
)}
</TabsContent> </TabsContent>
{/* Messages Tab */} {/* Messages Tab */}
@@ -593,6 +679,19 @@ export function UserActivityPage() {
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
{messagesTotal > 0 && (
<PaginationControls
page={messagesPage}
pageSize={messagesPageSize}
totalCount={messagesTotal}
onPageChange={setMessagesPage}
onPageSizeChange={(size) => {
setMessagesPage(1);
setMessagesPageSize(size);
}}
/>
)}
</TabsContent> </TabsContent>
{/* Signups Tab */} {/* Signups Tab */}
@@ -666,6 +765,19 @@ export function UserActivityPage() {
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
{signupsTotal > 0 && (
<PaginationControls
page={signupsPage}
pageSize={signupsPageSize}
totalCount={signupsTotal}
onPageChange={setSignupsPage}
onPageSizeChange={(size) => {
setSignupsPage(1);
setSignupsPageSize(size);
}}
/>
)}
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</div> </div>
+34 -1
View File
@@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { PaginationControls } from '@/components/ui/pagination';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { import {
@@ -58,6 +59,8 @@ export function AdminsPage() {
const [admins, setAdmins] = useState<AdminUser[]>([]); const [admins, setAdmins] = useState<AdminUser[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(25);
// Add Admin State // Add Admin State
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false); const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
@@ -280,6 +283,23 @@ export function AdminsPage() {
); );
}); });
const totalCount = filteredAdmins.length;
const paginatedAdmins = filteredAdmins.slice(
(page - 1) * pageSize,
page * pageSize
);
useEffect(() => {
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
if (page > totalPages) {
setPage(totalPages);
}
}, [page, pageSize, totalCount]);
useEffect(() => {
setPage(1);
}, [search]);
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">
@@ -509,7 +529,7 @@ export function AdminsPage() {
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
filteredAdmins.map((admin) => ( paginatedAdmins.map((admin) => (
<TableRow key={admin.user_id}> <TableRow key={admin.user_id}>
<TableCell> <TableCell>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -590,6 +610,19 @@ export function AdminsPage() {
</Table> </Table>
</div> </div>
{filteredAdmins.length > 0 && (
<PaginationControls
page={page}
pageSize={pageSize}
totalCount={totalCount}
onPageChange={setPage}
onPageSizeChange={(size) => {
setPage(1);
setPageSize(size);
}}
/>
)}
{/* Edit Permissions Dialog */} {/* Edit Permissions Dialog */}
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}> <Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<DialogContent className="sm:max-w-[500px]"> <DialogContent className="sm:max-w-[500px]">
+57 -22
View File
@@ -8,8 +8,9 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { EmojiPicker } from '@/components/ui/emoji-picker'; import { IconPicker, IconPreview } from '@/components/ui/icon-picker';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { PaginationControls } from '@/components/ui/pagination';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -53,14 +54,17 @@ export function CategoriesPage() {
const [categories, setCategories] = useState<Category[]>([]); const [categories, setCategories] = useState<Category[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [aiIdeasOpen, setAiIdeasOpen] = useState(false); const [aiIdeasOpen, setAiIdeasOpen] = useState(false);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(12);
const [totalCount, setTotalCount] = useState(0);
// Form state using the new hook // Form state using the new hook
const form = useEntityForm<CategoryFormData, Category>( const form = useEntityForm<CategoryFormData, Category>(
{ name: '', description: '', icon: '📚' }, { name: '', description: '', icon: 'bookmark-outline' },
(category) => ({ (category) => ({
name: category.name, name: category.name,
description: category.description || '', description: category.description || '',
icon: category.icon || '📚', icon: category.icon || 'bookmark-outline',
}) })
); );
@@ -71,25 +75,34 @@ export function CategoriesPage() {
const fetchCategories = useCallback(async () => { const fetchCategories = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
// Fetch categories with pack count const from = (page - 1) * pageSize;
const { data: cats, error } = await supabase const to = from + pageSize - 1;
const { data: cats, error, count } = await supabase
.from('categories') .from('categories')
.select('*') .select('*', { count: 'exact' })
.order('sort_order', { ascending: true }); .order('sort_order', { ascending: true })
.range(from, to);
if (error) throw error; if (error) throw error;
// Fetch pack counts per category setTotalCount(count || 0);
const { data: packs } = await supabase
.from('question_packs')
.select('category_id');
const categoryIds = (cats || []).map(c => c.id);
const packCounts: Record<string, number> = {}; const packCounts: Record<string, number> = {};
packs?.forEach(p => {
if (p.category_id) { if (categoryIds.length > 0) {
packCounts[p.category_id] = (packCounts[p.category_id] || 0) + 1; const { data: packs } = await supabase
} .from('question_packs')
}); .select('category_id')
.in('category_id', categoryIds);
packs?.forEach(p => {
if (p.category_id) {
packCounts[p.category_id] = (packCounts[p.category_id] || 0) + 1;
}
});
}
setCategories( setCategories(
(cats || []).map(c => ({ (cats || []).map(c => ({
@@ -103,12 +116,19 @@ export function CategoriesPage() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, []); }, [page, pageSize]);
useEffect(() => { useEffect(() => {
fetchCategories(); fetchCategories();
}, [fetchCategories]); }, [fetchCategories]);
useEffect(() => {
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
if (page > totalPages) {
setPage(totalPages);
}
}, [page, pageSize, totalCount]);
// ============================================================================= // =============================================================================
// Handlers // Handlers
// ============================================================================= // =============================================================================
@@ -118,7 +138,7 @@ export function CategoriesPage() {
form.openCreateWith({ form.openCreateWith({
name: idea.name, name: idea.name,
description: idea.description || '', description: idea.description || '',
icon: idea.icon || '📚', icon: idea.icon || 'bookmark-outline',
}); });
}; };
@@ -143,7 +163,7 @@ export function CategoriesPage() {
} else { } else {
const { error } = await auditedSupabase.insert('categories', { const { error } = await auditedSupabase.insert('categories', {
...data, ...data,
sort_order: categories.length, sort_order: totalCount,
}); });
if (error) throw error; if (error) throw error;
toast.success('Category created'); toast.success('Category created');
@@ -261,9 +281,9 @@ export function CategoriesPage() {
<div className="space-y-4 py-4"> <div className="space-y-4 py-4">
<div className="space-y-2"> <div className="space-y-2">
<Label>Icon</Label> <Label>Icon</Label>
<EmojiPicker <IconPicker
value={form.formData.icon} value={form.formData.icon}
onChange={(emoji) => form.setField('icon', emoji)} onChange={(iconName) => form.setField('icon', iconName)}
/> />
</div> </div>
@@ -363,7 +383,9 @@ export function CategoriesPage() {
<ChevronDown className="h-4 w-4" /> <ChevronDown className="h-4 w-4" />
</Button> </Button>
</div> </div>
<div className="text-4xl mb-2">{category.icon || '📚'}</div> <div className="text-4xl mb-2">
<IconPreview value={category.icon} fallback="bookmark-outline" className="text-4xl" />
</div>
</div> </div>
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex gap-1"> <div className="opacity-0 group-hover:opacity-100 transition-opacity flex gap-1">
<Button <Button
@@ -404,6 +426,19 @@ export function CategoriesPage() {
</div> </div>
)} )}
{categories.length > 0 && (
<PaginationControls
page={page}
pageSize={pageSize}
totalCount={totalCount}
onPageChange={setPage}
onPageSizeChange={(size) => {
setPage(1);
setPageSize(size);
}}
/>
)}
{/* AI Dialog */} {/* AI Dialog */}
<AiGeneratorDialog <AiGeneratorDialog
open={aiIdeasOpen} open={aiIdeasOpen}
@@ -0,0 +1,578 @@
import { useEffect, useState, useCallback } from 'react';
import { Link } from 'react-router-dom';
import { supabase } from '@/config';
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
import { useEntityForm } from '@/hooks/useEntityForm';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Switch } from '@/components/ui/switch';
import { IconPicker, IconPreview } from '@/components/ui/icon-picker';
import { Skeleton } from '@/components/ui/skeleton';
import { PaginationControls } from '@/components/ui/pagination';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Plus, Zap, Pencil, Trash2, Loader2, Crown, Eye, EyeOff, Flame, Heart, ChevronUp, ChevronDown } from 'lucide-react';
import { toast } from 'sonner';
// =============================================================================
// Types
// =============================================================================
interface Category {
id: string;
name: string;
}
interface DarePack {
id: string;
name: string;
description: string | null;
icon: string | null;
is_premium: boolean;
is_public: boolean;
is_explicit: boolean;
sort_order: number;
category_id: string | null;
min_intensity: number | null;
max_intensity: number | null;
avg_intensity: number | null;
created_at: string | null;
dare_count?: number;
}
interface DarePackFormData {
name: string;
description: string;
icon: string;
is_premium: boolean;
is_public: boolean;
is_explicit: boolean;
category_id: string;
}
// =============================================================================
// Component
// =============================================================================
export function DarePacksPage() {
// Data state
const [darePacks, setDarePacks] = useState<DarePack[]>([]);
const [categories, setCategories] = useState<Category[]>([]);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(12);
const [totalCount, setTotalCount] = useState(0);
// Form state using the hook
const form = useEntityForm<DarePackFormData, DarePack>(
{
name: '',
description: '',
icon: 'flame-outline',
is_premium: false,
is_public: false,
is_explicit: false,
category_id: '',
},
(pack) => ({
name: pack.name,
description: pack.description || '',
icon: pack.icon || 'flame-outline',
is_premium: pack.is_premium,
is_public: pack.is_public,
is_explicit: pack.is_explicit,
category_id: pack.category_id || '',
})
);
// =============================================================================
// Data Fetching
// =============================================================================
const fetchData = useCallback(async () => {
setLoading(true);
try {
const from = (page - 1) * pageSize;
const to = from + pageSize - 1;
// Fetch dare packs
const { data: packData, error, count } = await supabase
.from('dare_packs')
.select('*', { count: 'exact' })
.order('sort_order', { ascending: true })
.range(from, to);
if (error) throw error;
setTotalCount(count || 0);
// Fetch dare counts
const packIds = (packData || []).map(p => p.id);
const dareCounts: Record<string, number> = {};
if (packIds.length > 0) {
const { data: dares } = await supabase
.from('dares')
.select('pack_id')
.in('pack_id', packIds);
dares?.forEach(d => {
dareCounts[d.pack_id] = (dareCounts[d.pack_id] || 0) + 1;
});
}
// Fetch categories
const { data: categoryData } = await supabase
.from('categories')
.select('id, name')
.order('name');
setCategories(categoryData || []);
setDarePacks(
(packData || []).map(p => ({
...p,
dare_count: dareCounts[p.id] || 0,
}))
);
} catch (error) {
toast.error('Failed to load dare packs');
console.error(error);
} finally {
setLoading(false);
}
}, [page, pageSize]);
useEffect(() => {
fetchData();
}, [fetchData]);
useEffect(() => {
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
if (page > totalPages) {
setPage(totalPages);
}
}, [page, pageSize, totalCount]);
// =============================================================================
// Handlers
// =============================================================================
const handleSave = async () => {
if (!form.formData.name.trim()) {
toast.error('Name is required');
return;
}
form.setSaving(true);
try {
if (form.editingItem) {
const { error } = await auditedSupabase.update('dare_packs', form.editingItem.id, {
name: form.formData.name,
description: form.formData.description || null,
icon: form.formData.icon || null,
is_premium: form.formData.is_premium,
is_public: form.formData.is_public,
is_explicit: form.formData.is_explicit,
category_id: form.formData.category_id || null,
});
if (error) throw error;
toast.success('Dare pack updated');
} else {
const { error } = await auditedSupabase.insert('dare_packs', {
name: form.formData.name,
description: form.formData.description || null,
icon: form.formData.icon || null,
is_premium: form.formData.is_premium,
is_public: form.formData.is_public,
is_explicit: form.formData.is_explicit,
category_id: form.formData.category_id || null,
sort_order: totalCount,
});
if (error) throw error;
toast.success('Dare pack created');
}
form.close();
fetchData();
} catch (error) {
toast.error('Failed to save dare pack');
console.error(error);
} finally {
form.setSaving(false);
}
};
const handleDelete = async (pack: DarePack) => {
if (!confirm(`Delete dare pack "${pack.name}"? This will delete all dares in this pack.`)) {
return;
}
try {
const { error } = await auditedSupabase.delete('dare_packs', pack.id);
if (error) throw error;
toast.success('Dare pack deleted');
fetchData();
} catch (error) {
toast.error('Failed to delete dare pack');
console.error(error);
}
};
const handleMove = async (pack: DarePack, direction: 'up' | 'down') => {
const currentIndex = darePacks.findIndex(p => p.id === pack.id);
const targetIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1;
if (targetIndex < 0 || targetIndex >= darePacks.length) return;
const targetPack = darePacks[targetIndex];
try {
const currentOrder = pack.sort_order ?? currentIndex;
const targetOrder = targetPack.sort_order ?? targetIndex;
await Promise.all([
auditedSupabase.update('dare_packs', pack.id, { sort_order: targetOrder }),
auditedSupabase.update('dare_packs', targetPack.id, { sort_order: currentOrder }),
]);
// Optimistic update
const newPacks = [...darePacks];
[newPacks[currentIndex], newPacks[targetIndex]] = [newPacks[targetIndex], newPacks[currentIndex]];
setDarePacks(newPacks);
} catch (error) {
toast.error('Failed to reorder dare packs');
console.error(error);
fetchData();
}
};
const getIntensityColor = (avg: number | null) => {
if (avg === null) return 'bg-muted';
if (avg <= 1.5) return 'bg-green-500';
if (avg <= 2.5) return 'bg-lime-500';
if (avg <= 3.5) return 'bg-yellow-500';
if (avg <= 4.5) return 'bg-orange-500';
return 'bg-red-500';
};
// =============================================================================
// Render
// =============================================================================
if (loading) {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<Skeleton className="h-9 w-48" />
<Skeleton className="h-10 w-40" />
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3].map(i => (
<Skeleton key={i} className="h-48" />
))}
</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
<Zap className="h-8 w-8 text-orange-500" />
Dare Packs
</h1>
<p className="text-muted-foreground">
Manage dare packs that users can send to their partners
</p>
</div>
<Dialog open={form.dialogOpen} onOpenChange={form.setDialogOpen}>
<DialogTrigger asChild>
<Button onClick={form.openCreate}>
<Plus className="mr-2 h-4 w-4" />
Add Dare Pack
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
{form.isEditing ? 'Edit Dare Pack' : 'Create Dare Pack'}
</DialogTitle>
<DialogDescription>
{form.isEditing
? 'Update the dare pack details below.'
: 'Add a new dare pack for couples to challenge each other.'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>Icon</Label>
<IconPicker
value={form.formData.icon}
onChange={(iconName) => form.setField('icon', iconName)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={form.formData.name}
onChange={(e) => form.setField('name', e.target.value)}
placeholder="e.g., Romantic Dares"
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={form.formData.description}
onChange={(e) => form.setField('description', e.target.value)}
placeholder="Describe this dare pack..."
rows={3}
/>
</div>
<div className="space-y-2">
<Label>Category (optional)</Label>
<Select
value={form.formData.category_id || '__none__'}
onValueChange={(value) => form.setField('category_id', value === '__none__' ? '' : value)}
>
<SelectTrigger>
<SelectValue placeholder="Select a category" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">No category</SelectItem>
{categories.map((cat) => (
<SelectItem key={cat.id} value={cat.id}>
{cat.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Premium</Label>
<p className="text-xs text-muted-foreground">
Only available to premium users
</p>
</div>
<Switch
checked={form.formData.is_premium}
onCheckedChange={(checked) => form.setField('is_premium', checked)}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Published</Label>
<p className="text-xs text-muted-foreground">
Visible to users in the app
</p>
</div>
<Switch
checked={form.formData.is_public}
onCheckedChange={(checked) => form.setField('is_public', checked)}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Explicit</Label>
<p className="text-xs text-muted-foreground">
Contains adult/explicit content
</p>
</div>
<Switch
checked={form.formData.is_explicit}
onCheckedChange={(checked) => form.setField('is_explicit', checked)}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={form.close}>
Cancel
</Button>
<Button onClick={handleSave} disabled={form.saving}>
{form.saving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
</>
) : (
'Save'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
{/* Dare Packs Grid */}
{darePacks.length === 0 ? (
<Card className="flex flex-col items-center justify-center py-12">
<Zap className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold">No dare packs yet</h3>
<p className="text-muted-foreground mb-4">
Create your first dare pack to get started
</p>
<Button onClick={form.openCreate}>
<Plus className="mr-2 h-4 w-4" />
Add Dare Pack
</Button>
</Card>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{darePacks.map((pack, index) => (
<Card key={pack.id} className="group hover:shadow-md transition-shadow">
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<div className="flex flex-col opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => handleMove(pack, 'up')}
disabled={index === 0}
>
<ChevronUp className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => handleMove(pack, 'down')}
disabled={index === darePacks.length - 1}
>
<ChevronDown className="h-4 w-4" />
</Button>
</div>
<div className="text-4xl mb-2">
<IconPreview value={pack.icon} fallback="flame-outline" className="text-4xl" />
</div>
</div>
<div className="flex items-center gap-1">
{pack.is_premium && (
<Badge variant="default" className="bg-amber-500">
<Crown className="h-3 w-3 mr-1" />
Premium
</Badge>
)}
{pack.is_public ? (
<Badge variant="outline" className="border-green-500 text-green-400">
<Eye className="h-3 w-3 mr-1" />
Published
</Badge>
) : (
<Badge variant="secondary">
<EyeOff className="h-3 w-3 mr-1" />
Draft
</Badge>
)}
{pack.is_explicit ? (
<Badge variant="outline" className="border-red-500 text-red-400">
<Flame className="h-3 w-3 mr-1" />
Explicit
</Badge>
) : (
<Badge variant="outline" className="border-pink-400 text-pink-300">
<Heart className="h-3 w-3 mr-1" />
Safe
</Badge>
)}
</div>
</div>
<div className="flex items-start justify-between">
<CardTitle className="flex-1">{pack.name}</CardTitle>
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => form.openEdit(pack)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(pack)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</div>
<CardDescription className="line-clamp-2">
{pack.description || 'No description'}
</CardDescription>
{/* Intensity indicator */}
{pack.avg_intensity !== null && (
<div className="flex items-center gap-2 mt-2">
<Badge className={`${getIntensityColor(pack.avg_intensity)} text-white`}>
Avg: {pack.avg_intensity?.toFixed(1)}
</Badge>
<span className="text-xs text-muted-foreground">
Range: {pack.min_intensity ?? '?'} - {pack.max_intensity ?? '?'}
</span>
</div>
)}
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
{pack.dare_count} dare{pack.dare_count !== 1 ? 's' : ''}
</span>
<Link to={`/dare-packs/${pack.id}/dares`}>
<Button variant="outline" size="sm">
Manage Dares
</Button>
</Link>
</div>
</CardContent>
</Card>
))}
</div>
)}
{darePacks.length > 0 && (
<PaginationControls
page={page}
pageSize={pageSize}
totalCount={totalCount}
onPageChange={setPage}
onPageSizeChange={(size) => {
setPage(1);
setPageSize(size);
}}
/>
)}
</div>
);
}
+551
View File
@@ -0,0 +1,551 @@
import { useState, useEffect, useCallback } from 'react';
import { useParams } from 'react-router-dom';
import { supabase } from '@/config';
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { PaginationControls } from '@/components/ui/pagination';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Card } from '@/components/ui/card';
import { Plus, Pencil, Trash2, Zap, Loader2, Clock } from 'lucide-react';
import { Checkbox } from '@/components/ui/checkbox';
import { toast } from 'sonner';
import { IconPreview } from '@/components/ui/icon-picker';
import { INTENSITY_LEVELS } from '@/lib/openai';
// =============================================================================
// Types
// =============================================================================
interface DarePack {
id: string;
name: string;
description: string | null;
icon: string | null;
is_explicit: boolean;
}
interface Dare {
id: string;
pack_id: string;
text: string;
intensity: number;
suggested_duration_hours: number | null;
created_at: string | null;
}
// Duration options for dares
const DURATION_OPTIONS = [
{ value: '', label: 'No suggestion' },
{ value: '1', label: '1 hour' },
{ value: '6', label: '6 hours' },
{ value: '12', label: '12 hours' },
{ value: '24', label: '24 hours' },
{ value: '72', label: '3 days' },
{ value: '168', label: '1 week' },
];
// Helper to get intensity info by level (1-indexed)
const getIntensity = (level: number) => INTENSITY_LEVELS[level - 1] || INTENSITY_LEVELS[0];
// Format duration for display
const formatDuration = (hours: number | null): string => {
if (hours === null) return '—';
if (hours < 24) return `${hours}h`;
if (hours < 168) return `${Math.round(hours / 24)}d`;
return `${Math.round(hours / 168)}w`;
};
// =============================================================================
// Component
// =============================================================================
export function DaresPage() {
const { packId } = useParams<{ packId: string }>();
const [pack, setPack] = useState<DarePack | null>(null);
const [dares, setDares] = useState<Dare[]>([]);
const [loading, setLoading] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false);
const [editingDare, setEditingDare] = useState<Dare | null>(null);
const [saving, setSaving] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [bulkDeleting, setBulkDeleting] = useState(false);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(25);
const [totalCount, setTotalCount] = useState(0);
// Form state
const [formData, setFormData] = useState<{
text: string;
intensity: number;
suggested_duration_hours: string;
}>({
text: '',
intensity: 1,
suggested_duration_hours: '',
});
const fetchData = useCallback(async () => {
if (!packId) return;
setLoading(true);
try {
// Fetch pack info
const { data: packData } = await supabase
.from('dare_packs')
.select('id, name, description, icon, is_explicit')
.eq('id', packId)
.single();
setPack(packData);
const from = (page - 1) * pageSize;
const to = from + pageSize - 1;
// Fetch dares
const { data: dareData, error, count } = await supabase
.from('dares')
.select('*', { count: 'exact' })
.eq('pack_id', packId)
.order('created_at', { ascending: true })
.range(from, to);
if (error) throw error;
setTotalCount(count || 0);
setDares(dareData || []);
} catch (error) {
toast.error('Failed to load dares');
console.error(error);
} finally {
setLoading(false);
}
}, [packId, page, pageSize]);
useEffect(() => {
fetchData();
}, [fetchData]);
useEffect(() => {
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
if (page > totalPages) {
setPage(totalPages);
}
}, [page, pageSize, totalCount]);
useEffect(() => {
setPage(1);
setSelectedIds(new Set());
}, [packId]);
useEffect(() => {
setSelectedIds(new Set());
}, [page, pageSize]);
const openCreateDialog = () => {
setEditingDare(null);
setFormData({ text: '', intensity: 1, suggested_duration_hours: '' });
setDialogOpen(true);
};
const openEditDialog = (dare: Dare) => {
setEditingDare(dare);
setFormData({
text: dare.text,
intensity: dare.intensity,
suggested_duration_hours: dare.suggested_duration_hours?.toString() || '',
});
setDialogOpen(true);
};
const handleSave = async () => {
if (!formData.text.trim()) {
toast.error('Dare text is required');
return;
}
setSaving(true);
try {
const dareData = {
text: formData.text,
intensity: formData.intensity,
suggested_duration_hours: formData.suggested_duration_hours
? parseInt(formData.suggested_duration_hours, 10)
: null,
};
if (editingDare) {
const { error } = await auditedSupabase.update('dares', editingDare.id, dareData);
if (error) throw error;
toast.success('Dare updated');
} else {
const { error } = await auditedSupabase.insert('dares', {
...dareData,
pack_id: packId,
});
if (error) throw error;
toast.success('Dare created');
}
setDialogOpen(false);
fetchData();
} catch (error) {
toast.error('Failed to save dare');
console.error(error);
} finally {
setSaving(false);
}
};
const handleDelete = async (dare: Dare) => {
if (!confirm('Delete this dare? This cannot be undone.')) {
return;
}
try {
const { error } = await auditedSupabase.delete('dares', dare.id);
if (error) throw error;
toast.success('Dare deleted');
fetchData();
} catch (error) {
toast.error('Failed to delete dare');
console.error(error);
}
};
// Selection handlers
const toggleSelection = (id: string) => {
const next = new Set(selectedIds);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
setSelectedIds(next);
};
const toggleSelectAll = () => {
if (selectedIds.size === dares.length) {
setSelectedIds(new Set());
} else {
setSelectedIds(new Set(dares.map(d => d.id)));
}
};
const handleBulkDelete = async () => {
if (selectedIds.size === 0) return;
const count = selectedIds.size;
if (!confirm(`Delete ${count} dare${count !== 1 ? 's' : ''}? This cannot be undone.`)) {
return;
}
setBulkDeleting(true);
try {
await Promise.all(
Array.from(selectedIds).map(id => auditedSupabase.delete('dares', id))
);
toast.success(`${count} dare${count !== 1 ? 's' : ''} deleted`);
setSelectedIds(new Set());
fetchData();
} catch (error) {
toast.error('Failed to delete dares');
console.error(error);
} finally {
setBulkDeleting(false);
}
};
if (loading) {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<Skeleton className="h-9 w-64" />
<Skeleton className="h-10 w-40" />
</div>
<Skeleton className="h-96" />
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
{pack && (
<>
<span className="text-3xl">
<IconPreview value={pack.icon} fallback="flame-outline" className="text-3xl" />
</span>
{pack.name}
</>
)}
</h1>
<p className="text-muted-foreground">
{totalCount} dare{totalCount !== 1 ? 's' : ''} in this pack
</p>
</div>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogTrigger asChild>
<Button onClick={openCreateDialog}>
<Plus className="mr-2 h-4 w-4" />
Add Dare
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>
{editingDare ? 'Edit Dare' : 'Add Dare'}
</DialogTitle>
<DialogDescription>
{editingDare
? 'Update the dare details below.'
: 'Add a new dare to this pack.'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="text">Dare Text</Label>
<Textarea
id="text"
value={formData.text}
onChange={(e) => setFormData(d => ({ ...d, text: e.target.value }))}
placeholder="e.g., Give your partner a 5-minute massage..."
rows={3}
/>
</div>
<div className="space-y-2">
<Label>Intensity Level</Label>
<div className="flex gap-2 flex-wrap">
{INTENSITY_LEVELS.map((intensity) => (
<Button
key={intensity.level}
type="button"
variant={formData.intensity === intensity.level ? 'default' : 'outline'}
size="sm"
onClick={() => setFormData(d => ({ ...d, intensity: intensity.level }))}
className={formData.intensity === intensity.level ? intensity.color : ''}
title={intensity.description}
>
{intensity.level} - {intensity.label}
</Button>
))}
</div>
</div>
<div className="space-y-2">
<Label>Suggested Duration</Label>
<Select
value={formData.suggested_duration_hours}
onValueChange={(value) => setFormData(d => ({ ...d, suggested_duration_hours: value }))}
>
<SelectTrigger>
<SelectValue placeholder="Select a duration" />
</SelectTrigger>
<SelectContent>
{DURATION_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
This is a suggestion shown to users when sending the dare. They can choose a different timeframe.
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
</>
) : (
'Save'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
{/* Bulk Action Bar */}
{selectedIds.size > 0 && (
<div className="flex items-center justify-between bg-muted/50 border rounded-lg px-4 py-3">
<div className="flex items-center gap-3">
<span className="text-sm font-medium">
{selectedIds.size} dare{selectedIds.size !== 1 ? 's' : ''} selected
</span>
<Button
variant="ghost"
size="sm"
onClick={() => setSelectedIds(new Set())}
>
Clear selection
</Button>
</div>
<Button
variant="destructive"
size="sm"
onClick={handleBulkDelete}
disabled={bulkDeleting}
>
{bulkDeleting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Deleting...
</>
) : (
<>
<Trash2 className="mr-2 h-4 w-4" />
Delete {selectedIds.size} Dare{selectedIds.size !== 1 ? 's' : ''}
</>
)}
</Button>
</div>
)}
{/* Dares Table */}
{dares.length === 0 ? (
<Card className="flex flex-col items-center justify-center py-12">
<Zap className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold">No dares yet</h3>
<p className="text-muted-foreground mb-4">
Add dares to this pack for couples to challenge each other
</p>
<Button onClick={openCreateDialog}>
<Plus className="mr-2 h-4 w-4" />
Add Dare
</Button>
</Card>
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12">
<Checkbox
checked={selectedIds.size === dares.length && dares.length > 0}
onCheckedChange={toggleSelectAll}
aria-label="Select all"
/>
</TableHead>
<TableHead className="w-12">#</TableHead>
<TableHead>Dare</TableHead>
<TableHead className="w-24">Intensity</TableHead>
<TableHead className="w-24">Duration</TableHead>
<TableHead className="w-24">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{dares.map((dare, index) => (
<TableRow
key={dare.id}
className={selectedIds.has(dare.id) ? 'bg-muted/50' : ''}
>
<TableCell>
<Checkbox
checked={selectedIds.has(dare.id)}
onCheckedChange={() => toggleSelection(dare.id)}
aria-label={`Select dare ${index + 1}`}
/>
</TableCell>
<TableCell className="font-medium text-muted-foreground">
{(page - 1) * pageSize + index + 1}
</TableCell>
<TableCell>
<span className="line-clamp-2">{dare.text}</span>
</TableCell>
<TableCell>
<Badge
className={`${getIntensity(dare.intensity).color} text-white`}
title={getIntensity(dare.intensity).description}
>
{getIntensity(dare.intensity).label}
</Badge>
</TableCell>
<TableCell>
{dare.suggested_duration_hours ? (
<Badge variant="outline" className="text-xs">
<Clock className="h-3 w-3 mr-1" />
{formatDuration(dare.suggested_duration_hours)}
</Badge>
) : (
<span className="text-muted-foreground text-xs">—</span>
)}
</TableCell>
<TableCell>
<div className="flex gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => openEditDialog(dare)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(dare)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{dares.length > 0 && (
<PaginationControls
page={page}
pageSize={pageSize}
totalCount={totalCount}
onPageChange={setPage}
onPageSizeChange={(size) => {
setPage(1);
setPageSize(size);
}}
/>
)}
</div>
);
}
+65 -23
View File
@@ -7,11 +7,13 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { PaginationControls } from '@/components/ui/pagination';
import { Plus, MessageCircle, Pencil, Trash2, Sparkles, Crown, Eye, EyeOff, Flame, Heart, Tags, ChevronUp, ChevronDown } from 'lucide-react'; import { Plus, MessageCircle, Pencil, Trash2, Sparkles, Crown, Eye, EyeOff, Flame, Heart, Tags, ChevronUp, ChevronDown } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { AiGeneratorDialog } from '@/components/ai/AiGeneratorDialog'; import { AiGeneratorDialog } from '@/components/ai/AiGeneratorDialog';
import { ExtractTopicsDialog } from '@/components/content/ExtractTopicsDialog'; import { ExtractTopicsDialog } from '@/components/content/ExtractTopicsDialog';
import { PackFormDialog, type PackFormData } from '@/components/content/PackFormDialog'; import { PackFormDialog, type PackFormData } from '@/components/content/PackFormDialog';
import { IconPreview } from '@/components/ui/icon-picker';
// ============================================================================= // =============================================================================
// Types // Types
@@ -59,13 +61,16 @@ export function PacksPage() {
const [extractTopicsPack, setExtractTopicsPack] = useState<QuestionPack | null>(null); const [extractTopicsPack, setExtractTopicsPack] = useState<QuestionPack | null>(null);
const [allTopics, setAllTopics] = useState<Topic[]>([]); const [allTopics, setAllTopics] = useState<Topic[]>([]);
const [selectedTopicIds, setSelectedTopicIds] = useState<Set<string>>(new Set()); const [selectedTopicIds, setSelectedTopicIds] = useState<Set<string>>(new Set());
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(12);
const [totalCount, setTotalCount] = useState(0);
// Form state using the hook // Form state using the hook
const form = useEntityForm<PackFormData, QuestionPack>( const form = useEntityForm<PackFormData, QuestionPack>(
{ {
name: '', name: '',
description: '', description: '',
icon: '💕', icon: 'heart-outline',
is_premium: false, is_premium: false,
is_public: false, is_public: false,
is_explicit: false, is_explicit: false,
@@ -73,7 +78,7 @@ export function PacksPage() {
(pack) => ({ (pack) => ({
name: pack.name, name: pack.name,
description: pack.description || '', description: pack.description || '',
icon: pack.icon || '💕', icon: pack.icon || 'heart-outline',
is_premium: pack.is_premium, is_premium: pack.is_premium,
is_public: pack.is_public, is_public: pack.is_public,
is_explicit: pack.is_explicit, is_explicit: pack.is_explicit,
@@ -97,30 +102,39 @@ export function PacksPage() {
setCategory(cat); setCategory(cat);
} }
const from = (page - 1) * pageSize;
const to = from + pageSize - 1;
// Fetch packs // Fetch packs
let query = supabase let query = supabase
.from('question_packs') .from('question_packs')
.select('*') .select('*', { count: 'exact' })
.order('sort_order', { ascending: true }); .order('sort_order', { ascending: true })
.range(from, to);
if (categoryId) { if (categoryId) {
query = query.eq('category_id', categoryId); query = query.eq('category_id', categoryId);
} }
const { data: packData, error } = await query; const { data: packData, error, count } = await query;
if (error) throw error; if (error) throw error;
setTotalCount(count || 0);
// Fetch question counts // Fetch question counts
const packIds = (packData || []).map(p => p.id); const packIds = (packData || []).map(p => p.id);
const { data: questions } = await supabase
.from('questions')
.select('pack_id')
.in('pack_id', packIds);
const questionCounts: Record<string, number> = {}; const questionCounts: Record<string, number> = {};
questions?.forEach(q => {
questionCounts[q.pack_id] = (questionCounts[q.pack_id] || 0) + 1; if (packIds.length > 0) {
}); const { data: questions } = await supabase
.from('questions')
.select('pack_id')
.in('pack_id', packIds);
questions?.forEach(q => {
questionCounts[q.pack_id] = (questionCounts[q.pack_id] || 0) + 1;
});
}
// Fetch all topics // Fetch all topics
const { data: topicsData } = await supabase const { data: topicsData } = await supabase
@@ -130,13 +144,17 @@ export function PacksPage() {
setAllTopics(topicsData || []); setAllTopics(topicsData || []);
// Fetch pack_topics relationships // Fetch pack_topics relationships
const { data: packTopicsData } = await supabase let packTopicsData: any[] = [];
.from('pack_topics') if (packIds.length > 0) {
.select('pack_id, topic_id, topics(id, name)') const { data } = await supabase
.in('pack_id', packIds); .from('pack_topics')
.select('pack_id, topic_id, topics(id, name)')
.in('pack_id', packIds);
packTopicsData = data || [];
}
const packTopicsMap: Record<string, Topic[]> = {}; const packTopicsMap: Record<string, Topic[]> = {};
packTopicsData?.forEach((pt: any) => { packTopicsData.forEach((pt: any) => {
if (!packTopicsMap[pt.pack_id]) { if (!packTopicsMap[pt.pack_id]) {
packTopicsMap[pt.pack_id] = []; packTopicsMap[pt.pack_id] = [];
} }
@@ -158,12 +176,19 @@ export function PacksPage() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [categoryId]); }, [categoryId, page, pageSize]);
useEffect(() => { useEffect(() => {
fetchData(); fetchData();
}, [fetchData]); }, [fetchData]);
useEffect(() => {
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
if (page > totalPages) {
setPage(totalPages);
}
}, [page, pageSize, totalCount]);
// ============================================================================= // =============================================================================
// Handlers // Handlers
// ============================================================================= // =============================================================================
@@ -210,7 +235,7 @@ export function PacksPage() {
is_public: form.formData.is_public, is_public: form.formData.is_public,
is_explicit: form.formData.is_explicit, is_explicit: form.formData.is_explicit,
category_id: categoryId || null, category_id: categoryId || null,
sort_order: packs.length, sort_order: totalCount,
}) })
.select('id') .select('id')
.single(); .single();
@@ -265,7 +290,7 @@ export function PacksPage() {
form.openCreateWith({ form.openCreateWith({
name: result.name || '', name: result.name || '',
description: result.description, description: result.description,
icon: result.icon || '💕', icon: result.icon || 'heart-outline',
is_premium: false, is_premium: false,
is_public: false, is_public: false,
is_explicit: false, is_explicit: false,
@@ -331,7 +356,9 @@ export function PacksPage() {
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2"> <h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
{category && ( {category && (
<> <>
<span className="text-3xl">{category.icon}</span> <span className="text-3xl">
<IconPreview value={category.icon} fallback="bookmark-outline" className="text-3xl" />
</span>
{category.name}: {category.name}:
</> </>
)} )}
@@ -437,7 +464,9 @@ export function PacksPage() {
<ChevronDown className="h-4 w-4" /> <ChevronDown className="h-4 w-4" />
</Button> </Button>
</div> </div>
<div className="text-4xl mb-2">{pack.icon || '💕'}</div> <div className="text-4xl mb-2">
<IconPreview value={pack.icon} fallback="heart-outline" className="text-4xl" />
</div>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{pack.is_premium && ( {pack.is_premium && (
@@ -530,6 +559,19 @@ export function PacksPage() {
))} ))}
</div> </div>
)} )}
{packs.length > 0 && (
<PaginationControls
page={page}
pageSize={pageSize}
totalCount={totalCount}
onPageChange={setPage}
onPageSizeChange={(size) => {
setPage(1);
setPageSize(size);
}}
/>
)}
</div> </div>
); );
} }
+52 -14
View File
@@ -9,6 +9,7 @@ import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { PaginationControls } from '@/components/ui/pagination';
import { import {
Table, Table,
TableBody, TableBody,
@@ -70,6 +71,9 @@ export function QuestionsPage() {
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set()); const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [bulkDeleting, setBulkDeleting] = useState(false); const [bulkDeleting, setBulkDeleting] = useState(false);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(25);
const [totalCount, setTotalCount] = useState(0);
// Form state // Form state
const [formData, setFormData] = useState<{ const [formData, setFormData] = useState<{
@@ -100,14 +104,19 @@ export function QuestionsPage() {
setPack(packData); setPack(packData);
const from = (page - 1) * pageSize;
const to = from + pageSize - 1;
// Fetch questions // Fetch questions
const { data: questionData, error } = await supabase const { data: questionData, error, count } = await supabase
.from('questions') .from('questions')
.select('*') .select('*', { count: 'exact' })
.eq('pack_id', packId) .eq('pack_id', packId)
.order('created_at', { ascending: true }); .order('created_at', { ascending: true })
.range(from, to);
if (error) throw error; if (error) throw error;
setTotalCount(count || 0);
setQuestions(questionData || []); setQuestions(questionData || []);
} catch (error) { } catch (error) {
toast.error('Failed to load questions'); toast.error('Failed to load questions');
@@ -115,33 +124,49 @@ export function QuestionsPage() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [packId]); }, [packId, page, pageSize]);
// Real-time subscription for questions in this pack // Real-time subscription for questions in this pack
const { status: realtimeStatus } = useRealtimeSubscription<Question>({ const { status: realtimeStatus } = useRealtimeSubscription<Question>({
table: 'questions', table: 'questions',
filter: packId ? `pack_id=eq.${packId}` : undefined, filter: packId ? `pack_id=eq.${packId}` : undefined,
enabled: !!packId, enabled: !!packId,
onInsert: useCallback((newQuestion: Question) => { onInsert: useCallback(() => {
setQuestions(prev => [...prev, newQuestion]); fetchData();
}, []), }, [fetchData]),
onUpdate: useCallback(({ new: updated }: { old: Question; new: Question }) => { onUpdate: useCallback(() => {
setQuestions(prev => prev.map(q => q.id === updated.id ? updated : q)); fetchData();
}, []), }, [fetchData]),
onDelete: useCallback((deleted: Question) => { onDelete: useCallback((deleted: Question) => {
setQuestions(prev => prev.filter(q => q.id !== deleted.id));
setSelectedIds(prev => { setSelectedIds(prev => {
const next = new Set(prev); const next = new Set(prev);
next.delete(deleted.id); next.delete(deleted.id);
return next; return next;
}); });
}, []), fetchData();
}, [fetchData]),
}); });
useEffect(() => { useEffect(() => {
fetchData(); fetchData();
}, [fetchData]); }, [fetchData]);
useEffect(() => {
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
if (page > totalPages) {
setPage(totalPages);
}
}, [page, pageSize, totalCount]);
useEffect(() => {
setPage(1);
setSelectedIds(new Set());
}, [packId]);
useEffect(() => {
setSelectedIds(new Set());
}, [page, pageSize]);
const openCreateDialog = () => { const openCreateDialog = () => {
setEditingQuestion(null); setEditingQuestion(null);
setFormData({ text: '', partner_text: '', intensity: 1, allowed_couple_genders: [], target_user_genders: [] }); setFormData({ text: '', partner_text: '', intensity: 1, allowed_couple_genders: [], target_user_genders: [] });
@@ -319,7 +344,7 @@ export function QuestionsPage() {
<RealtimeStatusIndicator status={realtimeStatus} showLabel /> <RealtimeStatusIndicator status={realtimeStatus} showLabel />
</div> </div>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{questions.length} question{questions.length !== 1 ? 's' : ''} in this pack {totalCount} question{totalCount !== 1 ? 's' : ''} in this pack
</p> </p>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
@@ -615,7 +640,7 @@ export function QuestionsPage() {
/> />
</TableCell> </TableCell>
<TableCell className="font-medium text-muted-foreground"> <TableCell className="font-medium text-muted-foreground">
{index + 1} {(page - 1) * pageSize + index + 1}
</TableCell> </TableCell>
<TableCell> <TableCell>
<span className="line-clamp-2">{question.text}</span> <span className="line-clamp-2">{question.text}</span>
@@ -689,6 +714,19 @@ export function QuestionsPage() {
</div> </div>
) )
} }
{questions.length > 0 && (
<PaginationControls
page={page}
pageSize={pageSize}
totalCount={totalCount}
onPageChange={setPage}
onPageSizeChange={(size) => {
setPage(1);
setPageSize(size);
}}
/>
)}
</div > </div >
); );
} }
+104 -64
View File
@@ -12,6 +12,7 @@ import { format, isToday, isYesterday } from 'date-fns';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription'; import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator'; import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
import { PaginationControls } from '@/components/ui/pagination';
interface Profile { interface Profile {
id: string; id: string;
@@ -331,79 +332,103 @@ export function MatchChatPage() {
const [match, setMatch] = useState<Match | null>(null); const [match, setMatch] = useState<Match | null>(null);
const [messages, setMessages] = useState<Message[]>([]); const [messages, setMessages] = useState<Message[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(50);
const [totalCount, setTotalCount] = useState(0);
const fetchData = useCallback(async () => {
if (!userId || !matchId) return;
setLoading(true);
try {
// Fetch user profile
const { data: userData } = await supabase
.from('profiles')
.select('id, name, avatar_url, couple_id')
.eq('id', userId)
.single();
setUser(userData);
// Fetch partner
if (userData?.couple_id) {
const { data: partnerData } = await supabase
.from('profiles')
.select('id, name, avatar_url')
.eq('couple_id', userData.couple_id)
.neq('id', userId)
.single();
setPartner(partnerData);
} else {
setPartner(null);
}
// Fetch match with question
const { data: matchData } = await supabase
.from('matches')
.select(`
id, match_type,
question:questions (id, text)
`)
.eq('id', matchId)
.single();
setMatch(matchData as unknown as Match);
const from = (page - 1) * pageSize;
const to = from + pageSize - 1;
// Fetch messages
const { data: messageData, error: messagesError, count } = await supabase
.from('messages')
.select('*', { count: 'exact' })
.eq('match_id', matchId)
.order('created_at', { ascending: false })
.range(from, to);
if (messagesError) throw messagesError;
setTotalCount(count || 0);
const orderedMessages = (messageData || []).slice().reverse();
setMessages(orderedMessages);
} catch (error) {
console.error('Failed to load chat data:', error);
} finally {
setLoading(false);
}
}, [userId, matchId, page, pageSize]);
// Real-time subscription for messages in this chat // Real-time subscription for messages in this chat
const { status: realtimeStatus } = useRealtimeSubscription<Message>({ const { status: realtimeStatus } = useRealtimeSubscription<Message>({
table: 'messages', table: 'messages',
filter: matchId ? `match_id=eq.${matchId}` : undefined, filter: matchId ? `match_id=eq.${matchId}` : undefined,
enabled: !!matchId, enabled: !!matchId,
onInsert: useCallback((newMessage: Message) => { onInsert: useCallback(() => {
setMessages(prev => [...prev, newMessage]); fetchData();
}, []), }, [fetchData]),
onUpdate: useCallback(({ new: updated }: { old: Message; new: Message }) => { onUpdate: useCallback(() => {
setMessages(prev => prev.map(m => m.id === updated.id ? updated : m)); fetchData();
}, []), }, [fetchData]),
onDelete: useCallback((deleted: Message) => { onDelete: useCallback(() => {
setMessages(prev => prev.filter(m => m.id !== deleted.id)); fetchData();
}, []), }, [fetchData]),
}); });
useEffect(() => { useEffect(() => {
const fetchData = async () => {
if (!userId || !matchId) return;
setLoading(true);
try {
// Fetch user profile
const { data: userData } = await supabase
.from('profiles')
.select('id, name, avatar_url, couple_id')
.eq('id', userId)
.single();
setUser(userData);
// Fetch partner
if (userData?.couple_id) {
const { data: partnerData } = await supabase
.from('profiles')
.select('id, name, avatar_url')
.eq('couple_id', userData.couple_id)
.neq('id', userId)
.single();
setPartner(partnerData);
}
// Fetch match with question
const { data: matchData } = await supabase
.from('matches')
.select(`
id, match_type,
question:questions (id, text)
`)
.eq('id', matchId)
.single();
setMatch(matchData as unknown as Match);
// Fetch messages
const { data: messageData } = await supabase
.from('messages')
.select('*')
.eq('match_id', matchId)
.order('created_at', { ascending: true });
setMessages(messageData || []);
} catch (error) {
console.error('Failed to load chat data:', error);
} finally {
setLoading(false);
}
};
fetchData(); fetchData();
}, [userId, matchId]); }, [fetchData]);
useEffect(() => {
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
if (page > totalPages) {
setPage(totalPages);
}
}, [page, pageSize, totalCount]);
useEffect(() => {
setPage(1);
}, [matchId]);
const getProfile = (senderId: string): Profile | null => { const getProfile = (senderId: string): Profile | null => {
if (senderId === user?.id) return user; if (senderId === user?.id) return user;
@@ -497,7 +522,7 @@ export function MatchChatPage() {
<Card className="min-h-[400px]"> <Card className="min-h-[400px]">
<CardHeader className="py-4 border-b"> <CardHeader className="py-4 border-b">
<CardTitle className="text-sm font-medium"> <CardTitle className="text-sm font-medium">
Messages ({messages.length}) Messages ({totalCount})
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="p-4"> <CardContent className="p-4">
@@ -580,6 +605,21 @@ export function MatchChatPage() {
})} })}
</div> </div>
)} )}
{totalCount > 0 && (
<div className="mt-4">
<PaginationControls
page={page}
pageSize={pageSize}
totalCount={totalCount}
onPageChange={setPage}
onPageSizeChange={(size) => {
setPage(1);
setPageSize(size);
}}
/>
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
+98 -11
View File
@@ -8,6 +8,7 @@ import { useAuth, PERMISSION_KEYS } from '@/contexts/AuthContext';
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 { Skeleton } from '@/components/ui/skeleton';
import { PaginationControls } from '@/components/ui/pagination';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
@@ -237,6 +238,15 @@ export function UserDetailPage() {
const [responses, setResponses] = useState<Response[]>([]); const [responses, setResponses] = useState<Response[]>([]);
const [matches, setMatches] = useState<Match[]>([]); const [matches, setMatches] = useState<Match[]>([]);
const [mediaMessages, setMediaMessages] = useState<any[]>([]); const [mediaMessages, setMediaMessages] = useState<any[]>([]);
const [responsesPage, setResponsesPage] = useState(1);
const [responsesPageSize, setResponsesPageSize] = useState(10);
const [responsesTotal, setResponsesTotal] = useState(0);
const [matchesPage, setMatchesPage] = useState(1);
const [matchesPageSize, setMatchesPageSize] = useState(10);
const [matchesTotal, setMatchesTotal] = useState(0);
const [mediaPage, setMediaPage] = useState(1);
const [mediaPageSize, setMediaPageSize] = useState(12);
const [mediaTotal, setMediaTotal] = useState(0);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const canViewResponses = hasPermission(PERMISSION_KEYS.VIEW_RESPONSES); const canViewResponses = hasPermission(PERMISSION_KEYS.VIEW_RESPONSES);
@@ -308,44 +318,121 @@ export function UserDetailPage() {
setPartner(null); setPartner(null);
} }
const { data: responseData } = await supabase.from('responses').select('id, answer, created_at, question:questions(id, text, pack:question_packs(name))').eq('user_id', userId).order('created_at', { ascending: false }).limit(50); const responsesFrom = (responsesPage - 1) * responsesPageSize;
const responsesTo = responsesFrom + responsesPageSize - 1;
const { data: responseData, count: responsesCount, error: responsesError } = await supabase
.from('responses')
.select('id, answer, created_at, question:questions(id, text, pack:question_packs(name))', { count: 'exact' })
.eq('user_id', userId)
.order('created_at', { ascending: false })
.range(responsesFrom, responsesTo);
if (responsesError) throw responsesError;
setResponses((responseData || []) as unknown as Response[]); setResponses((responseData || []) as unknown as Response[]);
setResponsesTotal(responsesCount || 0);
if (profileData?.couple_id) { if (profileData?.couple_id) {
const { data: matchData } = await supabase.from('matches').select('id, match_type, is_new, created_at, question:questions(id, text)').eq('couple_id', profileData.couple_id).order('created_at', { ascending: false }).limit(50); const matchesFrom = (matchesPage - 1) * matchesPageSize;
const matchesTo = matchesFrom + matchesPageSize - 1;
const { data: matchData, count: matchesCount, error: matchesError } = await supabase
.from('matches')
.select('id, match_type, is_new, created_at, question:questions(id, text)', { count: 'exact' })
.eq('couple_id', profileData.couple_id)
.order('created_at', { ascending: false })
.range(matchesFrom, matchesTo);
if (matchesError) throw matchesError;
setMatchesTotal(matchesCount || 0);
const matchIds = (matchData || []).map(m => m.id); const matchIds = (matchData || []).map(m => m.id);
if (matchIds.length > 0) { if (matchIds.length > 0) {
const { data: messages } = await supabase.from('messages').select('match_id').in('match_id', matchIds); const { data: messages } = await supabase.from('messages').select('match_id').in('match_id', matchIds);
const messageCounts: Record<string, number> = {}; const messageCounts: Record<string, number> = {};
messages?.forEach(m => { messageCounts[m.match_id] = (messageCounts[m.match_id] || 0) + 1; }); messages?.forEach(m => { messageCounts[m.match_id] = (messageCounts[m.match_id] || 0) + 1; });
setMatches((matchData || []).map(m => ({ ...m, message_count: messageCounts[m.id] || 0 })) as unknown as Match[]); setMatches((matchData || []).map(m => ({ ...m, message_count: messageCounts[m.id] || 0 })) as unknown as Match[]);
const { data: mediaMessagesData, error: mediaError } = await supabase.from('messages').select('id, user_id, media_path, media_type, version, created_at, keys_metadata, match_id').in('match_id', matchIds).not('media_path', 'is', null).order('created_at', { ascending: false }); } else {
setMatches([]);
}
const { data: allMatchIdsData } = await supabase
.from('matches')
.select('id')
.eq('couple_id', profileData.couple_id);
const allMatchIds = (allMatchIdsData || []).map(m => m.id);
if (allMatchIds.length > 0) {
const mediaFrom = (mediaPage - 1) * mediaPageSize;
const mediaTo = mediaFrom + mediaPageSize - 1;
const { data: mediaMessagesData, error: mediaError, count: mediaCount } = await supabase
.from('messages')
.select('id, user_id, media_path, media_type, version, created_at, keys_metadata, match_id', { count: 'exact' })
.in('match_id', allMatchIds)
.not('media_path', 'is', null)
.order('created_at', { ascending: false })
.range(mediaFrom, mediaTo);
if (mediaError) { if (mediaError) {
console.error("Error fetching media messages:", mediaError); console.error("Error fetching media messages:", mediaError);
setMediaMessages([]); setMediaMessages([]);
setMediaTotal(0);
} else { } else {
setMediaMessages(mediaMessagesData || []); setMediaMessages(mediaMessagesData || []);
setMediaTotal(mediaCount || 0);
} }
} else { } else {
setMatches([]);
setMediaMessages([]); setMediaMessages([]);
setMediaTotal(0);
} }
} else { } else {
setMatches([]); setMatches([]);
setMatchesTotal(0);
setMediaMessages([]); setMediaMessages([]);
setMediaTotal(0);
} }
} catch (error) { } catch (error) {
console.error('Failed to load user data:', error); console.error('Failed to load user data:', error);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [userId]); }, [userId, responsesPage, responsesPageSize, matchesPage, matchesPageSize, mediaPage, mediaPageSize]);
const { status: profileStatus } = useRealtimeSubscription<Profile>({ table: 'profiles', filter: userId ? `id=eq.${userId}` : undefined, enabled: !!userId, onUpdate: useCallback(({ new: updated }: { old: Profile; new: Profile }) => { setProfile(updated); }, []) }); const { status: profileStatus } = useRealtimeSubscription<Profile>({ table: 'profiles', filter: userId ? `id=eq.${userId}` : undefined, enabled: !!userId, onUpdate: useCallback(({ new: updated }: { old: Profile; new: Profile }) => { setProfile(updated); }, []) });
const { status: responsesStatus } = useRealtimeSubscription<Response>({ table: 'responses', filter: userId ? `user_id=eq.${userId}` : undefined, enabled: !!userId, onInsert: fetchData, onDelete: fetchData }); const { status: responsesStatus } = useRealtimeSubscription<Response>({ table: 'responses', filter: userId ? `user_id=eq.${userId}` : undefined, enabled: !!userId, onInsert: fetchData, onDelete: fetchData });
useEffect(() => { fetchData(); }, [fetchData]); useEffect(() => { fetchData(); }, [fetchData]);
useEffect(() => {
setResponsesPage(1);
setMatchesPage(1);
setMediaPage(1);
}, [userId]);
useEffect(() => {
const totalPages = Math.max(1, Math.ceil(responsesTotal / responsesPageSize));
if (responsesPage > totalPages) {
setResponsesPage(totalPages);
}
}, [responsesPage, responsesPageSize, responsesTotal]);
useEffect(() => {
const totalPages = Math.max(1, Math.ceil(matchesTotal / matchesPageSize));
if (matchesPage > totalPages) {
setMatchesPage(totalPages);
}
}, [matchesPage, matchesPageSize, matchesTotal]);
useEffect(() => {
const totalPages = Math.max(1, Math.ceil(mediaTotal / mediaPageSize));
if (mediaPage > totalPages) {
setMediaPage(totalPages);
}
}, [mediaPage, mediaPageSize, mediaTotal]);
if (loading) return <div className="space-y-6"><Skeleton className="h-32" /><Skeleton className="h-96" /></div>; if (loading) return <div className="space-y-6"><Skeleton className="h-32" /><Skeleton className="h-96" /></div>;
if (!profile) return <div className="flex flex-col items-center justify-center py-12"><p className="text-lg font-medium">User not found</p><Link to="/users"><Button variant="link">Back to users</Button></Link></div>; if (!profile) return <div className="flex flex-col items-center justify-center py-12"><p className="text-lg font-medium">User not found</p><Link to="/users"><Button variant="link">Back to users</Button></Link></div>;
@@ -378,19 +465,19 @@ export function UserDetailPage() {
{/* Tabs */} {/* Tabs */}
<Tabs defaultValue={canViewResponses ? "responses" : canViewMatches ? "matches" : canViewMedia ? "media" : "responses"}> <Tabs defaultValue={canViewResponses ? "responses" : canViewMatches ? "matches" : canViewMedia ? "media" : "responses"}>
<TabsList> <TabsList>
{canViewResponses && <TabsTrigger value="responses">Responses ({responses.length})</TabsTrigger>} {canViewResponses && <TabsTrigger value="responses">Responses ({responsesTotal})</TabsTrigger>}
{canViewMatches && <TabsTrigger value="matches" disabled={!profile.couple_id}>Matches ({matches.length})</TabsTrigger>} {canViewMatches && <TabsTrigger value="matches" disabled={!profile.couple_id}>Matches ({matchesTotal})</TabsTrigger>}
{canViewMedia && <TabsTrigger value="media">Media ({mediaMessages.length})</TabsTrigger>} {canViewMedia && <TabsTrigger value="media">Media ({mediaTotal})</TabsTrigger>}
</TabsList> </TabsList>
{canViewResponses && <TabsContent value="responses" className="mt-4">{responses.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><p className="text-muted-foreground">No responses yet</p></Card> : <div className="rounded-md border"><Table><TableHeader><TableRow><TableHead>Question</TableHead><TableHead>Pack</TableHead><TableHead className="w-24">Answer</TableHead><TableHead className="w-32">Date</TableHead></TableRow></TableHeader><TableBody>{responses.map((r) => (<TableRow key={r.id}><TableCell className="max-w-md"><span className="line-clamp-2">{r.question.text}</span></TableCell><TableCell className="text-muted-foreground">{r.question.pack?.name || '—'}</TableCell><TableCell><div className="flex items-center gap-2">{answerIcons[r.answer]}<span className="capitalize">{r.answer}</span></div></TableCell><TableCell className="text-muted-foreground text-sm">{r.created_at ? format(new Date(r.created_at), 'MMM d, yyyy') : '—'}</TableCell></TableRow>))}</TableBody></Table></div>}</TabsContent>} {canViewResponses && <TabsContent value="responses" className="mt-4">{responses.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><p className="text-muted-foreground">No responses yet</p></Card> : <div className="space-y-4"><div className="rounded-md border"><Table><TableHeader><TableRow><TableHead>Question</TableHead><TableHead>Pack</TableHead><TableHead className="w-24">Answer</TableHead><TableHead className="w-32">Date</TableHead></TableRow></TableHeader><TableBody>{responses.map((r) => (<TableRow key={r.id}><TableCell className="max-w-md"><span className="line-clamp-2">{r.question.text}</span></TableCell><TableCell className="text-muted-foreground">{r.question.pack?.name || '—'}</TableCell><TableCell><div className="flex items-center gap-2">{answerIcons[r.answer]}<span className="capitalize">{r.answer}</span></div></TableCell><TableCell className="text-muted-foreground text-sm">{r.created_at ? format(new Date(r.created_at), 'MMM d, yyyy') : '—'}</TableCell></TableRow>))}</TableBody></Table></div><PaginationControls page={responsesPage} pageSize={responsesPageSize} totalCount={responsesTotal} onPageChange={setResponsesPage} onPageSizeChange={(size) => { setResponsesPage(1); setResponsesPageSize(size); }} /></div>}</TabsContent>}
{canViewMatches && <TabsContent value="matches" className="mt-4">{matches.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><p className="text-muted-foreground">No matches yet</p></Card> : <div className="grid gap-4 md:grid-cols-2">{matches.map((match) => (<Card key={match.id} className="hover:shadow-md transition-shadow"><CardHeader className="pb-2"><div className="flex items-start justify-between"><Badge variant="secondary">{matchTypeLabels[match.match_type]}</Badge>{match.is_new && <Badge variant="default" className="text-xs">New</Badge>}</div><CardDescription className="line-clamp-2 mt-2">{match.question?.text || 'Unknown question'}</CardDescription></CardHeader><CardContent><div className="flex items-center justify-between"><div className="flex items-center gap-1 text-sm text-muted-foreground"><MessageCircle className="h-4 w-4" />{match.message_count} message{match.message_count !== 1 ? 's' : ''}</div>{canViewChats && <Link to={`/users/${userId}/matches/${match.id}`}><Button variant="ghost" size="sm">View Chat<ChevronRight className="ml-1 h-4 w-4" /></Button></Link>}</div></CardContent></Card>))}</div>}</TabsContent>} {canViewMatches && <TabsContent value="matches" className="mt-4">{matches.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><p className="text-muted-foreground">No matches yet</p></Card> : <div className="space-y-4"><div className="grid gap-4 md:grid-cols-2">{matches.map((match) => (<Card key={match.id} className="hover:shadow-md transition-shadow"><CardHeader className="pb-2"><div className="flex items-start justify-between"><Badge variant="secondary">{matchTypeLabels[match.match_type]}</Badge>{match.is_new && <Badge variant="default" className="text-xs">New</Badge>}</div><CardDescription className="line-clamp-2 mt-2">{match.question?.text || 'Unknown question'}</CardDescription></CardHeader><CardContent><div className="flex items-center justify-between"><div className="flex items-center gap-1 text-sm text-muted-foreground"><MessageCircle className="h-4 w-4" />{match.message_count} message{match.message_count !== 1 ? 's' : ''}</div>{canViewChats && <Link to={`/users/${userId}/matches/${match.id}`}><Button variant="ghost" size="sm">View Chat<ChevronRight className="ml-1 h-4 w-4" /></Button></Link>}</div></CardContent></Card>))}</div><PaginationControls page={matchesPage} pageSize={matchesPageSize} totalCount={matchesTotal} onPageChange={setMatchesPage} onPageSizeChange={(size) => { setMatchesPage(1); setMatchesPageSize(size); }} /></div>}</TabsContent>}
{canViewMedia && <TabsContent value="media" className="mt-4">{mediaMessages.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><Image className="h-12 w-12 text-muted-foreground mb-4" /><p className="text-muted-foreground">No media uploaded</p></Card> : <div className="space-y-4"><div className="grid gap-4 grid-cols-2 md:grid-cols-3 lg:grid-cols-4">{mediaMessages.map((message) => (<Card key={message.id} className="overflow-hidden"><div className="aspect-square bg-muted relative"> {canViewMedia && <TabsContent value="media" className="mt-4">{mediaMessages.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><Image className="h-12 w-12 text-muted-foreground mb-4" /><p className="text-muted-foreground">No media uploaded</p></Card> : <div className="space-y-4"><div className="grid gap-4 grid-cols-2 md:grid-cols-3 lg:grid-cols-4">{mediaMessages.map((message) => (<Card key={message.id} className="overflow-hidden"><div className="aspect-square bg-muted relative">
{message.media_type === 'video' ? ( {message.media_type === 'video' ? (
<AdminDecryptedVideo messageId={message.id} /> <AdminDecryptedVideo messageId={message.id} />
) : ( ) : (
<AdminDecryptedImage messageId={message.id} alt={`Media from ${message.created_at}`} /> <AdminDecryptedImage messageId={message.id} alt={`Media from ${message.created_at}`} />
)} )}
</div><CardContent className="p-3"><div className="flex items-center justify-between text-sm"><div className="truncate text-muted-foreground">{message.media_type === 'video' ? 'Video' : 'Image'}</div>{canViewChats && message.match_id && <Link to={`/users/${userId}/matches/${message.match_id}`} className="text-primary hover:underline text-xs">View Chat</Link>}</div><div className="text-xs text-muted-foreground mt-1">{message.created_at ? format(new Date(message.created_at), 'MMM d, yyyy') : '—'}</div></CardContent></Card>))}</div></div>}</TabsContent>} </div><CardContent className="p-3"><div className="flex items-center justify-between text-sm"><div className="truncate text-muted-foreground">{message.media_type === 'video' ? 'Video' : 'Image'}</div>{canViewChats && message.match_id && <Link to={`/users/${userId}/matches/${message.match_id}`} className="text-primary hover:underline text-xs">View Chat</Link>}</div><div className="text-xs text-muted-foreground mt-1">{message.created_at ? format(new Date(message.created_at), 'MMM d, yyyy') : '—'}</div></CardContent></Card>))}</div><PaginationControls page={mediaPage} pageSize={mediaPageSize} totalCount={mediaTotal} onPageChange={setMediaPage} onPageSizeChange={(size) => { setMediaPage(1); setMediaPageSize(size); }} /></div>}</TabsContent>}
</Tabs> </Tabs>
</div> </div>
); );
+36 -4
View File
@@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { PaginationControls } from '@/components/ui/pagination';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription'; import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator'; import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
@@ -46,16 +47,20 @@ export function UsersPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [filteredProfiles, setFilteredProfiles] = useState<Profile[]>([]); const [filteredProfiles, setFilteredProfiles] = useState<Profile[]>([]);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(25);
const fetchProfiles = useCallback(async () => { const fetchProfiles = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
// Fetch profiles with auth info (includes last_sign_in_at) // Fetch profiles with auth info (includes last_sign_in_at)
const { data: profilesData, error } = await supabase const { data: profilesDataRaw, error } = await supabase
.rpc('get_profiles_with_auth_info'); .rpc('get_profiles_with_auth_info');
if (error) throw error; if (error) throw error;
const profilesData = (profilesDataRaw || []) as Profile[];
// Fetch storage usage per user from storage.objects // Fetch storage usage per user from storage.objects
const { data: storageData } = await supabase const { data: storageData } = await supabase
.rpc('get_user_storage_usage'); .rpc('get_user_storage_usage');
@@ -70,7 +75,7 @@ export function UsersPage() {
// Build a map of couple_id -> users with premium in that couple // Build a map of couple_id -> users with premium in that couple
const couplePartners: Record<string, { id: string; is_premium: boolean }[]> = {}; const couplePartners: Record<string, { id: string; is_premium: boolean }[]> = {};
(profilesData || []).forEach(profile => { profilesData.forEach(profile => {
if (profile.couple_id) { if (profile.couple_id) {
if (!couplePartners[profile.couple_id]) { if (!couplePartners[profile.couple_id]) {
couplePartners[profile.couple_id] = []; couplePartners[profile.couple_id] = [];
@@ -83,7 +88,7 @@ export function UsersPage() {
}); });
// Merge storage data and partner premium status with profiles // Merge storage data and partner premium status with profiles
const profilesWithExtras = (profilesData || []).map(profile => { const profilesWithExtras = profilesData.map(profile => {
// Check if partner has premium // Check if partner has premium
let partnerIsPremium = false; let partnerIsPremium = false;
if (profile.couple_id && couplePartners[profile.couple_id]) { if (profile.couple_id && couplePartners[profile.couple_id]) {
@@ -134,6 +139,7 @@ export function UsersPage() {
}, [fetchProfiles]); }, [fetchProfiles]);
useEffect(() => { useEffect(() => {
setPage(1);
if (!search.trim()) { if (!search.trim()) {
setFilteredProfiles(profiles); setFilteredProfiles(profiles);
return; return;
@@ -150,6 +156,19 @@ export function UsersPage() {
); );
}, [search, profiles]); }, [search, profiles]);
const totalCount = filteredProfiles.length;
const paginatedProfiles = filteredProfiles.slice(
(page - 1) * pageSize,
page * pageSize
);
useEffect(() => {
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
if (page > totalPages) {
setPage(totalPages);
}
}, [page, pageSize, totalCount]);
if (loading) { if (loading) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -209,7 +228,7 @@ export function UsersPage() {
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
filteredProfiles.map((profile) => ( paginatedProfiles.map((profile) => (
<TableRow key={profile.id}> <TableRow key={profile.id}>
<TableCell> <TableCell>
<Link to={`/users/${profile.id}`} className="flex items-center gap-3 hover:opacity-80 transition-opacity"> <Link to={`/users/${profile.id}`} className="flex items-center gap-3 hover:opacity-80 transition-opacity">
@@ -294,6 +313,19 @@ export function UsersPage() {
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
{filteredProfiles.length > 0 && (
<PaginationControls
page={page}
pageSize={pageSize}
totalCount={totalCount}
onPageChange={setPage}
onPageSizeChange={(size) => {
setPage(1);
setPageSize(size);
}}
/>
)}
</div> </div>
); );
} }
+1 -1
View File
@@ -93,7 +93,7 @@ android {
applicationId 'com.sauci.app' applicationId 'com.sauci.app'
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 17 versionCode 21
versionName "1.0.0" versionName "1.0.0"
} }
signingConfigs { signingConfigs {
+4 -4
View File
@@ -16,15 +16,15 @@
"ios": { "ios": {
"supportsTablet": false, "supportsTablet": false,
"bundleIdentifier": "com.sauci.app", "bundleIdentifier": "com.sauci.app",
"buildNumber": "24", "buildNumber": "25",
"usesAppleSignIn": true, "usesAppleSignIn": true,
"infoPlist": { "infoPlist": {
"UIBackgroundModes": [ "UIBackgroundModes": [
"remote-notification" "remote-notification"
], ],
"NSFaceIDUsageDescription": "Sauci uses Face ID to unlock the app and keep your conversations private.", "NSFaceIDUsageDescription": "Sauci uses Face ID to unlock the app and keep your conversations private.",
"NSCameraUsageDescription": "Sauci uses your camera to take photos and videos that you can share privately with your partner in encrypted chat messages.", "NSCameraUsageDescription": "Sauci uses your camera to take photos and videos that you can share privately with your partner.",
"NSPhotoLibraryUsageDescription": "Sauci uses your photo library so you can select photos and videos to share privately with your partner in encrypted chat messages.", "NSPhotoLibraryUsageDescription": "Sauci uses your photo library so you can select photos and videos to share privately with your partner.",
"NSPhotoLibraryAddUsageDescription": "Sauci saves photos and videos from your partner's messages to your photo library when you tap the save button.", "NSPhotoLibraryAddUsageDescription": "Sauci saves photos and videos from your partner's messages to your photo library when you tap the save button.",
"NSMicrophoneUsageDescription": "Sauci uses your microphone to record audio when you capture videos to share privately with your partner." "NSMicrophoneUsageDescription": "Sauci uses your microphone to record audio when you capture videos to share privately with your partner."
}, },
@@ -176,7 +176,7 @@
"backgroundColor": "#1a1a2e" "backgroundColor": "#1a1a2e"
}, },
"package": "com.sauci.app", "package": "com.sauci.app",
"versionCode": 20, "versionCode": 21,
"googleServicesFile": "./google-services.json", "googleServicesFile": "./google-services.json",
"permissions": [ "permissions": [
"RECEIVE_BOOT_COMPLETED", "RECEIVE_BOOT_COMPLETED",
+2 -38
View File
@@ -5,8 +5,6 @@ import { BlurView } from "expo-blur";
import { LinearGradient } from "expo-linear-gradient"; import { LinearGradient } from "expo-linear-gradient";
import { useEffect, useState, useCallback, useRef } from "react"; import { useEffect, useState, useCallback, useRef } from "react";
import { useAuthStore, useMatchStore, useMessageStore, useSubscriptionStore, usePacksStore } from "../../src/store"; import { useAuthStore, useMatchStore, useMessageStore, useSubscriptionStore, usePacksStore } from "../../src/store";
import { useDecryptedMessage } from "../../src/hooks";
import type { KeysMetadata } from "../../src/lib/encryption";
import { colors, gradients, blur, radius, spacing, typography, shadows } from "../../src/theme"; import { colors, gradients, blur, radius, spacing, typography, shadows } from "../../src/theme";
import { supabase } from "../../src/lib/supabase"; import { supabase } from "../../src/lib/supabase";
import { isBiometricEnabled } from "../../src/lib/biometricAuth"; import { isBiometricEnabled } from "../../src/lib/biometricAuth";
@@ -20,10 +18,8 @@ type Message = Database["public"]["Tables"]["messages"]["Row"];
function MessageToastPreview({ function MessageToastPreview({
message, message,
currentUserId,
}: { }: {
message: Message; message: Message;
currentUserId: string;
}) { }) {
if (message.media_path) { if (message.media_path) {
const fallback = message.media_type === 'video' ? 'Sent a video' : 'Sent an image'; const fallback = message.media_type === 'video' ? 'Sent a video' : 'Sent an image';
@@ -34,41 +30,9 @@ function MessageToastPreview({
); );
} }
const version = message.version ?? 1;
const isV2 = version === 2;
const { content, isDecrypting, error } = useDecryptedMessage({
message: {
id: message.id,
content: message.content,
version,
encrypted_content: message.encrypted_content,
encryption_iv: message.encryption_iv,
keys_metadata: message.keys_metadata as unknown as KeysMetadata | null,
user_id: message.user_id,
},
currentUserId,
});
if (isDecrypting) {
return (
<Text style={styles.messageToastBody} numberOfLines={1}>
Waiting for message...
</Text>
);
}
if (error) {
return (
<Text style={styles.messageToastBody} numberOfLines={1}>
Encrypted message
</Text>
);
}
return ( return (
<Text style={styles.messageToastBody} numberOfLines={1}> <Text style={styles.messageToastBody} numberOfLines={1}>
{content ?? (!isV2 ? message.content : null) ?? 'New message'} {message.content ?? 'New message'}
</Text> </Text>
); );
} }
@@ -886,7 +850,7 @@ export default function AppLayout() {
<Text style={styles.messageToastTitle} numberOfLines={1}> <Text style={styles.messageToastTitle} numberOfLines={1}>
New message New message
</Text> </Text>
<MessageToastPreview message={lastMessage} currentUserId={user?.id ?? ''} /> <MessageToastPreview message={lastMessage} />
</View> </View>
<Pressable onPress={dismissMessageToast} hitSlop={8}> <Pressable onPress={dismissMessageToast} hitSlop={8}>
<Ionicons name="close" size={20} color={colors.textTertiary} /> <Ionicons name="close" size={20} color={colors.textTertiary} />
+2 -2
View File
@@ -22,7 +22,7 @@ const ACCENT_COLOR = colors.primary;
export default function HomeScreen() { export default function HomeScreen() {
const { user, partner, couple } = useAuthStore(); const { user, partner, couple } = useAuthStore();
const { matches, newMatchesCount, fetchMatches } = useMatchStore(); const { matches, newMatchesCount, totalCount, fetchMatches } = useMatchStore();
const { enabledPackIds, fetchPacks } = usePacksStore(); const { enabledPackIds, fetchPacks } = usePacksStore();
const { width } = useWindowDimensions(); const { width } = useWindowDimensions();
@@ -146,7 +146,7 @@ export default function HomeScreen() {
color={matches.length > 0 ? colors.text : ACCENT_COLOR} color={matches.length > 0 ? colors.text : ACCENT_COLOR}
/> />
</View> </View>
<Text style={styles.statNumber}>{matches.length}</Text> <Text style={styles.statNumber}>{totalCount ?? matches.length}</Text>
<Text style={styles.statLabel}>Matches</Text> <Text style={styles.statLabel}>Matches</Text>
</View> </View>
</TouchableOpacity> </TouchableOpacity>
+40 -12
View File
@@ -47,7 +47,7 @@ const AnimatedSectionList = Animated.createAnimatedComponent(SectionList);
type GroupByOption = "pack" | "date" | "answer"; type GroupByOption = "pack" | "date" | "answer";
export default function MyAnswersScreen() { export default function MyAnswersScreen() {
const { responses, isLoading, isLoadingMore, hasMore, groupBy, fetchResponses, setGroupBy, totalCount } = useResponsesStore(); const { responses, isLoading, isLoadingMore, hasMore, groupBy, dateSortOrder, fetchResponses, setGroupBy, toggleDateSortOrder, totalCount } = useResponsesStore();
const { user } = useAuthStore(); const { user } = useAuthStore();
const router = useRouter(); const router = useRouter();
const params = useLocalSearchParams(); const params = useLocalSearchParams();
@@ -123,7 +123,7 @@ export default function MyAnswersScreen() {
} }
}, [isLoading, isLoadingMore, hasMore]); }, [isLoading, isLoadingMore, hasMore]);
const sections = groupResponses(responses, groupBy); const sections = groupResponses(responses, groupBy, dateSortOrder);
const handleEditPress = (response: ResponseWithQuestion) => { const handleEditPress = (response: ResponseWithQuestion) => {
setSelectedResponse(response); setSelectedResponse(response);
@@ -164,16 +164,39 @@ export default function MyAnswersScreen() {
); );
}; };
const GroupByButton = ({ option, label }: { option: GroupByOption; label: string }) => ( const GroupByButton = ({ option, label }: { option: GroupByOption; label: string }) => {
<TouchableOpacity const isActive = groupBy === option;
style={[styles.groupByButton, groupBy === option && styles.groupByButtonActive]} const isDateButton = option === "date";
onPress={() => setGroupBy(option)} const showChevron = isDateButton && isActive;
>
<Text style={[styles.groupByText, groupBy === option && styles.groupByTextActive]}> const handlePress = () => {
{label} if (isDateButton && isActive) {
</Text> // Toggle sort order when date is already selected
</TouchableOpacity> toggleDateSortOrder();
); } else {
setGroupBy(option);
}
};
return (
<TouchableOpacity
style={[styles.groupByButton, isActive && styles.groupByButtonActive]}
onPress={handlePress}
>
<Text style={[styles.groupByText, isActive && styles.groupByTextActive]}>
{label}
</Text>
{showChevron && (
<Ionicons
name={dateSortOrder === "newest" ? "chevron-down" : "chevron-up"}
size={14}
color={ACCENT}
style={styles.sortChevron}
/>
)}
</TouchableOpacity>
);
};
if (!user) { if (!user) {
return ( return (
@@ -441,6 +464,8 @@ const styles = StyleSheet.create({
marginTop: spacing.md, marginTop: spacing.md,
}, },
groupByButton: { groupByButton: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: spacing.md, paddingHorizontal: spacing.md,
paddingVertical: spacing.sm, paddingVertical: spacing.sm,
borderRadius: radius.full, borderRadius: radius.full,
@@ -461,6 +486,9 @@ const styles = StyleSheet.create({
color: ACCENT, color: ACCENT,
fontWeight: "600", fontWeight: "600",
}, },
sortChevron: {
marginLeft: spacing.xs,
},
// Section header // Section header
sectionHeader: { sectionHeader: {
paddingVertical: spacing.sm, paddingVertical: spacing.sm,
-2
View File
@@ -48,7 +48,6 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import * as Linking from "expo-linking"; import * as Linking from "expo-linking";
import { useAuthStore } from "../src/store"; import { useAuthStore } from "../src/store";
import { supabase } from "../src/lib/supabase"; import { supabase } from "../src/lib/supabase";
import { EncryptionKeyInitializer } from "../src/components/EncryptionKeyInitializer";
const queryClient = new QueryClient(); const queryClient = new QueryClient();
@@ -213,7 +212,6 @@ export default function RootLayout() {
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<GestureHandlerRootView style={{ flex: 1 }}> <GestureHandlerRootView style={{ flex: 1 }}>
<StatusBar style="light" /> <StatusBar style="light" />
<EncryptionKeyInitializer />
<Stack <Stack
screenOptions={{ screenOptions={{
headerShown: false, headerShown: false,
+3 -3
View File
@@ -47,7 +47,7 @@
</dict> </dict>
</array> </array>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>20</string> <string>25</string>
<key>LSMinimumSystemVersion</key> <key>LSMinimumSystemVersion</key>
<string>12.0</string> <string>12.0</string>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
@@ -60,7 +60,7 @@
<true/> <true/>
</dict> </dict>
<key>NSCameraUsageDescription</key> <key>NSCameraUsageDescription</key>
<string>Sauci uses your camera to take photos and videos that you can share privately with your partner in encrypted chat messages.</string> <string>Sauci uses your camera to take photos and videos that you can share privately with your partner.</string>
<key>NSFaceIDUsageDescription</key> <key>NSFaceIDUsageDescription</key>
<string>Sauci uses Face ID to unlock the app and keep your conversations private.</string> <string>Sauci uses Face ID to unlock the app and keep your conversations private.</string>
<key>NSMicrophoneUsageDescription</key> <key>NSMicrophoneUsageDescription</key>
@@ -68,7 +68,7 @@
<key>NSPhotoLibraryAddUsageDescription</key> <key>NSPhotoLibraryAddUsageDescription</key>
<string>Sauci saves photos and videos from your partner's messages to your photo library when you tap the save button.</string> <string>Sauci saves photos and videos from your partner's messages to your photo library when you tap the save button.</string>
<key>NSPhotoLibraryUsageDescription</key> <key>NSPhotoLibraryUsageDescription</key>
<string>Sauci uses your photo library so you can select photos and videos to share privately with your partner in encrypted chat messages.</string> <string>Sauci uses your photo library so you can select photos and videos to share privately with your partner.</string>
<key>NSUserActivityTypes</key> <key>NSUserActivityTypes</key>
<array> <array>
<string>$(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route</string> <string>$(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route</string>
@@ -10,7 +10,6 @@ describe('InputBar', () => {
<InputBar <InputBar
inputText="" inputText=""
uploading={false} uploading={false}
secureReady={true}
onChangeText={onChangeText} onChangeText={onChangeText}
onSend={jest.fn()} onSend={jest.fn()}
onPickMedia={jest.fn()} onPickMedia={jest.fn()}
@@ -30,7 +29,6 @@ describe('InputBar', () => {
<InputBar <InputBar
inputText="" inputText=""
uploading={false} uploading={false}
secureReady={true}
onChangeText={jest.fn()} onChangeText={jest.fn()}
onSend={onSend} onSend={onSend}
onPickMedia={jest.fn()} onPickMedia={jest.fn()}
@@ -50,7 +48,6 @@ describe('InputBar', () => {
<InputBar <InputBar
inputText="" inputText=""
uploading={false} uploading={false}
secureReady={true}
onChangeText={jest.fn()} onChangeText={jest.fn()}
onSend={onSend} onSend={onSend}
onPickMedia={jest.fn()} onPickMedia={jest.fn()}
@@ -63,7 +60,6 @@ describe('InputBar', () => {
<InputBar <InputBar
inputText="hi" inputText="hi"
uploading={false} uploading={false}
secureReady={true}
onChangeText={jest.fn()} onChangeText={jest.fn()}
onSend={onSend} onSend={onSend}
onPickMedia={jest.fn()} onPickMedia={jest.fn()}
@@ -76,26 +72,6 @@ describe('InputBar', () => {
expect(onSend).toHaveBeenCalledTimes(1); expect(onSend).toHaveBeenCalledTimes(1);
}); });
it('disables send when secure messaging is not ready', () => {
const onSend = jest.fn();
const { getByTestId } = render(
<InputBar
inputText="hello"
uploading={false}
secureReady={false}
onChangeText={jest.fn()}
onSend={onSend}
onPickMedia={jest.fn()}
onTakePhoto={jest.fn()}
onRecordVideo={jest.fn()}
/>
);
fireEvent.press(getByTestId('chat-input-send'));
expect(onSend).not.toHaveBeenCalled();
});
it('calls media actions from menu buttons', () => { it('calls media actions from menu buttons', () => {
const onPickMedia = jest.fn(); const onPickMedia = jest.fn();
const onTakePhoto = jest.fn(); const onTakePhoto = jest.fn();
@@ -105,7 +81,6 @@ describe('InputBar', () => {
<InputBar <InputBar
inputText="hello" inputText="hello"
uploading={false} uploading={false}
secureReady={true}
onChangeText={jest.fn()} onChangeText={jest.fn()}
onSend={jest.fn()} onSend={jest.fn()}
onPickMedia={onPickMedia} onPickMedia={onPickMedia}
@@ -1,330 +0,0 @@
import {
generateAESKey,
generateIV,
encryptAES,
decryptAES,
exportAESKey,
importAESKey,
wrapAESKey,
unwrapAESKey,
verifyKeyPair,
} from '../../lib/encryption/crypto';
import {
generateAndStoreKeyPair,
getPrivateKey,
clearKeys,
sanitizePublicKeyJwk,
isValidPublicKeyJwk,
} from '../../lib/encryption/keyManager';
import { encryptTextMessage } from '../../lib/encryption/messageEncryption';
import { decryptTextMessage } from '../../lib/encryption/messageDecryption';
import { RSAPublicKeyJWK, RSAPrivateKeyJWK } from '../../lib/encryption/types';
// Mock dependencies
jest.mock('expo-secure-store', () => {
let store: Record<string, string> = {};
return {
setItemAsync: jest.fn(async (key, value) => {
store[key] = value;
}),
getItemAsync: jest.fn(async (key) => {
return store[key] || null;
}),
deleteItemAsync: jest.fn(async (key) => {
delete store[key];
}),
_clear: () => {
store = {};
},
};
});
jest.mock('react-native', () => ({
Platform: {
OS: 'ios', // Mock as native platform to allow key generation
},
}));
// Mock react-native-quick-crypto's polyfill behavior if needed,
// but Node.js 20+ has global.crypto.subtle.
// If tests fail due to missing crypto, we will need to polyfill it.
if (!global.crypto) {
// @ts-ignore
global.crypto = require('crypto').webcrypto;
}
describe('E2EE Encryption Library', () => {
// 1. Low-Level Crypto Primitives
describe('Crypto Primitives (AES-GCM)', () => {
it('should generate a valid AES-256 key', async () => {
const key = await generateAESKey();
expect(key.algorithm.name).toBe('AES-GCM');
// @ts-ignore - length is present in Web Crypto keys
expect(key.algorithm.length).toBe(256);
expect(key.extractable).toBe(true);
});
it('should generate a valid 12-byte IV', () => {
const iv = generateIV();
expect(iv.length).toBe(12);
expect(iv).toBeInstanceOf(Uint8Array);
});
it('should correctly round-trip AES encryption/decryption', async () => {
const plaintext = 'Hello, Secret World!';
const key = await generateAESKey();
const iv = generateIV();
const ciphertext = await encryptAES(plaintext, key, iv);
const decryptedBuffer = await decryptAES(ciphertext, key, iv);
const decryptedText = new TextDecoder().decode(decryptedBuffer);
expect(decryptedText).toBe(plaintext);
});
it('should export and import AES keys correctly', async () => {
const originalKey = await generateAESKey();
const rawKey = await exportAESKey(originalKey);
expect(rawKey.byteLength).toBe(32); // 256 bits = 32 bytes
const importedKey = await importAESKey(rawKey);
expect(importedKey.algorithm.name).toBe('AES-GCM');
// @ts-ignore
expect(importedKey.algorithm.length).toBe(256);
});
});
// 2. RSA Key Wrapping
describe('RSA Key Wrapping', () => {
let aliceKeyPair: CryptoKeyPair;
let alicePublicKeyJwk: RSAPublicKeyJWK;
let alicePrivateKeyJwk: RSAPrivateKeyJWK;
beforeAll(async () => {
// Generate a real keypair for testing using Web Crypto directly
// simulating what generateAndStoreKeyPair does internally
aliceKeyPair = await crypto.subtle.generateKey(
{
name: 'RSA-OAEP',
modulusLength: 2048,
publicExponent: new Uint8Array([1, 0, 1]),
hash: 'SHA-256',
},
true,
['encrypt', 'decrypt']
);
alicePublicKeyJwk = (await crypto.subtle.exportKey('jwk', aliceKeyPair.publicKey)) as RSAPublicKeyJWK;
alicePrivateKeyJwk = (await crypto.subtle.exportKey('jwk', aliceKeyPair.privateKey)) as RSAPrivateKeyJWK;
});
it('should wrap and unwrap an AES key using RSA keys', async () => {
const aesKey = await generateAESKey();
const rawAesKey = await exportAESKey(aesKey);
// Wrap with Public Key
const wrappedKeyBase64 = await wrapAESKey(rawAesKey, alicePublicKeyJwk);
expect(typeof wrappedKeyBase64).toBe('string');
// Unwrap with Private Key
const unwrappedRawKey = await unwrapAESKey(wrappedKeyBase64, alicePrivateKeyJwk);
// Compare bytes
const originalBytes = new Uint8Array(rawAesKey);
const unwrappedBytes = new Uint8Array(unwrappedRawKey);
expect(unwrappedBytes).toEqual(originalBytes);
});
it('should verify matching key pairs', async () => {
const isMatch = await verifyKeyPair(alicePublicKeyJwk, alicePrivateKeyJwk);
expect(isMatch).toBe(true);
});
it('should reject mismatched key pairs', async () => {
// Suppress expected console error
const originalError = console.error;
console.error = jest.fn();
try {
// Generate a second, different keypair
const bobKeyPair = await crypto.subtle.generateKey(
{
name: 'RSA-OAEP',
modulusLength: 2048,
publicExponent: new Uint8Array([1, 0, 1]),
hash: 'SHA-256',
},
true,
['encrypt', 'decrypt']
);
const bobPrivateKeyJwk = (await crypto.subtle.exportKey('jwk', bobKeyPair.privateKey)) as RSAPrivateKeyJWK;
// Check mismatch (Alice's Public + Bob's Private)
const isMatch = await verifyKeyPair(alicePublicKeyJwk, bobPrivateKeyJwk);
expect(isMatch).toBe(false);
} finally {
// Restore console error
console.error = originalError;
}
});
});
// 3. Key Manager (with mocked SecureStore)
describe('Key Manager', () => {
beforeEach(() => {
// Clear mocked store
require('expo-secure-store')._clear();
});
it('should generate and store keys correctly', async () => {
const publicKey = await generateAndStoreKeyPair();
expect(publicKey.kty).toBe('RSA');
expect(publicKey.alg).toBe('RSA-OAEP-256');
// Check SecureStore interaction
const storedPrivateKey = await getPrivateKey();
expect(storedPrivateKey).toBeDefined();
expect(storedPrivateKey?.d).toBeDefined(); // 'd' is the private exponent
// Verify they match
const isMatch = await verifyKeyPair(publicKey, storedPrivateKey!);
expect(isMatch).toBe(true);
});
it('should clear keys', async () => {
await generateAndStoreKeyPair();
await clearKeys();
const storedPrivateKey = await getPrivateKey();
expect(storedPrivateKey).toBeNull();
});
it('should validate and sanitize public keys', () => {
// Valid key
const validKey: RSAPublicKeyJWK = {
kty: 'RSA',
n: 'some_base64_string',
e: 'AQAB',
alg: 'RSA-OAEP-256'
};
expect(isValidPublicKeyJwk(validKey)).toBe(true);
// Invalid key (missing fields)
expect(isValidPublicKeyJwk({} as any)).toBe(false);
// Key with trailing dots (sanitization test)
const dirtyKey: RSAPublicKeyJWK = {
...validKey,
n: 'some_base64_string...',
e: 'AQAB.'
};
const cleanKey = sanitizePublicKeyJwk(dirtyKey);
expect(cleanKey.n).toBe('some_base64_string');
expect(cleanKey.e).toBe('AQAB');
expect(isValidPublicKeyJwk(dirtyKey)).toBe(true); // Should pass because it sanitizes internally
});
});
// 4. High-Level Message Flow (Alice -> Bob)
describe('Full Message Flow (Alice -> Bob)', () => {
let aliceKeys: { pub: RSAPublicKeyJWK, priv: RSAPrivateKeyJWK };
let bobKeys: { pub: RSAPublicKeyJWK, priv: RSAPrivateKeyJWK };
let adminKeys: { pub: RSAPublicKeyJWK, priv: RSAPrivateKeyJWK };
beforeAll(async () => {
const genKey = async () => {
const kp = await crypto.subtle.generateKey(
{
name: 'RSA-OAEP',
modulusLength: 2048,
publicExponent: new Uint8Array([1, 0, 1]),
hash: 'SHA-256',
},
true,
['encrypt', 'decrypt']
);
return {
pub: (await crypto.subtle.exportKey('jwk', kp.publicKey)) as RSAPublicKeyJWK,
priv: (await crypto.subtle.exportKey('jwk', kp.privateKey)) as RSAPrivateKeyJWK,
};
};
aliceKeys = await genKey();
bobKeys = await genKey();
adminKeys = await genKey();
});
it('should encrypt and decrypt a message successfully', async () => {
const plaintext = "Meet me at the secret base.";
const adminKeyId = "admin-key-uuid";
// 1. Alice Encrypts
const encryptedPayload = await encryptTextMessage(
plaintext,
aliceKeys.pub,
bobKeys.pub,
adminKeys.pub,
adminKeyId
);
expect(encryptedPayload.version).toBe(2);
expect(encryptedPayload.keys_metadata.algorithm).toBe('AES-256-GCM');
expect(encryptedPayload.keys_metadata.sender_wrapped_key).toBeDefined();
expect(encryptedPayload.keys_metadata.recipient_wrapped_key).toBeDefined();
expect(encryptedPayload.keys_metadata.admin_wrapped_key).toBeDefined();
// 2. Bob Decrypts (as Recipient)
const decryptedByBob = await decryptTextMessage(
encryptedPayload.encrypted_content,
encryptedPayload.encryption_iv,
encryptedPayload.keys_metadata,
bobKeys.priv,
true // isRecipient
);
expect(decryptedByBob).toBe(plaintext);
// 3. Alice Decrypts (as Sender) - e.g. for history
const decryptedByAlice = await decryptTextMessage(
encryptedPayload.encrypted_content,
encryptedPayload.encryption_iv,
encryptedPayload.keys_metadata,
aliceKeys.priv,
false // isRecipient = false
);
expect(decryptedByAlice).toBe(plaintext);
});
it('should handle pending recipient (no recipient key)', async () => {
const plaintext = "Waiting for you to join...";
const adminKeyId = "admin-key-uuid";
// Encrypt with NO recipient key (null)
const encryptedPayload = await encryptTextMessage(
plaintext,
aliceKeys.pub,
null, // No recipient key
adminKeys.pub,
adminKeyId
);
expect(encryptedPayload.keys_metadata.recipient_wrapped_key).toBeUndefined();
expect(encryptedPayload.keys_metadata.pending_recipient).toBe(true);
// Sender (Alice) can still decrypt
const decryptedByAlice = await decryptTextMessage(
encryptedPayload.encrypted_content,
encryptedPayload.encryption_iv,
encryptedPayload.keys_metadata,
aliceKeys.priv,
false
);
expect(decryptedByAlice).toBe(plaintext);
});
});
});
@@ -1,206 +0,0 @@
/**
* EncryptionKeyInitializer Component
*
* Handles automatic initialization and upload of E2EE encryption keys.
* Should be placed in the app layout to run after authentication.
*
* Uses global Zustand store for key state to ensure all components
* see the same keys at all times.
*/
import { useEffect, useRef, useState } from 'react';
import { Platform } from 'react-native';
import { useEncryptionKeys } from '../hooks';
import { useAuthStore } from '../store';
import { reencryptPendingMessages } from '../lib/encryption/reencryptPendingMessages';
import { triggerKeyRotation, getAdminPublicKey } from '../lib/encryption';
import { LoadingOverlay } from './ui/LoadingOverlay';
import { colors } from '../theme';
/**
* Check if crypto is available (react-native-quick-crypto polyfill installed)
*/
function isCryptoAvailable(): boolean {
return typeof crypto !== 'undefined' && crypto.subtle !== undefined;
}
/**
* Component that initializes encryption keys after user authentication.
*
* Flow:
* 1. When user signs in, check if they have encryption keys
* 2. If not, generate new RSA key pair
* 3. Upload public key to Supabase profile
* 4. If user has a partner without keys, trigger re-encryption when partner generates keys
*/
export function EncryptionKeyInitializer(): JSX.Element | null {
const { initializeAndUploadKeys, isLoading: keysLoading, hasKeys, error } = useEncryptionKeys();
const user = useAuthStore((s) => s.user);
const couple = useAuthStore((s) => s.couple);
// Track if keys were regenerated this session (new device, reinstall, etc.)
const [wasRegenerated, setWasRegenerated] = useState(false);
const hasTriggeredRotation = useRef(false);
const hasLoggedCryptoWarning = useRef(false);
const isInitializing = useRef(false);
const hasInitialized = useRef(false);
// Show loading overlay when we are initializing keys (generating new ones takes time)
// or when rotation is in progress
const [showLoading, setShowLoading] = useState(false);
const [loadingStatus, setLoadingStatus] = useState('Connecting...');
// Effect 1: Initialize encryption keys when user is authenticated
useEffect(() => {
// Skip on web - E2EE not supported
if (Platform.OS === 'web') return;
// Check if crypto is available (native modules rebuilt)
if (!isCryptoAvailable()) {
if (!hasLoggedCryptoWarning.current) {
console.warn('[E2EE Init] Crypto not available. Run: npx expo prebuild --clean');
hasLoggedCryptoWarning.current = true;
}
return;
}
// Skip if no user, already initialized, or currently initializing
if (!user?.id || hasInitialized.current || isInitializing.current) return;
// Wait for initial key loading to complete
if (keysLoading) return;
// Skip if keys already exist in global state
if (hasKeys) {
console.log('[E2EE Init] Keys already loaded in global state');
hasInitialized.current = true;
return;
}
// Skip if there was an error loading keys (avoid retry loop)
if (error) {
console.warn('[E2EE Init] Skipping key initialization due to previous error:', error.message);
return;
}
const initKeys = async () => {
try {
// Mark as initializing to prevent re-entry
isInitializing.current = true;
// Only show loading if we are generating keys (which might take a second)
// Standard loading is fast enough to not need a full screen blocker
console.log('[E2EE Init] Starting key initialization...');
// Initialize keys and upload in one operation
const result = await initializeAndUploadKeys();
console.log('[E2EE Init] Encryption keys initialized and uploaded');
// Pre-fetch admin key to warm the cache before user reaches chat
// This reduces the delay before secureReady becomes true
try {
await getAdminPublicKey();
console.log('[E2EE Init] Admin key pre-fetched successfully');
} catch (adminKeyError) {
// Non-fatal: admin key will be fetched again when needed
console.warn('[E2EE Init] Failed to pre-fetch admin key:', adminKeyError);
}
// Track if keys were regenerated (for rotation decision)
if (result.wasRegenerated) {
console.log('[E2EE Init] Keys were regenerated this session');
setWasRegenerated(true);
}
hasInitialized.current = true;
} catch (err) {
console.error('[E2EE Init] Failed to initialize encryption keys:', err);
// Reset initialization flag so we can retry on next render
// But only if it's a transient error, not a crypto unavailable error
if (err instanceof Error && !err.message.includes('Crypto')) {
isInitializing.current = false;
}
setShowLoading(false);
}
};
initKeys();
}, [user?.id, keysLoading, hasKeys, error, initializeAndUploadKeys]);
// Effect 2: Trigger key rotation when keys were regenerated and couple is available
useEffect(() => {
// Skip on web or if crypto not available
if (Platform.OS === 'web' || !isCryptoAvailable()) return;
// Need user and couple to trigger rotation
if (!user?.id || !couple?.id) return;
// Only trigger once per session
if (hasTriggeredRotation.current) return;
// Wait for keys to be initialized in global state
if (!hasKeys) return;
// Only trigger rotation if keys were actually regenerated this session
// (new device, app reinstall, key corruption recovery, etc.)
if (!wasRegenerated) {
// If we're done initializing but not rotating, hide loading
if (showLoading) {
setShowLoading(false);
isInitializing.current = false;
}
console.log('[E2EE Init] Keys unchanged, skipping rotation');
return;
}
const runRotation = async () => {
try {
hasTriggeredRotation.current = true;
// Only show loading for rotation which is a heavier operation
setShowLoading(true);
setLoadingStatus('Updating security...');
// Trigger key rotation - re-wraps message keys for the new public key
console.log('[E2EE Init] Keys were regenerated, triggering key rotation...');
await triggerKeyRotation();
// Trigger re-encryption of pending messages (for messages where
// recipient had no key at send time)
await reencryptPendingMessages({ force: true });
console.log('[E2EE Init] Key rotation complete');
} catch (err) {
console.error('[E2EE Init] Failed to run key rotation:', err);
// Reset so we can retry
hasTriggeredRotation.current = false;
} finally {
setShowLoading(false);
isInitializing.current = false;
}
};
runRotation();
}, [user?.id, couple?.id, hasKeys, wasRegenerated, showLoading]);
// Reset initialization flags when user changes (sign out/sign in)
useEffect(() => {
if (!user?.id) {
hasInitialized.current = false;
setWasRegenerated(false);
isInitializing.current = false;
hasTriggeredRotation.current = false;
setShowLoading(false);
}
}, [user?.id]);
return (
<LoadingOverlay
visible={showLoading}
statusText={loadingStatus}
detailText="This helps keep your conversations private."
fullScreen={true}
spinnerColor={colors.primary}
/>
);
}
+9 -110
View File
@@ -16,7 +16,7 @@ import type { ReportReason } from "./types";
import { supabase } from "../../lib/supabase"; import { supabase } from "../../lib/supabase";
import { useAuthStore, useMessageStore } from "../../store"; import { useAuthStore, useMessageStore } from "../../store";
import { useAmbientOrbAnimation, useMediaPicker, useTypingIndicator, useMessageSubscription, useEncryptedSend, useMediaSaver } from "../../hooks"; import { useAmbientOrbAnimation, useMediaPicker, useTypingIndicator, useMessageSubscription, useMediaSaver } from "../../hooks";
import { Database } from "../../types/supabase"; import { Database } from "../../types/supabase";
import { GradientBackground } from "../../components/ui"; import { GradientBackground } from "../../components/ui";
import { colors, gradients, radius, spacing, typography } from "../../theme"; import { colors, gradients, radius, spacing, typography } from "../../theme";
@@ -34,7 +34,7 @@ export const ChatScreen: React.FC = () => {
const matchId = id as string; const matchId = id as string;
const router = useRouter(); const router = useRouter();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { user, partner, encryptionKeys } = useAuthStore(); const { user, partner } = useAuthStore();
const { setActiveMatchId, markMatchMessagesAsRead } = useMessageStore(); const { setActiveMatchId, markMatchMessagesAsRead } = useMessageStore();
const [match, setMatch] = useState<any>(null); const [match, setMatch] = useState<any>(null);
const [inputText, setInputText] = useState(""); const [inputText, setInputText] = useState("");
@@ -74,23 +74,6 @@ export const ChatScreen: React.FC = () => {
onNewMessage: clearTypingIndicator, onNewMessage: clearTypingIndicator,
}); });
// E2EE encryption hook for sending messages
const { encryptMessage, isE2EEAvailable, isAdminKeyReady, isEncrypting } = useEncryptedSend();
const secureReady = isE2EEAvailable && isAdminKeyReady;
const recoveryMessage = (() => {
if (!encryptionKeys.isRecovering) return null;
switch (encryptionKeys.recoveryReason) {
case 'pending-recipient':
return 'Finishing security setup for older messages...';
case 'keys-unavailable':
return 'Setting up secure keys...';
case 'stale-key':
case 'rotation':
return 'Updating security for this device...';
default:
return 'Updating security...';
}
})();
// Message actions hook for deletion and reporting // Message actions hook for deletion and reporting
const { showDeleteOptions, deleteForSelf } = useMessageActions({ const { showDeleteOptions, deleteForSelf } = useMessageActions({
@@ -166,6 +149,7 @@ export const ChatScreen: React.FC = () => {
if (!matchId) return; if (!matchId) return;
const fetchMatch = async () => { const fetchMatch = async () => {
setMatch(null); // Clear previous match data immediately
const { data } = await supabase const { data } = await supabase
.from("matches") .from("matches")
.select("*, question:questions(*)") .select("*, question:questions(*)")
@@ -196,35 +180,18 @@ export const ChatScreen: React.FC = () => {
const handleSend = async () => { const handleSend = async () => {
if (!inputText.trim()) return; if (!inputText.trim()) return;
if (!user) return; if (!user) return;
if (!secureReady) return;
if (isEncrypting) return;
const content = inputText.trim(); const content = inputText.trim();
setInputText(""); setInputText("");
try { try {
// Encrypt the message (secure-only) // Send plaintext message (v1) - protected by TLS in transit and RLS policies
console.log(`[E2EE Send] isE2EEAvailable=${isE2EEAvailable}`); const { error } = await supabase.from("messages").insert({
const encryptedPayload = await encryptMessage(content);
console.log(`[E2EE Send] encryptedPayload=`, {
version: encryptedPayload.version,
encrypted_content_length: encryptedPayload.encrypted_content.length,
encryption_iv_length: encryptedPayload.encryption_iv.length,
keys_metadata: encryptedPayload.keys_metadata,
});
// Send encrypted message (v2)
const { error, data } = await supabase.from("messages").insert({
match_id: matchId, match_id: matchId,
user_id: user.id, user_id: user.id,
content: null, content: content,
version: encryptedPayload.version, version: 1,
encrypted_content: encryptedPayload.encrypted_content, });
encryption_iv: encryptedPayload.encryption_iv,
keys_metadata: encryptedPayload.keys_metadata as unknown as Record<string, unknown>,
}).select();
console.log(`[E2EE Send] Insert result:`, { error, data });
if (error) { if (error) {
Alert.alert("Error", "Failed to send message"); Alert.alert("Error", "Failed to send message");
@@ -234,26 +201,12 @@ export const ChatScreen: React.FC = () => {
} }
} catch (err) { } catch (err) {
console.error("Error sending message:", err); console.error("Error sending message:", err);
// Show the actual error message if it's user-friendly (from encryption) Alert.alert("Delivery Failed", "Couldn't send message. Please check your connection.");
const errorMessage = err instanceof Error && err.message
? err.message
: "Failed to send message";
// Map technical/unknown errors to friendly ones
let friendlyMessage = errorMessage;
if (errorMessage.includes('Failed to send') && !errorMessage.includes('connection')) {
friendlyMessage = "Couldn't send secure message. Please check your connection.";
} else if (errorMessage.includes('verification failed')) {
friendlyMessage = "Security check failed. Please restart the app.";
}
Alert.alert("Delivery Failed", friendlyMessage);
setInputText(content); setInputText(content);
} }
}; };
const handlePickMedia = async () => { const handlePickMedia = async () => {
if (!secureReady) return;
const result = await pickMedia(); const result = await pickMedia();
if (result) { if (result) {
uploadMedia(result.uri, result.mediaType); uploadMedia(result.uri, result.mediaType);
@@ -261,7 +214,6 @@ export const ChatScreen: React.FC = () => {
}; };
const handleTakePhoto = async () => { const handleTakePhoto = async () => {
if (!secureReady) return;
const result = await takePhoto(); const result = await takePhoto();
if (result) { if (result) {
uploadMedia(result.uri, 'image'); uploadMedia(result.uri, 'image');
@@ -269,7 +221,6 @@ export const ChatScreen: React.FC = () => {
}; };
const handleRecordVideo = async () => { const handleRecordVideo = async () => {
if (!secureReady) return;
const result = await recordVideo(); const result = await recordVideo();
if (result) { if (result) {
uploadMedia(result.uri, 'video'); uploadMedia(result.uri, 'video');
@@ -335,13 +286,6 @@ export const ChatScreen: React.FC = () => {
onBack={() => router.push('/(app)/matches')} onBack={() => router.push('/(app)/matches')}
/> />
{recoveryMessage && (
<View style={styles.securityBanner}>
<Ionicons name="shield-checkmark-outline" size={16} color={ACCENT} />
<Text style={styles.securityBannerText}>{recoveryMessage}</Text>
<ActivityIndicator size="small" color={ACCENT} />
</View>
)}
<ChatMessages <ChatMessages
messages={messages} messages={messages}
@@ -370,17 +314,9 @@ export const ChatScreen: React.FC = () => {
{/* Top border glow */} {/* Top border glow */}
<View style={styles.inputWrapperBorder} /> <View style={styles.inputWrapperBorder} />
{!secureReady && (
<View style={styles.secureStatus}>
<Ionicons name="lock-closed-outline" size={14} color={colors.textSecondary} />
<Text style={styles.secureStatusText}>Finishing security setup...</Text>
</View>
)}
<InputBar <InputBar
inputText={inputText} inputText={inputText}
uploading={uploading} uploading={uploading}
secureReady={secureReady}
onChangeText={handleTyping} onChangeText={handleTyping}
onSend={handleSend} onSend={handleSend}
onPickMedia={handlePickMedia} onPickMedia={handlePickMedia}
@@ -585,41 +521,4 @@ const styles = StyleSheet.create({
height: 1, height: 1,
backgroundColor: `${ACCENT_RGBA}0.15)`, backgroundColor: `${ACCENT_RGBA}0.15)`,
}, },
securityBanner: {
marginHorizontal: spacing.md,
marginTop: spacing.sm,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.md,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
backgroundColor: 'rgba(212, 175, 55, 0.12)',
borderWidth: 1,
borderColor: 'rgba(212, 175, 55, 0.25)',
},
securityBannerText: {
flex: 1,
color: colors.textSecondary,
fontSize: 13,
fontWeight: '600',
},
secureStatus: {
alignSelf: 'flex-start',
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
marginHorizontal: spacing.md,
marginTop: spacing.sm,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
borderRadius: radius.full,
backgroundColor: colors.glass.background,
borderWidth: 1,
borderColor: colors.glass.border,
},
secureStatusText: {
...typography.caption1,
color: colors.textTertiary,
},
}); });
@@ -14,7 +14,6 @@ import { colors, gradients, spacing, radius, typography } from '../../../theme';
export interface InputBarProps { export interface InputBarProps {
inputText: string; inputText: string;
uploading: boolean; uploading: boolean;
secureReady: boolean;
onChangeText: (text: string) => void; onChangeText: (text: string) => void;
onSend: () => void; onSend: () => void;
onPickMedia: () => void; onPickMedia: () => void;
@@ -31,7 +30,6 @@ const ACCENT = colors.premium.gold;
export function InputBar({ export function InputBar({
inputText, inputText,
uploading, uploading,
secureReady,
onChangeText, onChangeText,
onSend, onSend,
onPickMedia, onPickMedia,
@@ -65,10 +63,8 @@ export function InputBar({
transform: [{ rotate: `${buttonRotation.value}deg` }], transform: [{ rotate: `${buttonRotation.value}deg` }],
})); }));
const sendDisabled = !inputText.trim() || !secureReady; const sendDisabled = !inputText.trim();
const mediaDisabled = uploading || !secureReady; const mediaDisabled = uploading;
// Determine if send is disabled specifically because of security (not empty input)
const waitingForSecurity = !secureReady && inputText.trim().length > 0;
return ( return (
<View style={styles.container}> <View style={styles.container}>
@@ -153,8 +149,7 @@ export function InputBar({
disabled={sendDisabled} disabled={sendDisabled}
activeOpacity={0.8} activeOpacity={0.8}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={waitingForSecurity ? "Send message - securing connection" : "Send message"} accessibilityLabel="Send message"
accessibilityHint={waitingForSecurity ? "Please wait while we secure your connection" : undefined}
testID="chat-input-send" testID="chat-input-send"
> >
<LinearGradient <LinearGradient
@@ -173,8 +168,8 @@ export function InputBar({
/> />
)} )}
<Ionicons <Ionicons
name={waitingForSecurity ? "lock-closed" : "send"} name="send"
size={waitingForSecurity ? 16 : 18} size={18}
color={sendDisabled ? colors.textTertiary : colors.text} color={sendDisabled ? colors.textTertiary : colors.text}
/> />
</LinearGradient> </LinearGradient>
@@ -1,18 +1,16 @@
/** /**
* MessageContent - Renders message content (text, images, videos) * MessageContent - Renders message content (text, images, videos)
* Handles media reveal logic, expired media, and loading states. * Handles media reveal logic, expired media, and loading states.
* Supports both v1 (plaintext) and v2 (E2EE encrypted) messages.
*/ */
import React from 'react'; import React, { useState, useEffect } from 'react';
import { View, TouchableOpacity, Text, StyleSheet, ActivityIndicator, Alert } from 'react-native'; import { View, TouchableOpacity, Text, StyleSheet, ActivityIndicator } from 'react-native';
import { Image } from 'expo-image'; import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { colors, gradients, spacing, radius } from '../../../theme'; import { colors, gradients, spacing, radius } from '../../../theme';
import { MessageMeta } from './MessageMeta'; import { MessageMeta } from './MessageMeta';
import { ChatVideoPlayer } from './ChatVideoPlayer'; import { ChatVideoPlayer } from './ChatVideoPlayer';
import { useDecryptedMessage, useDecryptedMedia } from '../../../hooks'; import { supabase } from '../../../lib/supabase';
import type { KeysMetadata } from '../../../lib/encryption';
import type { Message } from '../types'; import type { Message } from '../types';
const ACCENT = colors.premium.gold; const ACCENT = colors.premium.gold;
@@ -52,61 +50,41 @@ export function MessageContent({
); );
} }
// Use decryption hook for E2EE text messages
const {
content: decryptedContent,
isDecrypting,
error: decryptError,
errorCode: decryptErrorCode,
retry: retryDecrypt,
} = useDecryptedMessage({
message: {
id: item.id,
content: item.content,
version: item.version,
encrypted_content: item.encrypted_content,
encryption_iv: item.encryption_iv,
keys_metadata: item.keys_metadata as KeysMetadata | null,
user_id: item.user_id,
},
currentUserId,
});
const isVideo = (item as any).media_type === 'video'; const isVideo = (item as any).media_type === 'video';
const isViewed = !!item.media_viewed_at; const isViewed = !!item.media_viewed_at;
const isRecipientHidden = !isMe && !isViewed; const isRecipientHidden = !isMe && !isViewed;
// Use decryption hook for E2EE media (images/videos) // Get signed URL for media
const { const [mediaUri, setMediaUri] = useState<string | null>(null);
uri: mediaUri, const [mediaLoading, setMediaLoading] = useState(false);
isDecrypting: isDecryptingMedia, const [mediaError, setMediaError] = useState(false);
error: mediaError,
errorCode: mediaErrorCode,
retry: retryMedia,
isEncrypted: isMediaEncrypted,
} = useDecryptedMedia({
messageId: item.id,
mediaPath: item.media_path ?? null,
version: item.version,
encryptionIv: item.encryption_iv,
keysMetadata: item.keys_metadata as KeysMetadata | null,
isMe,
mediaType: isVideo ? 'video' : 'image',
shouldFetch: !!item.media_path && !isRecipientHidden,
});
const isPendingMedia = mediaErrorCode === 'E2EE_PENDING_RECIPIENT_KEY'; useEffect(() => {
if (!item.media_path || isRecipientHidden) {
setMediaUri(null);
return;
}
const showSecureMediaInfo = () => { const fetchMediaUrl = async () => {
Alert.alert( setMediaLoading(true);
'Secure media', setMediaError(false);
"To keep your chats private, photos and videos are locked to each person’s secure key.\n\nThis media was sent before your secure key was ready, so it’s still locked. We’re now securely updating the lock for your device (without exposing the media) so you can open it.\n\nIf it doesn’t unlock in a few seconds, tap “Try again”.", try {
[ const { data, error } = await supabase.storage
{ text: 'OK', style: 'cancel' }, .from('chat-media')
{ text: 'Try again', onPress: () => retryMedia() }, .createSignedUrl(item.media_path!, 3600); // 1 hour expiry
]
); if (error) throw error;
}; setMediaUri(data.signedUrl);
} catch (err) {
console.error('Failed to get media URL:', err);
setMediaError(true);
} finally {
setMediaLoading(false);
}
};
fetchMediaUrl();
}, [item.media_path, isRecipientHidden]);
// Handle expired videos // Handle expired videos
const isExpired = !!(item as any).media_expired; const isExpired = !!(item as any).media_expired;
@@ -135,8 +113,7 @@ export function MessageContent({
// Handle media (images and videos) // Handle media (images and videos)
if (item.media_path || isExpired) { if (item.media_path || isExpired) {
const canOpenFullScreen = (isMe || isViewed) && !isVideo; // Only images can go fullscreen const canOpenFullScreen = (isMe || isViewed) && !isVideo; // Only images can go fullscreen
const hasMediaError = !!mediaError; const isLoading = mediaLoading || (!mediaUri && !mediaError && !isRecipientHidden);
const isLoading = isDecryptingMedia || (!mediaUri && !hasMediaError && !isRecipientHidden);
// Video content // Video content
if (isVideo) { if (isVideo) {
@@ -166,26 +143,11 @@ export function MessageContent({
<Text style={styles.unrevealedText}>Video hidden</Text> <Text style={styles.unrevealedText}>Video hidden</Text>
<Text style={styles.revealHint}>Tap to reveal</Text> <Text style={styles.revealHint}>Tap to reveal</Text>
</TouchableOpacity> </TouchableOpacity>
) : isPendingMedia ? ( ) : mediaUri || mediaError ? (
<TouchableOpacity activeOpacity={0.85} onPress={showSecureMediaInfo}>
<View style={[styles.messageVideo, styles.secureMediaPlaceholder]}>
<LinearGradient
colors={['rgba(22, 33, 62, 0.85)', 'rgba(13, 13, 26, 0.95)']}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
<Ionicons name="lock-closed-outline" size={32} color={colors.textSecondary} />
<Text style={styles.secureMediaPlaceholderText}>Secure video</Text>
<Text style={styles.secureMediaPlaceholderSubtext}>Finishing security setup • Tap for info</Text>
{isDecryptingMedia && <ActivityIndicator color={ACCENT} />}
</View>
</TouchableOpacity>
) : mediaUri || hasMediaError ? (
<ChatVideoPlayer <ChatVideoPlayer
signedUrl={mediaUri} signedUrl={mediaUri}
storagePath={item.media_path!} storagePath={item.media_path!}
urlError={hasMediaError} urlError={mediaError}
onFullScreen={onVideoFullScreen} onFullScreen={onVideoFullScreen}
/> />
) : ( ) : (
@@ -209,17 +171,13 @@ export function MessageContent({
return ( return (
<View> <View>
<TouchableOpacity <TouchableOpacity
activeOpacity={isPendingMedia || canOpenFullScreen ? 0.8 : 1} activeOpacity={canOpenFullScreen ? 0.8 : 1}
onPress={() => { onPress={() => {
if (isPendingMedia) {
showSecureMediaInfo();
return;
}
if (canOpenFullScreen && mediaUri) { if (canOpenFullScreen && mediaUri) {
onImagePress(mediaUri); onImagePress(mediaUri);
} }
}} }}
disabled={!(isPendingMedia || (canOpenFullScreen && !!mediaUri))} disabled={!(canOpenFullScreen && !!mediaUri)}
> >
{isRecipientHidden ? ( {isRecipientHidden ? (
<TouchableOpacity <TouchableOpacity
@@ -252,20 +210,7 @@ export function MessageContent({
cachePolicy="disk" cachePolicy="disk"
transition={200} transition={200}
/> />
) : isPendingMedia ? ( ) : mediaError ? (
<View style={[styles.messageImage, styles.secureMediaPlaceholder]}>
<LinearGradient
colors={['rgba(22, 33, 62, 0.85)', 'rgba(13, 13, 26, 0.95)']}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
<Ionicons name="lock-closed-outline" size={32} color={colors.textSecondary} />
<Text style={styles.secureMediaPlaceholderText}>Secure photo</Text>
<Text style={styles.secureMediaPlaceholderSubtext}>Finishing security setup • Tap for info</Text>
{isDecryptingMedia && <ActivityIndicator color={ACCENT} />}
</View>
) : hasMediaError ? (
<View style={[styles.messageImage, styles.messageImageError]}> <View style={[styles.messageImage, styles.messageImageError]}>
<Ionicons name="image-outline" size={32} color={colors.textSecondary} /> <Ionicons name="image-outline" size={32} color={colors.textSecondary} />
<Text style={styles.messageImageErrorText}>Failed to load image</Text> <Text style={styles.messageImageErrorText}>Failed to load image</Text>
@@ -288,73 +233,10 @@ export function MessageContent({
); );
} }
// Text message // Text message - display content directly
const version = item.version ?? 1;
const isV2 = version === 2;
const showSecureMessageInfo = () => {
Alert.alert(
'Secure message',
"To keep your chats private, each message is locked to your device’s secure key.\n\nThis message was sent before your secure key was ready, so it’s still locked. We’re now securely updating the lock for your device (without exposing the message content) so you can read it.\n\nIf it doesn’t unlock in a few seconds, tap “Try again”.",
[
{ text: 'OK', style: 'cancel' },
{ text: 'Try again', onPress: () => retryDecrypt() },
]
);
};
const renderText = () => {
if (decryptErrorCode === 'E2EE_PENDING_RECIPIENT_KEY') {
return (
<TouchableOpacity
activeOpacity={0.85}
onPress={showSecureMessageInfo}
onLongPress={onLongPress}
delayLongPress={300}
>
<View style={styles.securePlaceholder}>
<View style={styles.securePlaceholderRow}>
<Ionicons name="lock-closed-outline" size={16} color={colors.textSecondary} />
<Text style={styles.securePlaceholderTitle}>Secure message</Text>
{isDecrypting && <ActivityIndicator size="small" color={ACCENT} />}
</View>
<Text style={styles.securePlaceholderSubtext}>Finishing security setup • Tap for info</Text>
</View>
</TouchableOpacity>
);
}
if (isDecrypting) {
return (
<View style={styles.decryptingContainer}>
<ActivityIndicator size="small" color={ACCENT} />
</View>
);
}
if (decryptError) {
return (
<TouchableOpacity
activeOpacity={0.85}
onPress={retryDecrypt}
onLongPress={onLongPress}
delayLongPress={300}
>
<Text style={[styles.messageText, styles.errorText]}>Couldn’t open secure message • Tap to retry</Text>
</TouchableOpacity>
);
}
if (isV2) {
return <Text style={styles.messageText}>{decryptedContent ?? 'Secure message'}</Text>;
}
return <Text style={styles.messageText}>{item.content ?? 'Failed to load message'}</Text>;
};
return ( return (
<> <>
{renderText()} <Text style={styles.messageText}>{item.content ?? 'Message unavailable'}</Text>
<MessageMeta item={item} isMe={isMe} /> <MessageMeta item={item} isMe={isMe} />
</> </>
); );
@@ -376,61 +258,12 @@ const styles = StyleSheet.create({
fontSize: 16, fontSize: 16,
lineHeight: 22, lineHeight: 22,
}, },
securePlaceholder: {
gap: spacing.xs,
},
securePlaceholderRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
},
securePlaceholderTitle: {
color: colors.textSecondary,
fontSize: 14,
fontWeight: '600',
},
securePlaceholderSubtext: {
color: colors.textTertiary,
fontSize: 12,
},
decryptingContainer: {
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
justifyContent: 'center',
alignItems: 'center',
},
decryptingText: {
color: colors.textSecondary,
fontSize: 11,
marginTop: spacing.xs,
},
errorText: {
color: colors.textSecondary,
fontStyle: 'italic',
},
messageImage: { messageImage: {
width: 200, width: 200,
height: 200, height: 200,
borderRadius: radius.md, borderRadius: radius.md,
backgroundColor: 'rgba(255, 255, 255, 0.05)', backgroundColor: 'rgba(255, 255, 255, 0.05)',
}, },
secureMediaPlaceholder: {
justifyContent: 'center',
alignItems: 'center',
gap: spacing.sm,
overflow: 'hidden',
},
secureMediaPlaceholderText: {
color: colors.textSecondary,
fontSize: 12,
fontWeight: '600',
},
secureMediaPlaceholderSubtext: {
color: colors.textTertiary,
fontSize: 11,
textAlign: 'center',
paddingHorizontal: spacing.md,
},
messageImageLoading: { messageImageLoading: {
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
@@ -1,4 +1,4 @@
import { useRef, useState } from 'react'; import { useState } from 'react';
import { Alert, Platform } from 'react-native'; import { Alert, Platform } from 'react-native';
import * as FileSystem from 'expo-file-system'; import * as FileSystem from 'expo-file-system';
import { Video as VideoCompressor } from 'react-native-compressor'; import { Video as VideoCompressor } from 'react-native-compressor';
@@ -7,32 +7,10 @@ import { decode } from 'base64-arraybuffer';
import { supabase } from '../../../lib/supabase'; import { supabase } from '../../../lib/supabase';
import { Events } from '../../../lib/analytics'; import { Events } from '../../../lib/analytics';
import { UploadStatus } from '../types'; import { UploadStatus } from '../types';
import { useAuthStore } from '../../../store';
import {
encryptMediaFile,
getAdminPublicKey,
getAdminKeyId,
MESSAGE_VERSION_E2EE,
isValidPublicKeyJwk,
sanitizePublicKeyJwk,
verifyKeyPair,
} from '../../../lib/encryption';
import type { RSAPublicKeyJWK, EncryptedMediaPayload } from '../../../lib/encryption';
import { useEncryptionKeys } from '../../../hooks';
export function useMediaUpload(matchId: string, userId: string | undefined) { export function useMediaUpload(matchId: string, userId: string | undefined) {
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [uploadStatus, setUploadStatus] = useState<UploadStatus>(null); const [uploadStatus, setUploadStatus] = useState<UploadStatus>(null);
const verifiedKeysVersionRef = useRef<number | null>(null);
const partnerRefreshRef = useRef(0);
const PARTNER_REFRESH_TTL_MS = 2 * 60 * 1000;
const refreshPartner = useAuthStore((s) => s.refreshPartner);
const { publicKeyJwk, privateKeyJwk, hasKeys, isLoading: keysLoading } = useEncryptionKeys();
// Check if E2EE is available for media
const isE2EEAvailable =
Platform.OS !== 'web' && hasKeys && !keysLoading && !!publicKeyJwk && !!privateKeyJwk;
const uploadMedia = async (uri: string, mediaType: 'image' | 'video') => { const uploadMedia = async (uri: string, mediaType: 'image' | 'video') => {
if (!userId) return; if (!userId) return;
@@ -40,17 +18,9 @@ export function useMediaUpload(matchId: string, userId: string | undefined) {
setUploadStatus({ mediaType, status: mediaType === 'video' ? 'compressing' : 'uploading', thumbnailUri: uri }); setUploadStatus({ mediaType, status: mediaType === 'video' ? 'compressing' : 'uploading', thumbnailUri: uri });
try { try {
// Block media uploads until encryption is ready (secure-only)
if (!isE2EEAvailable || Platform.OS === 'web') {
const readyError = new Error('Media security not ready. Please check your connection.');
console.warn('[E2EE Media Upload] E2EE not available, blocking upload');
throw readyError;
}
let fileUri = uri; let fileUri = uri;
let fileBody; let fileBody;
let ext = mediaType === 'video' ? 'mp4' : 'jpg'; let ext = mediaType === 'video' ? 'mp4' : 'jpg';
let encryptedPayload: EncryptedMediaPayload;
// Compress videos on native platforms before upload // Compress videos on native platforms before upload
if (mediaType === 'video' && Platform.OS !== 'web') { if (mediaType === 'video' && Platform.OS !== 'web') {
@@ -73,108 +43,6 @@ export function useMediaUpload(matchId: string, userId: string | undefined) {
} }
} }
// CRITICAL: Read keys directly from global store at encryption time
// This ensures we always use the most current keys, not potentially stale hook state
const currentKeys = useAuthStore.getState().encryptionKeys;
if (!currentKeys.hasKeys || !currentKeys.publicKeyJwk || !currentKeys.privateKeyJwk) {
console.warn('[E2EE Media Upload] Keys not fully available in global store at encryption time');
throw new Error('Media security not ready. Please check your connection.');
}
const senderPublicKey = currentKeys.publicKeyJwk;
const senderPrivateKey = currentKeys.privateKeyJwk;
// SAFETY CHECK: Verify the key pair works together before encrypting
if (verifiedKeysVersionRef.current !== currentKeys.keysVersion) {
console.log(
`[E2EE Media Upload] Verifying sender key pair (version ${currentKeys.keysVersion})...`
);
const isValidKeyPair = await verifyKeyPair(
senderPublicKey as RSAPublicKeyJWK,
senderPrivateKey
);
if (!isValidKeyPair) {
console.error('[E2EE Media Upload] CRITICAL: Sender key pair verification failed!');
throw new Error('Security check failed. Please restart the app.');
}
verifiedKeysVersionRef.current = currentKeys.keysVersion;
console.log('[E2EE Media Upload] Sender key pair verified successfully');
}
console.log('[E2EE Media Upload] Using sender public key from global store, version:', currentKeys.keysVersion);
// Refresh partner's profile only when key is missing/invalid or TTL expires
const resolvePartnerProfile = async () => {
const cachedPartner = useAuthStore.getState().partner;
const cachedKey = cachedPartner?.public_key_jwk as RSAPublicKeyJWK | null | undefined;
const hasValidKey = cachedKey ? isValidPublicKeyJwk(cachedKey) : false;
const now = Date.now();
const shouldRefresh = !hasValidKey || (now - partnerRefreshRef.current) > PARTNER_REFRESH_TTL_MS;
if (!shouldRefresh) {
return cachedPartner;
}
console.log('[E2EE Media Upload] Refreshing partner profile...');
try {
const freshPartner = await refreshPartner();
partnerRefreshRef.current = Date.now();
return freshPartner ?? useAuthStore.getState().partner;
} catch (refreshError) {
console.warn('[E2EE Media Upload] Partner refresh failed, using cached key:', refreshError);
partnerRefreshRef.current = Date.now();
return cachedPartner;
}
};
const freshPartner = await resolvePartnerProfile();
let adminPublicKey: RSAPublicKeyJWK;
let adminKeyId: string;
try {
adminPublicKey = await getAdminPublicKey();
adminKeyId = await getAdminKeyId();
if (!isValidPublicKeyJwk(adminPublicKey)) {
throw new Error('Admin public key is invalid');
}
} catch (adminKeyError) {
console.error('[E2EE Media Upload] Failed to fetch admin key:', adminKeyError);
throw new Error('Connection issue. Please try again.');
}
let partnerPublicKey = freshPartner?.public_key_jwk as RSAPublicKeyJWK | null | undefined;
// Validate and sanitize partner's public key
if (partnerPublicKey) {
if (!isValidPublicKeyJwk(partnerPublicKey)) {
console.warn('[E2EE Media Upload] Partner public key is invalid, treating as unavailable');
partnerPublicKey = null;
} else {
// Sanitize to remove trailing dots from base64 fields
partnerPublicKey = sanitizePublicKeyJwk(partnerPublicKey);
}
}
console.log('[E2EE Media Upload] Partner public key available:', !!partnerPublicKey);
encryptedPayload = await encryptMediaFile(
fileUri,
senderPublicKey as RSAPublicKeyJWK,
partnerPublicKey ?? null,
adminPublicKey,
adminKeyId
);
// Use the encrypted file for upload
fileUri = encryptedPayload.encryptedFileUri;
ext = 'enc'; // Encrypted files use .enc extension
console.log('Media encrypted successfully');
if (Platform.OS === 'web') { if (Platform.OS === 'web') {
const response = await fetch(fileUri); const response = await fetch(fileUri);
const blob = await response.blob(); const blob = await response.blob();
@@ -194,11 +62,10 @@ export function useMediaUpload(matchId: string, userId: string | undefined) {
} else { } else {
const base64 = await FileSystem.readAsStringAsync(fileUri, { encoding: FileSystem.EncodingType.Base64 }); const base64 = await FileSystem.readAsStringAsync(fileUri, { encoding: FileSystem.EncodingType.Base64 });
fileBody = decode(base64); fileBody = decode(base64);
} }
const fileName = `${matchId}/${Date.now()}_${Math.random().toString(36).substring(7)}.${ext}`; const fileName = `${matchId}/${Date.now()}_${Math.random().toString(36).substring(7)}.${ext}`;
const contentType = 'application/octet-stream'; const contentType = mediaType === 'video' ? 'video/mp4' : 'image/jpeg';
const { error: uploadError } = await supabase.storage const { error: uploadError } = await supabase.storage
.from("chat-media") .from("chat-media")
@@ -209,16 +76,7 @@ export function useMediaUpload(matchId: string, userId: string | undefined) {
if (uploadError) throw uploadError; if (uploadError) throw uploadError;
// Clean up encrypted temp file // Store the message (plaintext v1)
if (encryptedPayload && encryptedPayload.encryptedFileUri) {
try {
await FileSystem.deleteAsync(encryptedPayload.encryptedFileUri, { idempotent: true });
} catch (cleanupError) {
console.warn('Failed to clean up encrypted temp file:', cleanupError);
}
}
// Store the message with encryption metadata
await supabase.from("messages").insert({ await supabase.from("messages").insert({
match_id: matchId, match_id: matchId,
user_id: userId, user_id: userId,
@@ -226,9 +84,7 @@ export function useMediaUpload(matchId: string, userId: string | undefined) {
media_path: fileName, media_path: fileName,
media_type: mediaType, media_type: mediaType,
media_expired: false, media_expired: false,
version: MESSAGE_VERSION_E2EE, version: 1,
encryption_iv: encryptedPayload.encryption_iv,
keys_metadata: encryptedPayload.keys_metadata as unknown as Record<string, unknown>,
}); });
Events.mediaUploaded(); Events.mediaUploaded();
@@ -20,7 +20,6 @@ import { useAuthStore, useSubscriptionStore } from '../../store';
import { resetSwipeTutorial } from '../../lib/swipeTutorialSeen'; import { resetSwipeTutorial } from '../../lib/swipeTutorialSeen';
import { resetMatchesTutorial } from '../../lib/matchesTutorialSeen'; import { resetMatchesTutorial } from '../../lib/matchesTutorialSeen';
import { supabase } from '../../lib/supabase'; // Needed for debug reset import { supabase } from '../../lib/supabase'; // Needed for debug reset
import { clearKeys } from '../../lib/encryption';
// Hooks // Hooks
import { useProfileSettings, useCoupleManagement } from './hooks'; import { useProfileSettings, useCoupleManagement } from './hooks';
@@ -294,29 +293,6 @@ export function ProfileScreen() {
} }
}} }}
/> />
<View style={styles.preferencesDivider} />
<MenuItem
icon="key-outline"
label="Clear Private Key"
description="Remove E2EE keys from device"
onPress={() => {
Alert.alert(
"Clear Keys",
"Are you sure? You will lose access to encrypted messages until keys are regenerated.",
[
{ text: "Cancel", style: "cancel" },
{
text: "Clear",
style: "destructive",
onPress: async () => {
await clearKeys();
Alert.alert("Success", "Keys cleared. They will be regenerated on next launch/login.");
}
}
]
);
}}
/>
</SettingsSection> </SettingsSection>
)} )}
@@ -4,7 +4,6 @@ import { useRouter } from 'expo-router';
import { useAuthStore, useMatchStore, useMessageStore, usePacksStore } from '../../../store'; import { useAuthStore, useMatchStore, useMessageStore, usePacksStore } from '../../../store';
import { supabase } from '../../../lib/supabase'; import { supabase } from '../../../lib/supabase';
import { Events } from '../../../lib/analytics'; import { Events } from '../../../lib/analytics';
import { clearKeys } from '../../../lib/encryption';
export function useCoupleManagement() { export function useCoupleManagement() {
const router = useRouter(); const router = useRouter();
@@ -114,7 +113,6 @@ export function useCoupleManagement() {
clearMatches(); clearMatches();
clearMessages(); clearMessages();
clearPacks(); clearPacks();
await clearKeys();
Events.accountDeleted(); Events.accountDeleted();
-4
View File
@@ -7,8 +7,4 @@ export { useTypingIndicator } from './useTypingIndicator';
export type { UseTypingIndicatorConfig, UseTypingIndicatorReturn } from './useTypingIndicator'; export type { UseTypingIndicatorConfig, UseTypingIndicatorReturn } from './useTypingIndicator';
export { useMessageSubscription } from './useMessageSubscription'; export { useMessageSubscription } from './useMessageSubscription';
export type { UseMessageSubscriptionConfig, UseMessageSubscriptionReturn } from './useMessageSubscription'; export type { UseMessageSubscriptionConfig, UseMessageSubscriptionReturn } from './useMessageSubscription';
export { useEncryptionKeys } from './useEncryptionKeys';
export { useDecryptedMessage } from './useDecryptedMessage';
export { useEncryptedSend } from './useEncryptedSend';
export { useDecryptedMedia, clearDecryptedMediaCache } from './useDecryptedMedia';
export { useMediaSaver } from './useMediaSaver'; export { useMediaSaver } from './useMediaSaver';
-486
View File
@@ -1,486 +0,0 @@
/**
* useDecryptedMedia Hook
*
* React hook for decrypting E2EE encrypted media files (images/videos).
* Handles downloading, decrypting, and caching decrypted media.
*/
import { useState, useEffect, useCallback, useRef } from 'react';
import { Platform } from 'react-native';
import * as FileSystem from 'expo-file-system';
import {
decryptMediaFile,
cleanupDecryptedMedia,
MESSAGE_VERSION_E2EE,
repairStaleKey,
triggerAutoKeyRotation,
} from '../lib/encryption';
import type { KeysMetadata, RSAPrivateKeyJWK } from '../lib/encryption';
import { useEncryptionKeys } from './useEncryptionKeys';
import { getCachedSignedUrl, getStoragePath } from '../lib/imageCache';
import { supabase } from '../lib/supabase';
import { reencryptPendingMessages } from '../lib/encryption/reencryptPendingMessages';
import { useAuthStore } from '../store/authStore';
// Import shared cache from mediaCache to avoid circular dependency
import { decryptedMediaCache, clearDecryptedMediaCache } from '../lib/mediaCache';
const AUTO_RETRY_BASE_DELAY_MS = 3000;
const MAX_AUTO_RETRIES = 2;
const autoRetryCountsByMediaKey = new Map<string, number>();
export type DecryptedMediaErrorCode =
| 'E2EE_NOT_SUPPORTED'
| 'E2EE_KEYS_UNAVAILABLE'
| 'E2EE_MISSING_FIELDS'
| 'E2EE_PENDING_RECIPIENT_KEY'
| 'E2EE_DECRYPT_FAILED';
// Re-export clearDecryptedMediaCache for backwards compatibility
export { clearDecryptedMediaCache };
interface DecryptedMediaState {
/** Local file URI to the decrypted media (or null if not ready) */
uri: string | null;
/** Whether decryption is in progress */
isDecrypting: boolean;
/** Error if decryption failed */
error: Error | null;
/** Optional error code for UX */
errorCode?: DecryptedMediaErrorCode;
}
interface UseDecryptedMediaOptions {
/** The message ID (used for cache key) */
messageId: string;
/** Path to the media file in storage */
mediaPath: string | null;
/** Message version (1 = plaintext, 2 = encrypted) */
version: number | null | undefined;
/** Encryption IV (required for v2) */
encryptionIv: string | null | undefined;
/** Keys metadata containing wrapped keys (required for v2) */
keysMetadata: KeysMetadata | null | undefined;
/** Whether current user is the sender */
isMe: boolean;
/** Media type */
mediaType: 'image' | 'video';
/** Whether media should be fetched (e.g., not hidden) */
shouldFetch: boolean;
}
export type UseDecryptedMediaResult = DecryptedMediaState & {
isEncrypted: boolean;
retry: () => void;
};
/**
* Hook to decrypt encrypted media files
*/
export function useDecryptedMedia({
messageId,
mediaPath,
version,
encryptionIv,
keysMetadata,
isMe,
mediaType,
shouldFetch,
}: UseDecryptedMediaOptions): UseDecryptedMediaResult {
const [state, setState] = useState<DecryptedMediaState>({
uri: null,
isDecrypting: false,
error: null,
});
const { privateKeyJwk, hasKeys, isLoading: keysLoading } = useEncryptionKeys();
const beginEncryptionRecovery = useAuthStore((s) => s.beginEncryptionRecovery);
const endEncryptionRecovery = useAuthStore((s) => s.endEncryptionRecovery);
const isMounted = useRef(true);
const repairAttemptedByMessageId = useRef(new Set<string>());
const [retryNonce, setRetryNonce] = useState(0);
const autoRetryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const retry = useCallback(() => {
repairAttemptedByMessageId.current.delete(messageId);
setRetryNonce((n) => n + 1);
}, [messageId]);
// Determine if this is encrypted media
const isEncrypted = version === MESSAGE_VERSION_E2EE && (mediaPath?.endsWith('.enc') ?? false);
const decryptMedia = useCallback(async () => {
// No media path - nothing to do
if (!mediaPath || !shouldFetch) {
setState({ uri: null, isDecrypting: false, error: null, errorCode: undefined });
return;
}
console.log(
`[E2EE Media] Message ${messageId}: Starting decrypt - version=${version}, mediaPath=${mediaPath}, isEncrypted=${isEncrypted}`,
);
// Check cache first
const cacheKey = `${messageId}-${mediaPath}`;
const cachedUri = decryptedMediaCache.get(cacheKey);
if (cachedUri) {
try {
const fileInfo = await FileSystem.getInfoAsync(cachedUri);
if (fileInfo.exists) {
console.log(`[E2EE Media] Message ${messageId}: Using cached decrypted file`);
setState({ uri: cachedUri, isDecrypting: false, error: null, errorCode: undefined });
return;
}
} catch {
decryptedMediaCache.delete(cacheKey);
}
}
// v1 (plaintext) or non-.enc files: Use existing signed URL flow
if (!isEncrypted) {
console.log(`[E2EE Media] Message ${messageId}: Not encrypted, using signed URL flow`);
try {
const storagePath = getStoragePath(mediaPath);
const signedUrl = await getCachedSignedUrl(storagePath);
if (isMounted.current) {
setState({
uri: signedUrl,
isDecrypting: false,
error: signedUrl ? null : new Error('Failed to get signed URL'),
errorCode: signedUrl ? undefined : 'E2EE_DECRYPT_FAILED',
});
}
} catch (err) {
console.error(`[E2EE Media] Message ${messageId}: Failed to get signed URL:`, err);
if (isMounted.current) {
setState({
uri: null,
isDecrypting: false,
error: err as Error,
errorCode: 'E2EE_DECRYPT_FAILED',
});
}
}
return;
}
// v2 (encrypted) media - requires decryption
console.log(`[E2EE Media] Message ${messageId}: Encrypted media, starting decryption`);
if (Platform.OS === 'web') {
setState({
uri: null,
isDecrypting: false,
error: new Error('Failed to load media'),
errorCode: 'E2EE_NOT_SUPPORTED',
});
return;
}
// Wait for keys to load
if (keysLoading) {
console.log(`[E2EE Media] Message ${messageId}: Keys still loading...`);
setState((prev) => ({ ...prev, isDecrypting: true }));
return;
}
if (!hasKeys || !privateKeyJwk) {
console.log(
`[E2EE Media] Message ${messageId}: No keys available - hasKeys=${hasKeys}, hasPrivateKey=${!!privateKeyJwk}`,
);
setState({
uri: null,
isDecrypting: false,
error: new Error('Failed to load media'),
errorCode: 'E2EE_KEYS_UNAVAILABLE',
});
return;
}
// Validate required encryption fields
if (!encryptionIv || !keysMetadata) {
console.log(
`[E2EE Media] Message ${messageId}: Missing encryption metadata - iv=${!!encryptionIv}, keysMetadata=${!!keysMetadata}`,
);
setState({
uri: null,
isDecrypting: false,
error: new Error('Failed to load media'),
errorCode: 'E2EE_MISSING_FIELDS',
});
return;
}
// If the recipient key is missing, try server-assisted repair (admin recovery model).
let effectiveKeysMetadata: KeysMetadata | null = keysMetadata;
const getWrappedKey = (metadata: KeysMetadata | null): string | undefined =>
isMe ? metadata?.sender_wrapped_key : metadata?.recipient_wrapped_key;
let wrappedKey = getWrappedKey(effectiveKeysMetadata);
if (!wrappedKey) {
console.log(
`[E2EE Media] Message ${messageId}: No wrapped key for ${isMe ? 'sender' : 'recipient'} - pending_recipient=${(effectiveKeysMetadata as any)?.pending_recipient}`,
);
if (!isMe) {
beginEncryptionRecovery('pending-recipient');
try {
setState({
uri: null,
isDecrypting: true,
error: null,
errorCode: 'E2EE_PENDING_RECIPIENT_KEY',
});
if (!repairAttemptedByMessageId.current.has(messageId)) {
repairAttemptedByMessageId.current.add(messageId);
await reencryptPendingMessages();
}
const { data: refreshed } = await supabase
.from('messages')
.select('keys_metadata')
.eq('id', messageId)
.maybeSingle();
effectiveKeysMetadata = (refreshed?.keys_metadata ?? null) as KeysMetadata | null;
wrappedKey = getWrappedKey(effectiveKeysMetadata);
} finally {
endEncryptionRecovery();
}
}
if (!wrappedKey) {
setState({
uri: null,
isDecrypting: false,
error: null,
errorCode: 'E2EE_PENDING_RECIPIENT_KEY',
});
return;
}
}
setState((prev) => ({ ...prev, isDecrypting: true, error: null, errorCode: undefined }));
try {
// Get signed URL for the encrypted file
const storagePath = getStoragePath(mediaPath);
console.log(`[E2EE Media] Message ${messageId}: Getting signed URL for ${storagePath}`);
const encryptedUrl = await getCachedSignedUrl(storagePath);
if (!encryptedUrl) {
throw new Error('Failed to load media');
}
console.log(`[E2EE Media] Message ${messageId}: Decrypting ${mediaType}...`);
const decryptedUri = await decryptMediaFile(
encryptedUrl,
encryptionIv,
effectiveKeysMetadata as KeysMetadata,
privateKeyJwk as RSAPrivateKeyJWK,
!isMe, // isRecipient
mediaType,
);
console.log(`[E2EE Media] Message ${messageId}: Decryption successful - ${decryptedUri}`);
decryptedMediaCache.set(cacheKey, decryptedUri);
if (isMounted.current) {
setState({
uri: decryptedUri,
isDecrypting: false,
error: null,
errorCode: undefined,
});
}
} catch (err) {
const error = err instanceof Error ? err : new Error('Unknown error');
console.error(`[E2EE Media] Message ${messageId}: Decryption failed:`, error);
// Check if this is a stale key failure (message encrypted with old key)
const isStaleKeyFailure =
error.name === 'OperationError' ||
error.message.includes('OperationError') ||
error.message.includes('decryption failed') ||
error.message.includes('decrypt error') ||
error.message.includes('error in DoCipher');
// Attempt stale key repair if not already attempted
if (isStaleKeyFailure && !repairAttemptedByMessageId.current.has(messageId)) {
beginEncryptionRecovery('stale-key');
const autoRotationPromise = triggerAutoKeyRotation();
let didDecrypt = false;
try {
console.log(`[E2EE Media] Message ${messageId}: Attempting stale key repair...`);
if (isMounted.current) {
setState({
uri: null,
isDecrypting: true,
error: null,
errorCode: undefined,
});
}
repairAttemptedByMessageId.current.add(messageId);
try {
const repairResult = await repairStaleKey(messageId);
if (repairResult.success) {
console.log(`[E2EE Media] Message ${messageId}: Stale key repaired, retrying decryption...`);
// Fetch updated message and retry
const { data: refreshed } = await supabase
.from('messages')
.select('keys_metadata')
.eq('id', messageId)
.maybeSingle();
const refreshedMetadata = (refreshed?.keys_metadata ?? null) as KeysMetadata | null;
if (refreshedMetadata && encryptionIv && mediaPath) {
const storagePath = getStoragePath(mediaPath);
const encryptedUrl = await getCachedSignedUrl(storagePath);
if (encryptedUrl) {
const decryptedUri = await decryptMediaFile(
encryptedUrl,
encryptionIv,
refreshedMetadata,
privateKeyJwk as RSAPrivateKeyJWK,
!isMe,
mediaType,
);
const cacheKey = `${messageId}-${mediaPath}`;
decryptedMediaCache.set(cacheKey, decryptedUri);
if (isMounted.current) {
setState({
uri: decryptedUri,
isDecrypting: false,
error: null,
errorCode: undefined,
});
}
didDecrypt = true;
return;
}
}
} else {
console.error(`[E2EE Media] Message ${messageId}: Stale key repair failed:`, repairResult.error);
}
} catch (repairErr) {
console.error(`[E2EE Media] Message ${messageId}: Stale key repair threw:`, repairErr);
}
} finally {
await autoRotationPromise;
if (autoRotationPromise && !didDecrypt) {
retry();
}
endEncryptionRecovery();
}
}
if (isMounted.current) {
setState({
uri: null,
isDecrypting: false,
error: new Error('Failed to load media'),
errorCode: 'E2EE_DECRYPT_FAILED',
});
}
}
}, [
messageId,
mediaPath,
version,
encryptionIv,
keysMetadata,
isMe,
mediaType,
shouldFetch,
isEncrypted,
privateKeyJwk,
hasKeys,
keysLoading,
retryNonce,
beginEncryptionRecovery,
endEncryptionRecovery,
retry,
]);
useEffect(() => {
isMounted.current = true;
decryptMedia();
return () => {
isMounted.current = false;
};
}, [decryptMedia, retryNonce]);
useEffect(() => {
return () => {
if (autoRetryTimerRef.current) {
clearTimeout(autoRetryTimerRef.current);
autoRetryTimerRef.current = null;
}
};
}, [messageId, mediaPath]);
useEffect(() => {
const autoRetryKey = `${messageId}:${mediaPath ?? 'none'}`;
if (!state.errorCode) {
autoRetryCountsByMediaKey.delete(autoRetryKey);
if (autoRetryTimerRef.current) {
clearTimeout(autoRetryTimerRef.current);
autoRetryTimerRef.current = null;
}
return;
}
const shouldRetry =
state.errorCode === 'E2EE_PENDING_RECIPIENT_KEY' ||
state.errorCode === 'E2EE_KEYS_UNAVAILABLE';
if (!shouldRetry) {
return;
}
const attempts = autoRetryCountsByMediaKey.get(autoRetryKey) ?? 0;
if (attempts >= MAX_AUTO_RETRIES) {
return;
}
const nextAttempt = attempts + 1;
const delayMs = AUTO_RETRY_BASE_DELAY_MS * nextAttempt;
autoRetryCountsByMediaKey.set(autoRetryKey, nextAttempt);
autoRetryTimerRef.current = setTimeout(() => {
retry();
}, delayMs);
return () => {
if (autoRetryTimerRef.current) {
clearTimeout(autoRetryTimerRef.current);
autoRetryTimerRef.current = null;
}
};
}, [messageId, mediaPath, state.errorCode, retry]);
return {
...state,
isEncrypted,
retry,
};
}
@@ -1,466 +0,0 @@
/**
* useDecryptedMessage Hook
*
* React hook for decrypting E2EE messages.
* Handles both v1 (plaintext) and v2 (encrypted) messages.
*/
import { useState, useEffect, useCallback, useRef } from 'react';
import { Platform } from 'react-native';
import {
decryptTextMessage,
MESSAGE_VERSION_PLAINTEXT,
MESSAGE_VERSION_E2EE,
repairStaleKey,
triggerAutoKeyRotation,
} from '../lib/encryption';
import type {
DecryptedMessageState,
KeysMetadata,
RSAPrivateKeyJWK,
} from '../lib/encryption';
import { supabase } from '../lib/supabase';
import { reencryptPendingMessages } from '../lib/encryption/reencryptPendingMessages';
import { useEncryptionKeys } from './useEncryptionKeys';
import { useAuthStore } from '../store/authStore';
const AUTO_RETRY_BASE_DELAY_MS = 3000;
const MAX_AUTO_RETRIES = 2;
const autoRetryCountsByMessageId = new Map<string, number>();
interface Message {
id: string;
content: string | null;
version?: number | null;
encrypted_content?: string | null;
encryption_iv?: string | null;
keys_metadata?: KeysMetadata | null;
user_id: string;
}
interface UseDecryptedMessageOptions {
/** The message to decrypt */
message: Message;
/** Current user's ID to determine if sender or recipient */
currentUserId: string;
}
export type UseDecryptedMessageResult = DecryptedMessageState & {
/** Force a re-attempt (used by UI retry affordances) */
retry: () => void;
};
/**
* Hook to decrypt a message
*
* Automatically handles:
* - v1 (plaintext) messages: returns content directly
* - v2 (encrypted) messages: decrypts using private key
* - Server-assisted re-wrap for pending recipient keys (admin recovery model)
*/
export function useDecryptedMessage({
message,
currentUserId,
}: UseDecryptedMessageOptions): UseDecryptedMessageResult {
const [state, setState] = useState<DecryptedMessageState>({
content: null,
mediaUri: null,
isDecrypting: false,
error: null,
});
const { privateKeyJwk, hasKeys, isLoading: keysLoading, ensureKeysUploaded } = useEncryptionKeys();
const beginEncryptionRecovery = useAuthStore((s) => s.beginEncryptionRecovery);
const endEncryptionRecovery = useAuthStore((s) => s.endEncryptionRecovery);
// Track per-message repair attempts to avoid tight retry loops.
const repairAttemptedByMessageId = useRef(new Set<string>());
const [retryNonce, setRetryNonce] = useState(0);
const autoRetryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const retry = useCallback(() => {
repairAttemptedByMessageId.current.delete(message.id);
setRetryNonce((n) => n + 1);
}, [message.id]);
const decrypt = useCallback(async () => {
// Version 1 or undefined: Legacy plaintext message
const version = message.version ?? MESSAGE_VERSION_PLAINTEXT;
console.log(
`[E2EE Decrypt] Message ${message.id}: version=${version}, hasEncryptedContent=${!!message.encrypted_content}, hasIV=${!!message.encryption_iv}, hasKeysMetadata=${!!message.keys_metadata}`,
);
if (version === MESSAGE_VERSION_PLAINTEXT) {
console.log(`[E2EE Decrypt] Message ${message.id}: Using plaintext content`);
setState({
content: message.content,
mediaUri: null,
isDecrypting: false,
error: null,
errorCode: undefined,
});
return;
}
// Version 2: E2EE - requires decryption
if (version === MESSAGE_VERSION_E2EE) {
console.log(`[E2EE Decrypt] Message ${message.id}: Attempting E2EE decryption`);
// E2EE not supported on web
if (Platform.OS === 'web') {
console.log(`[E2EE Decrypt] Message ${message.id}: Web platform - E2EE not supported`);
setState({
content: null,
mediaUri: null,
isDecrypting: false,
error: new Error('Encrypted message'),
errorCode: 'E2EE_NOT_SUPPORTED',
});
return;
}
// Wait for keys to load
if (keysLoading) {
console.log(`[E2EE Decrypt] Message ${message.id}: Keys still loading, waiting...`);
setState((prev) => ({ ...prev, isDecrypting: true }));
return;
}
if (!hasKeys || !privateKeyJwk) {
console.log(
`[E2EE Decrypt] Message ${message.id}: No keys available - hasKeys=${hasKeys}, hasPrivateKey=${!!privateKeyJwk}`,
);
setState({
content: null,
mediaUri: null,
isDecrypting: false,
error: new Error('Waiting for keys...'),
errorCode: 'E2EE_KEYS_UNAVAILABLE',
});
return;
}
// Validate required encryption fields
if (!message.encrypted_content || !message.encryption_iv || !message.keys_metadata) {
console.log(
`[E2EE Decrypt] Message ${message.id}: Missing encryption fields - encrypted_content=${!!message.encrypted_content}, encryption_iv=${!!message.encryption_iv}, keys_metadata=${!!message.keys_metadata}`,
);
setState({
content: null,
mediaUri: null,
isDecrypting: false,
error: new Error('Message unavailable'),
errorCode: 'E2EE_MISSING_FIELDS',
});
return;
}
setState((prev) => ({ ...prev, isDecrypting: true, error: null, errorCode: undefined }));
try {
const isMe = message.user_id === currentUserId;
console.log(`[E2EE Decrypt] Message ${message.id}: Decrypting as ${isMe ? 'sender' : 'recipient'}`);
console.log(
`[E2EE Decrypt] Message ${message.id}: keys_metadata=`,
JSON.stringify(message.keys_metadata, null, 2),
);
const decryptedContent = await decryptTextMessage(
message.encrypted_content,
message.encryption_iv,
message.keys_metadata as KeysMetadata,
privateKeyJwk as RSAPrivateKeyJWK,
!isMe, // isRecipient
);
console.log(
`[E2EE Decrypt] Message ${message.id}: Decryption successful, content length=${decryptedContent.length}`,
);
setState({
content: decryptedContent,
mediaUri: null,
isDecrypting: false,
error: null,
errorCode: undefined,
});
} catch (err) {
const error = err instanceof Error ? err : new Error('Unknown error');
console.error(`[E2EE Decrypt] Message ${message.id}: Decryption failed:`, error);
const isMe = message.user_id === currentUserId;
const isMissingWrappedKey = error.message.includes('No wrapped key available for decryption');
const metadata = message.keys_metadata as KeysMetadata;
// Check if this is a stale key failure (message encrypted with old key)
// This happens when: message was composed offline, recipient regenerated keys before sync
const isStaleKeyFailure =
error.name === 'OperationError' ||
error.message.includes('OperationError') ||
error.message.includes('decryption failed') ||
error.message.includes('decrypt error') ||
// react-native-quick-crypto specific error for RSA decryption failure
error.message.includes('error in DoCipher');
// Case 1: Missing wrapped key (pending_recipient)
// - Partner sent message before recipient had keys
// - keys_metadata has no recipient_wrapped_key
const shouldAttemptPendingRepair =
!isMe &&
isMissingWrappedKey &&
(!!metadata?.pending_recipient || !metadata?.recipient_wrapped_key);
// Case 2: Stale key failure
// - Message was encrypted with old public key
// - Need admin to re-wrap with current key
const shouldAttemptStaleKeyRepair =
isStaleKeyFailure &&
!isMissingWrappedKey &&
!repairAttemptedByMessageId.current.has(message.id);
if (shouldAttemptPendingRepair) {
beginEncryptionRecovery('pending-recipient');
try {
setState({
content: null,
mediaUri: null,
isDecrypting: true,
error: null,
errorCode: 'E2EE_PENDING_RECIPIENT_KEY',
});
if (!repairAttemptedByMessageId.current.has(message.id)) {
repairAttemptedByMessageId.current.add(message.id);
await reencryptPendingMessages();
}
// Fetch the latest keys_metadata and retry once locally.
const { data: refreshed } = await supabase
.from('messages')
.select('encrypted_content, encryption_iv, keys_metadata, user_id')
.eq('id', message.id)
.maybeSingle();
const refreshedMetadata = (refreshed?.keys_metadata ?? null) as KeysMetadata | null;
const refreshedEncryptedContent = refreshed?.encrypted_content ?? null;
const refreshedIv = refreshed?.encryption_iv ?? null;
if (refreshedMetadata && refreshedEncryptedContent && refreshedIv) {
try {
const decryptedContent = await decryptTextMessage(
refreshedEncryptedContent,
refreshedIv,
refreshedMetadata,
privateKeyJwk as RSAPrivateKeyJWK,
true, // isRecipient
);
setState({
content: decryptedContent,
mediaUri: null,
isDecrypting: false,
error: null,
errorCode: undefined,
});
return;
} catch {
// Still pending (or repair failed); show the placeholder instead of a hard error.
}
}
setState({
content: null,
mediaUri: null,
isDecrypting: false,
error: null,
errorCode: 'E2EE_PENDING_RECIPIENT_KEY',
});
return;
} finally {
endEncryptionRecovery();
}
}
if (shouldAttemptStaleKeyRepair) {
beginEncryptionRecovery('stale-key');
let didDecrypt = false;
try {
console.log(`[E2EE Decrypt] Message ${message.id}: Attempting stale key repair...`);
setState({
content: null,
mediaUri: null,
isDecrypting: true,
error: null,
errorCode: 'E2EE_REPAIRING_STALE_KEY',
});
repairAttemptedByMessageId.current.add(message.id);
// CRITICAL: Ensure local keys are uploaded to database BEFORE repair.
// The repair edge function uses the database public key to re-wrap.
// If the database has a stale key, repair will "succeed" but decryption will fail.
console.log(`[E2EE Decrypt] Message ${message.id}: Ensuring keys are synced before repair...`);
const keysUploaded = await ensureKeysUploaded();
console.log(`[E2EE Decrypt] Message ${message.id}: Keys sync result: ${keysUploaded}`);
// Run auto key rotation to re-wrap recent messages BEFORE individual repair.
// This ensures handle-key-rotation uses the correct database key.
const autoRotationResult = await triggerAutoKeyRotation();
if (autoRotationResult) {
console.log(`[E2EE Decrypt] Message ${message.id}: Auto rotation completed: ${autoRotationResult.updated} messages updated`);
}
try {
const repairResult = await repairStaleKey(message.id);
if (repairResult.success) {
console.log(`[E2EE Decrypt] Message ${message.id}: Stale key repaired, retrying decryption...`);
// Fetch updated message and retry
const { data: refreshed } = await supabase
.from('messages')
.select('encrypted_content, encryption_iv, keys_metadata, user_id')
.eq('id', message.id)
.maybeSingle();
const refreshedMetadata = (refreshed?.keys_metadata ?? null) as KeysMetadata | null;
const refreshedEncryptedContent = refreshed?.encrypted_content ?? null;
const refreshedIv = refreshed?.encryption_iv ?? null;
// Log key metadata for debugging
console.log(`[E2EE Decrypt] Message ${message.id}: Refreshed metadata:`, {
hasSenderKey: !!refreshedMetadata?.sender_wrapped_key,
hasRecipientKey: !!refreshedMetadata?.recipient_wrapped_key,
adminKeyId: refreshedMetadata?.admin_key_id,
isRecipient: !isMe,
});
if (refreshedMetadata && refreshedEncryptedContent && refreshedIv) {
const decryptedContent = await decryptTextMessage(
refreshedEncryptedContent,
refreshedIv,
refreshedMetadata,
privateKeyJwk as RSAPrivateKeyJWK,
!isMe, // isRecipient
);
setState({
content: decryptedContent,
mediaUri: null,
isDecrypting: false,
error: null,
errorCode: undefined,
});
didDecrypt = true;
return;
}
} else {
console.error(`[E2EE Decrypt] Message ${message.id}: Stale key repair failed:`, repairResult.error);
}
} catch (repairErr) {
// Note: This catch includes decryption errors after successful repair
console.error(`[E2EE Decrypt] Message ${message.id}: Decryption retry failed after repair:`, repairErr);
}
// Repair failed or retry failed
setState({
content: null,
mediaUri: null,
isDecrypting: false,
error: new Error('Waiting for message...'),
errorCode: 'E2EE_DECRYPT_FAILED',
});
return;
} finally {
if (!didDecrypt) {
// Schedule another retry after a delay - keys may sync in the background
retry();
}
endEncryptionRecovery();
}
}
setState({
content: null,
mediaUri: null,
isDecrypting: false,
error: new Error('Waiting for message...'),
errorCode: 'E2EE_DECRYPT_FAILED',
});
}
}
}, [
message.id,
message.version,
message.content,
message.encrypted_content,
message.encryption_iv,
message.keys_metadata,
message.user_id,
currentUserId,
privateKeyJwk,
hasKeys,
keysLoading,
retryNonce,
beginEncryptionRecovery,
endEncryptionRecovery,
retry,
ensureKeysUploaded,
]);
useEffect(() => {
decrypt();
}, [decrypt, retryNonce]);
useEffect(() => {
return () => {
if (autoRetryTimerRef.current) {
clearTimeout(autoRetryTimerRef.current);
autoRetryTimerRef.current = null;
}
};
}, [message.id]);
useEffect(() => {
if (!state.errorCode) {
autoRetryCountsByMessageId.delete(message.id);
if (autoRetryTimerRef.current) {
clearTimeout(autoRetryTimerRef.current);
autoRetryTimerRef.current = null;
}
return;
}
const shouldRetry =
state.errorCode === 'E2EE_PENDING_RECIPIENT_KEY' ||
state.errorCode === 'E2EE_KEYS_UNAVAILABLE';
if (!shouldRetry) {
return;
}
const attempts = autoRetryCountsByMessageId.get(message.id) ?? 0;
if (attempts >= MAX_AUTO_RETRIES) {
return;
}
const nextAttempt = attempts + 1;
const delayMs = AUTO_RETRY_BASE_DELAY_MS * nextAttempt;
autoRetryCountsByMessageId.set(message.id, nextAttempt);
autoRetryTimerRef.current = setTimeout(() => {
retry();
}, delayMs);
return () => {
if (autoRetryTimerRef.current) {
clearTimeout(autoRetryTimerRef.current);
autoRetryTimerRef.current = null;
}
};
}, [message.id, state.errorCode, retry]);
return { ...state, retry };
}
-349
View File
@@ -1,349 +0,0 @@
/**
* useEncryptedSend Hook
*
* Hook for sending encrypted messages via E2EE.
* Handles encryption with triple-wrapped keys (sender, recipient, admin).
*
* IMPORTANT: This hook reads keys from the global Zustand store to ensure
* consistency with all other encryption operations.
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import { Platform } from 'react-native';
import {
encryptTextMessage,
decryptTextMessage,
getAdminPublicKey,
getAdminKeyId,
isValidPublicKeyJwk,
sanitizePublicKeyJwk,
verifyKeyPair,
} from '../lib/encryption';
import type { RSAPublicKeyJWK, EncryptedMessagePayload } from '../lib/encryption';
import { useEncryptionKeys } from './useEncryptionKeys';
import { useAuthStore } from '../store';
import { captureError } from '../lib/sentry';
interface UseEncryptedSendReturn {
/** Encrypt a text message for sending */
encryptMessage: (plaintext: string) => Promise<EncryptedMessagePayload>;
/** Whether E2EE is available (keys loaded, not on web) */
isE2EEAvailable: boolean;
/** Whether the admin key is available and valid */
isAdminKeyReady: boolean;
/** Whether encryption is currently in progress */
isEncrypting: boolean;
/** Last encryption error, if any */
error: Error | null;
}
/**
* Hook for encrypting messages before sending
*
* Throws if E2EE is not available or keys are not ready.
*/
export function useEncryptedSend(): UseEncryptedSendReturn {
const [isEncrypting, setIsEncrypting] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [isAdminKeyReady, setIsAdminKeyReady] = useState(false);
// Track the version of keys we have verified to handle key rotation/regeneration
const verifiedKeysVersionRef = useRef<number | null>(null);
const partnerRefreshRef = useRef(0);
const adminKeyRetryRef = useRef(0);
const PARTNER_REFRESH_TTL_MS = 2 * 60 * 1000;
const ADMIN_KEY_RETRY_DELAYS = [500, 1000, 2000, 4000]; // Exponential backoff delays in ms
const { publicKeyJwk, privateKeyJwk, hasKeys, isLoading } = useEncryptionKeys();
const refreshPartner = useAuthStore((s) => s.refreshPartner);
const user = useAuthStore((s) => s.user);
// E2EE is available if:
// - Not on web platform
// - Keys are loaded and available
const isE2EEAvailable =
Platform.OS !== 'web' && hasKeys && !isLoading && !!publicKeyJwk && !!privateKeyJwk;
useEffect(() => {
let isMounted = true;
let retryTimeoutId: ReturnType<typeof setTimeout> | null = null;
const checkAdminKey = async (retryCount = 0) => {
if (!isE2EEAvailable) {
if (isMounted) {
setIsAdminKeyReady(false);
}
return;
}
try {
const adminPublicKey = await getAdminPublicKey();
const adminKeyId = await getAdminKeyId();
if (!adminKeyId || !isValidPublicKeyJwk(adminPublicKey)) {
throw new Error('Admin public key is invalid');
}
if (isMounted) {
setIsAdminKeyReady(true);
adminKeyRetryRef.current = 0; // Reset retry count on success
}
} catch (adminKeyError) {
console.warn(`[E2EE Send] Admin key not ready (attempt ${retryCount + 1}):`, adminKeyError);
if (isMounted) {
setIsAdminKeyReady(false);
// Retry with exponential backoff if we haven't exhausted retries
if (retryCount < ADMIN_KEY_RETRY_DELAYS.length) {
const delay = ADMIN_KEY_RETRY_DELAYS[retryCount];
console.log(`[E2EE Send] Retrying admin key fetch in ${delay}ms...`);
retryTimeoutId = setTimeout(() => {
if (isMounted) {
checkAdminKey(retryCount + 1);
}
}, delay);
} else {
console.warn('[E2EE Send] Admin key fetch failed after all retries');
}
}
}
};
checkAdminKey();
return () => {
isMounted = false;
if (retryTimeoutId) {
clearTimeout(retryTimeoutId);
}
};
}, [isE2EEAvailable]);
const encryptMessage = useCallback(
async (plaintext: string): Promise<EncryptedMessagePayload> => {
// Block sending until E2EE is ready
if (!isE2EEAvailable) {
const readyError = new Error('Message security not ready. Please check your connection.');
console.warn('[E2EE Send] E2EE not available');
setError(readyError);
throw readyError;
}
// CRITICAL: Read keys directly from the global store at encryption time
// This ensures we always use the most current keys, not potentially stale hook state
const currentKeys = useAuthStore.getState().encryptionKeys;
if (!currentKeys.hasKeys || !currentKeys.publicKeyJwk || !currentKeys.privateKeyJwk) {
// User-friendly error
const keysError = new Error('Message security not ready. Please check your connection.');
console.warn('[E2EE Send] Keys not fully available in global store');
setError(keysError);
throw keysError;
}
const senderPublicKey = currentKeys.publicKeyJwk;
const senderPrivateKey = currentKeys.privateKeyJwk;
// SAFETY CHECK: Verify the key pair works together before encrypting
// This prevents creating unrecoverable messages if keys are mismatched.
// We check if the current keys version matches what we last verified.
if (verifiedKeysVersionRef.current !== currentKeys.keysVersion) {
console.log(
`[E2EE Send] Verifying sender key pair (version ${currentKeys.keysVersion})...`
);
const isValidKeyPair = await verifyKeyPair(
senderPublicKey as RSAPublicKeyJWK,
senderPrivateKey
);
if (!isValidKeyPair) {
// User-friendly error
const keyError = new Error('Security check failed. Please restart the app.');
console.error('[E2EE Send] CRITICAL: Sender key pair verification failed!');
setError(keyError);
throw keyError;
}
verifiedKeysVersionRef.current = currentKeys.keysVersion;
console.log('[E2EE Send] Sender key pair verified successfully');
}
console.log(
'[E2EE Send] Using sender public key from global store, version:',
currentKeys.keysVersion
);
setIsEncrypting(true);
setError(null);
try {
// Refresh partner's profile only when key is missing/invalid or TTL expires
const resolvePartnerProfile = async () => {
const cachedPartner = useAuthStore.getState().partner;
const cachedKey = cachedPartner?.public_key_jwk as RSAPublicKeyJWK | null | undefined;
const hasValidKey = cachedKey ? isValidPublicKeyJwk(cachedKey) : false;
const now = Date.now();
const shouldRefresh = !hasValidKey || (now - partnerRefreshRef.current) > PARTNER_REFRESH_TTL_MS;
if (!shouldRefresh) {
return cachedPartner;
}
console.log('[E2EE Send] Refreshing partner profile to get latest public key...');
try {
const freshPartner = await refreshPartner();
partnerRefreshRef.current = Date.now();
return freshPartner ?? useAuthStore.getState().partner;
} catch (refreshError) {
console.warn('[E2EE Send] Partner refresh failed, using cached key:', refreshError);
partnerRefreshRef.current = Date.now();
return cachedPartner;
}
};
const freshPartner = await resolvePartnerProfile();
// Get admin key for moderation access
// SAFETY: Validate the admin key before using it
let adminPublicKey: RSAPublicKeyJWK;
let adminKeyId: string;
try {
adminPublicKey = await getAdminPublicKey();
adminKeyId = await getAdminKeyId();
// Validate admin key structure
if (!isValidPublicKeyJwk(adminPublicKey)) {
const adminError = new Error('Connection issue. Please try again.');
console.error('[E2EE Send] Admin public key is invalid');
setIsEncrypting(false);
setError(adminError);
throw adminError;
}
} catch (adminKeyError) {
// Re-throw if already a user-facing error
if (adminKeyError instanceof Error && (
adminKeyError.message.includes('Connection issue') ||
adminKeyError.message.includes('Unable to secure')
)) {
throw adminKeyError;
}
const fetchError = new Error('Connection issue. Please try again.');
console.error('[E2EE Send] Failed to fetch admin key:', adminKeyError);
setIsEncrypting(false);
setError(fetchError);
throw fetchError;
}
// Get partner's public key from the freshly fetched profile
let partnerPublicKey = freshPartner?.public_key_jwk as
| RSAPublicKeyJWK
| null
| undefined;
// Validate and sanitize partner's public key
if (partnerPublicKey) {
if (!isValidPublicKeyJwk(partnerPublicKey)) {
console.warn('[E2EE Send] Partner public key is invalid, treating as unavailable');
partnerPublicKey = null;
} else {
// Sanitize to remove trailing dots from base64 fields (react-native-quick-crypto bug)
partnerPublicKey = sanitizePublicKeyJwk(partnerPublicKey);
}
}
console.log('[E2EE Send] Partner public key available:', !!partnerPublicKey);
let lastPayload: EncryptedMessagePayload | null = null;
const encryptAndVerify = async (): Promise<EncryptedMessagePayload> => {
const payload = await encryptTextMessage(
plaintext,
senderPublicKey as RSAPublicKeyJWK,
partnerPublicKey ?? null,
adminPublicKey,
adminKeyId
);
lastPayload = payload;
const decrypted = await decryptTextMessage(
payload.encrypted_content,
payload.encryption_iv,
payload.keys_metadata,
senderPrivateKey,
false
);
if (decrypted !== plaintext) {
throw new Error('E2EE payload verification mismatch');
}
return payload;
};
const maxAttempts = 2;
let encryptedPayload: EncryptedMessagePayload | null = null;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
encryptedPayload = await encryptAndVerify();
break;
} catch (verifyError) {
console.warn(`[E2EE Send] Payload verification failed (attempt ${attempt})`, verifyError);
if (attempt === maxAttempts) {
const errorInstance =
verifyError instanceof Error
? verifyError
: new Error('E2EE payload verification failed');
const lastPayloadData = lastPayload as EncryptedMessagePayload | null;
captureError(errorInstance, {
stage: 'encrypt_verify',
attempt,
max_attempts: maxAttempts,
plaintext_length: plaintext.length,
encrypted_content_length: lastPayloadData?.encrypted_content.length ?? null,
encryption_iv_length: lastPayloadData?.encryption_iv.length ?? null,
admin_key_id: lastPayloadData?.keys_metadata?.admin_key_id ?? null,
pending_recipient: lastPayloadData?.keys_metadata?.pending_recipient ?? null,
has_partner_key: !!partnerPublicKey,
keys_version: currentKeys.keysVersion,
platform: Platform.OS,
user_id: user?.id ?? null,
});
throw new Error('Security check failed. Please try again.');
}
}
}
if (!encryptedPayload) {
throw new Error('Security check failed. Please try again.');
}
console.log('[E2EE Send] Message encrypted successfully');
setIsEncrypting(false);
return encryptedPayload;
} catch (err) {
console.error('[E2EE Send] Failed to encrypt message:', err);
setError(err as Error);
setIsEncrypting(false);
// Re-throw to block sending - never fall back to plaintext
throw err;
}
},
[isE2EEAvailable, refreshPartner]
);
return {
encryptMessage,
isE2EEAvailable,
isAdminKeyReady,
isEncrypting,
error,
};
}
-426
View File
@@ -1,426 +0,0 @@
/**
* useEncryptionKeys Hook
*
* React hook for managing E2EE encryption keys.
* Uses global Zustand store to ensure all components share the same key state.
*
* IMPORTANT: This hook reads from and writes to the global authStore.encryptionKeys
* to prevent race conditions where different hook instances have different state.
*/
import { useCallback, useRef, useEffect } from 'react';
import { Platform } from 'react-native';
import {
generateAndStoreKeyPair,
getPrivateKey,
getLocalPublicKey,
hasKeyPair,
isPublicKeyUploaded,
markPublicKeyUploaded,
isValidPublicKeyJwk,
sanitizePublicKeyJwk,
} from '../lib/encryption';
import type { RSAPublicKeyJWK } from '../lib/encryption';
import { supabase } from '../lib/supabase';
// Import directly from authStore to avoid circular dependency with store/index.ts
import { useAuthStore } from '../store/authStore';
interface UseEncryptionKeysReturn {
/** RSA private key in JWK format */
privateKeyJwk: RSAPublicKeyJWK | null;
/** RSA public key in JWK format */
publicKeyJwk: RSAPublicKeyJWK | null;
/** Whether keys are currently being loaded */
isLoading: boolean;
/** Whether valid keys exist */
hasKeys: boolean;
/** Any error that occurred */
error: Error | null;
/** Initialize keys and upload to Supabase - does everything in one call */
initializeAndUploadKeys: () => Promise<{ wasRegenerated: boolean }>;
/** Refresh the key state from storage */
refresh: () => Promise<void>;
/** Ensure local keys are uploaded to database. Blocks until complete. */
ensureKeysUploaded: () => Promise<boolean>;
}
// Global promise to track in-flight key loading across all hook instances
let globalLoadKeysPromise: Promise<RSAPublicKeyJWK | null> | null = null;
/**
* Hook for accessing and managing encryption keys.
*
* All key state is stored in the global Zustand store to ensure
* all components see the same keys at all times.
*/
export function useEncryptionKeys(): UseEncryptionKeysReturn {
// Get global state and actions from Zustand store
const encryptionKeys = useAuthStore((s) => s.encryptionKeys);
const setEncryptionKeys = useAuthStore((s) => s.setEncryptionKeys);
const user = useAuthStore((s) => s.user);
// Track if we've already loaded keys in this session
const hasLoadedRef = useRef(false);
const isLoadingRef = useRef(false);
/**
* Load existing keys from SecureStore into global state
*/
const loadKeys = useCallback(async () => {
// E2EE not supported on web
if (Platform.OS === 'web') {
setEncryptionKeys({
privateKeyJwk: null,
publicKeyJwk: null,
isLoadingKeys: false,
hasKeys: false,
keysError: null,
});
return null;
}
// If keys are already loaded in global store, return them
if (encryptionKeys.hasKeys && encryptionKeys.publicKeyJwk) {
return encryptionKeys.publicKeyJwk;
}
// If there's an in-flight global load, return that promise
if (globalLoadKeysPromise) {
console.log('[E2EE Global] Joining existing key load operation');
return globalLoadKeysPromise;
}
// Prevent concurrent loads (local check)
if (isLoadingRef.current) {
return encryptionKeys.publicKeyJwk;
}
const task = async () => {
try {
isLoadingRef.current = true;
setEncryptionKeys({ isLoadingKeys: true, keysError: null });
const keysExist = await hasKeyPair();
if (keysExist) {
const [privateKey, publicKey] = await Promise.all([
getPrivateKey(),
getLocalPublicKey(),
]);
console.log('[E2EE Global] Keys loaded from storage');
setEncryptionKeys({
privateKeyJwk: privateKey,
publicKeyJwk: publicKey,
isLoadingKeys: false,
hasKeys: true,
keysError: null,
});
hasLoadedRef.current = true;
return publicKey;
} else {
console.log('[E2EE Global] No keys in storage');
setEncryptionKeys({
privateKeyJwk: null,
publicKeyJwk: null,
isLoadingKeys: false,
hasKeys: false,
keysError: null,
});
return null;
}
} catch (error) {
console.error('[E2EE Global] Failed to load encryption keys:', error);
setEncryptionKeys({
privateKeyJwk: null,
publicKeyJwk: null,
isLoadingKeys: false,
hasKeys: false,
keysError: error as Error,
});
return null;
} finally {
isLoadingRef.current = false;
}
};
// Assign to global promise
globalLoadKeysPromise = task().finally(() => {
globalLoadKeysPromise = null;
});
return globalLoadKeysPromise;
}, [encryptionKeys.publicKeyJwk, encryptionKeys.hasKeys, setEncryptionKeys]);
/**
* Initialize encryption keys and upload to Supabase in one operation.
* This avoids race conditions between state updates and upload.
*
* @returns { wasRegenerated: boolean } - true if keys were newly generated or regenerated
*/
const initializeAndUploadKeys = useCallback(async (): Promise<{
wasRegenerated: boolean;
}> => {
if (Platform.OS === 'web') {
console.warn('[E2EE Global] E2EE not supported on web');
return { wasRegenerated: false };
}
if (!user?.id) {
console.warn('[E2EE Global] Cannot initialize keys: user not authenticated');
return { wasRegenerated: false };
}
// Prevent concurrent initialization
if (isLoadingRef.current) {
console.log('[E2EE Global] Key initialization already in progress');
return { wasRegenerated: false };
}
try {
isLoadingRef.current = true;
setEncryptionKeys({ isLoadingKeys: true, keysError: null });
let publicKey: RSAPublicKeyJWK | null = null;
let privateKey = null;
let wasRegenerated = false;
const keysExist = await hasKeyPair();
if (!keysExist) {
// Generate new key pair
console.log('[E2EE Global] Generating new key pair...');
publicKey = await generateAndStoreKeyPair();
privateKey = await getPrivateKey();
wasRegenerated = true;
// Update global state IMMEDIATELY with new keys
setEncryptionKeys({
privateKeyJwk: privateKey,
publicKeyJwk: publicKey,
isLoadingKeys: false,
hasKeys: true,
keysError: null,
});
console.log('[E2EE Global] Key pair generated and stored in global state');
} else {
// Load existing keys
[privateKey, publicKey] = await Promise.all([
getPrivateKey(),
getLocalPublicKey(),
]);
// Update global state with loaded keys
setEncryptionKeys({
privateKeyJwk: privateKey,
publicKeyJwk: publicKey,
isLoadingKeys: false,
hasKeys: true,
keysError: null,
});
}
// Validate keys
if (!publicKey || !isValidPublicKeyJwk(publicKey)) {
if (publicKey) {
console.warn(
'[E2EE Global] Local public key invalid, regenerating...'
);
} else {
console.warn(
'[E2EE Global] Local public key missing, regenerating...'
);
}
publicKey = await generateAndStoreKeyPair();
privateKey = await getPrivateKey();
wasRegenerated = true;
// Update global state IMMEDIATELY with regenerated keys
setEncryptionKeys({
privateKeyJwk: privateKey,
publicKeyJwk: publicKey,
isLoadingKeys: false,
hasKeys: true,
keysError: null,
});
console.log('[E2EE Global] Keys regenerated and stored in global state');
}
// Fetch the server copy to verify validity and avoid stale uploads
const { data: profile, error: profileError } = await supabase
.from('profiles')
.select('public_key_jwk')
.eq('id', user.id)
.maybeSingle();
if (profileError) {
throw profileError;
}
const remoteKey = (profile?.public_key_jwk ?? null) as RSAPublicKeyJWK | null;
const remoteValid = isValidPublicKeyJwk(remoteKey);
const localValid = isValidPublicKeyJwk(publicKey);
// Sanitize both keys before comparing (local key may have trailing dots)
const sanitizedLocal = sanitizePublicKeyJwk(publicKey!);
const sanitizedRemote = remoteKey ? sanitizePublicKeyJwk(remoteKey) : null;
const sameKey =
remoteValid &&
localValid &&
sanitizedRemote?.n === sanitizedLocal.n &&
sanitizedRemote?.e === sanitizedLocal.e;
// Upload if remote key is missing/invalid or doesn't match local.
// If we just regenerated the key, we MUST upload it regardless
if (publicKey && localValid && !sameKey) {
// Treat a mismatch as regeneration so we trigger key rotation later.
wasRegenerated = true;
}
if ((publicKey && localValid && !sameKey) || (publicKey && wasRegenerated)) {
console.log('[E2EE Global] Uploading public key to profile...');
// Sanitize key before upload
const sanitizedKey = sanitizePublicKeyJwk(publicKey);
const { error } = await supabase
.from('profiles')
.update({
public_key_jwk: sanitizedKey as unknown as Record<string, unknown>,
})
.eq('id', user.id);
if (error) {
throw error;
}
await markPublicKeyUploaded();
console.log('[E2EE Global] Public key uploaded successfully');
} else if (sameKey) {
const alreadyUploaded = await isPublicKeyUploaded();
if (!alreadyUploaded) {
await markPublicKeyUploaded();
}
console.log('[E2EE Global] Public key already uploaded and valid');
}
hasLoadedRef.current = true;
return { wasRegenerated };
} catch (error) {
console.error('[E2EE Global] Failed to initialize encryption keys:', error);
setEncryptionKeys({
isLoadingKeys: false,
keysError: error as Error,
});
throw error;
} finally {
isLoadingRef.current = false;
}
}, [user?.id, setEncryptionKeys]);
/**
* Refresh keys from storage
*/
const refresh = useCallback(async () => {
hasLoadedRef.current = false;
await loadKeys();
}, [loadKeys]);
/**
* Ensure local keys are uploaded to database.
* Blocks until upload is complete or fails.
* This is critical for E2EE repair flows where the edge function needs
* the current public key to re-wrap message AES keys.
*/
const ensureKeysUploaded = useCallback(async (): Promise<boolean> => {
if (Platform.OS === 'web') {
console.log('[E2EE] ensureKeysUploaded: Web platform, skipping');
return false;
}
if (!user?.id) {
console.log('[E2EE] ensureKeysUploaded: No user ID, skipping');
return false;
}
try {
// Check if local key matches database key
const { data: profile, error: profileError } = await supabase
.from('profiles')
.select('public_key_jwk')
.eq('id', user.id)
.maybeSingle();
if (profileError) {
console.error('[E2EE] ensureKeysUploaded: Failed to fetch profile:', profileError);
// Fall through to initializeAndUploadKeys which will handle this
}
const remoteKey = (profile?.public_key_jwk ?? null) as RSAPublicKeyJWK | null;
const localKey = encryptionKeys.publicKeyJwk;
if (!localKey) {
// No local key, need to initialize
console.log('[E2EE] ensureKeysUploaded: No local key, initializing...');
await initializeAndUploadKeys();
return true;
}
// Compare keys (sanitize both for accurate comparison)
const sanitizedLocal = sanitizePublicKeyJwk(localKey);
const sanitizedRemote = remoteKey ? sanitizePublicKeyJwk(remoteKey) : null;
const keysMatch = sanitizedRemote &&
sanitizedLocal.n === sanitizedRemote.n &&
sanitizedLocal.e === sanitizedRemote.e;
if (!keysMatch) {
// Mismatch - upload local key
console.log('[E2EE] ensureKeysUploaded: Key mismatch detected, uploading local key...');
await initializeAndUploadKeys();
return true;
}
console.log('[E2EE] ensureKeysUploaded: Keys already synced');
return true;
} catch (error) {
console.error('[E2EE] ensureKeysUploaded: Error:', error);
// Try to initialize anyway as a fallback
try {
await initializeAndUploadKeys();
return true;
} catch {
return false;
}
}
}, [user?.id, encryptionKeys.publicKeyJwk, initializeAndUploadKeys]);
// Load keys on mount if not already loaded
useEffect(() => {
if (!hasLoadedRef.current && !isLoadingRef.current && !encryptionKeys.hasKeys) {
loadKeys();
}
}, [loadKeys, encryptionKeys.hasKeys]);
// Reset loaded flag when user changes
useEffect(() => {
if (!user?.id) {
hasLoadedRef.current = false;
}
}, [user?.id]);
return {
privateKeyJwk: encryptionKeys.privateKeyJwk,
publicKeyJwk: encryptionKeys.publicKeyJwk,
isLoading: encryptionKeys.isLoadingKeys,
hasKeys: encryptionKeys.hasKeys,
error: encryptionKeys.keysError,
initializeAndUploadKeys,
refresh,
ensureKeysUploaded,
};
}
@@ -104,6 +104,7 @@ export const useMessageSubscription = (
const fetchMessagesAndMarkRead = async () => { const fetchMessagesAndMarkRead = async () => {
setLoading(true); setLoading(true);
setAllMessages([]); // Clear previous chat's messages immediately
const { data } = await supabase const { data } = await supabase
.from('messages') .from('messages')
@@ -1,88 +0,0 @@
/**
* Admin Keys Management
*
* Fetches the active admin public key from the master_keys table.
*/
import { supabase } from '../supabase';
import type { MasterKey, RSAPublicKeyJWK } from './types';
// Cache for admin key to avoid repeated database calls
let cachedAdminKey: MasterKey | null = null;
let cacheTimestamp: number = 0;
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
/**
* Fetch the active admin public key from the database
*
* Results are cached for 5 minutes to reduce database calls.
*
* @param forceRefresh - Force a fresh fetch, bypassing cache
* @returns The active master key record
*/
export async function getActiveMasterKey(forceRefresh = false): Promise<MasterKey> {
const now = Date.now();
// Return cached key if still valid
if (!forceRefresh && cachedAdminKey && (now - cacheTimestamp) < CACHE_TTL_MS) {
return cachedAdminKey;
}
const { data, error } = await supabase
.from('master_keys')
.select('*')
.eq('is_active', true)
.limit(1)
.maybeSingle();
if (error) {
throw new Error(`Failed to fetch admin key: ${error.message}`);
}
if (!data) {
throw new Error('No active admin key found. E2EE is not configured.');
}
// Validate the key structure
const publicKeyJwk = data.public_key_jwk as RSAPublicKeyJWK;
if (!publicKeyJwk || publicKeyJwk.kty !== 'RSA') {
throw new Error('Invalid admin public key format');
}
cachedAdminKey = {
id: data.id,
key_name: data.key_name,
public_key_jwk: publicKeyJwk,
is_active: data.is_active ?? true,
created_at: data.created_at ?? '',
rotated_at: data.rotated_at,
};
cacheTimestamp = now;
return cachedAdminKey;
}
/**
* Get the admin public key JWK directly
*/
export async function getAdminPublicKey(): Promise<RSAPublicKeyJWK> {
const masterKey = await getActiveMasterKey();
return masterKey.public_key_jwk;
}
/**
* Get the admin key ID for storing in message metadata
*/
export async function getAdminKeyId(): Promise<string> {
const masterKey = await getActiveMasterKey();
return masterKey.id;
}
/**
* Clear the admin key cache
* Called when keys might have been rotated
*/
export function clearAdminKeyCache(): void {
cachedAdminKey = null;
cacheTimestamp = 0;
}
@@ -1,30 +0,0 @@
/**
* E2EE Constants
*
* Algorithm identifiers and storage key names for encryption
*/
// SecureStore key names for private key storage
export const PRIVATE_KEY_PREFIX = 'e2ee_private_key_chunk_';
export const PUBLIC_KEY_UPLOADED = 'e2ee_public_key_uploaded';
export const LOCAL_PUBLIC_KEY = 'e2ee_local_public_key';
// Encryption algorithms
export const SYMMETRIC_ALGORITHM = 'AES-GCM';
export const SYMMETRIC_KEY_LENGTH = 256;
export const IV_LENGTH_BYTES = 12; // 96 bits for AES-GCM
// Key wrapping
export const KEY_WRAP_ALGORITHM = 'RSA-OAEP';
export const KEY_WRAP_HASH = 'SHA-256';
export const RSA_KEY_SIZE = 2048;
// SecureStore chunking (RSA-2048 JWK is ~1.7KB, SecureStore limit is 2KB)
export const SECURE_STORE_CHUNK_SIZE = 1900;
// Default master key name
export const DEFAULT_MASTER_KEY_NAME = 'e2ee_admin_key_v1';
// Message version constants
export const MESSAGE_VERSION_PLAINTEXT = 1;
export const MESSAGE_VERSION_E2EE = 2;
-226
View File
@@ -1,226 +0,0 @@
/**
* Low-level Crypto Operations
*
* AES-256-GCM encryption/decryption and RSA key wrapping operations.
*/
import {
SYMMETRIC_ALGORITHM,
SYMMETRIC_KEY_LENGTH,
IV_LENGTH_BYTES,
KEY_WRAP_ALGORITHM
} from './constants';
import { arrayBufferToBase64, base64ToArrayBuffer } from './utils';
import type { RSAPublicKeyJWK, RSAPrivateKeyJWK } from './types';
import { importPublicKey, importPrivateKey } from './keyManager';
/**
* Get the Web Crypto API
*/
function getSubtle(): SubtleCrypto {
if (typeof crypto !== 'undefined' && crypto.subtle) {
return crypto.subtle;
}
throw new Error('Web Crypto API not available');
}
/**
* Get crypto.getRandomValues
*/
function getRandomValues(array: Uint8Array): Uint8Array {
if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
return crypto.getRandomValues(array);
}
throw new Error('Crypto random not available');
}
/**
* Generate a random AES-256 key
*/
export async function generateAESKey(): Promise<CryptoKey> {
const subtle = getSubtle();
return await subtle.generateKey(
{ name: SYMMETRIC_ALGORITHM, length: SYMMETRIC_KEY_LENGTH },
true,
['encrypt', 'decrypt']
);
}
/**
* Generate a random initialization vector for AES-GCM (12 bytes)
*/
export function generateIV(): Uint8Array {
return getRandomValues(new Uint8Array(IV_LENGTH_BYTES));
}
/**
* Encrypt data with AES-256-GCM
*
* @param plaintext - The data to encrypt (string or ArrayBuffer)
* @param key - The AES key
* @param iv - The initialization vector
* @returns The encrypted ciphertext as ArrayBuffer
*/
export async function encryptAES(
plaintext: string | ArrayBuffer,
key: CryptoKey,
iv: Uint8Array
): Promise<ArrayBuffer> {
const subtle = getSubtle();
const data = typeof plaintext === 'string'
? new TextEncoder().encode(plaintext)
: plaintext;
return await subtle.encrypt(
{ name: SYMMETRIC_ALGORITHM, iv: iv as BufferSource },
key,
data
);
}
/**
* Decrypt data with AES-256-GCM
*
* @param ciphertext - The encrypted data
* @param key - The AES key
* @param iv - The initialization vector
* @returns The decrypted plaintext as ArrayBuffer
*/
export async function decryptAES(
ciphertext: ArrayBuffer,
key: CryptoKey,
iv: Uint8Array
): Promise<ArrayBuffer> {
const subtle = getSubtle();
return await subtle.decrypt(
{ name: SYMMETRIC_ALGORITHM, iv: iv as BufferSource },
key,
ciphertext
);
}
/**
* Export an AES key to raw bytes
*/
export async function exportAESKey(key: CryptoKey): Promise<ArrayBuffer> {
const subtle = getSubtle();
return await subtle.exportKey('raw', key);
}
/**
* Import an AES key from raw bytes
*/
export async function importAESKey(rawKey: ArrayBuffer): Promise<CryptoKey> {
const subtle = getSubtle();
return await subtle.importKey(
'raw',
rawKey,
{ name: SYMMETRIC_ALGORITHM },
false,
['decrypt']
);
}
/**
* Wrap (encrypt) an AES key with an RSA public key
*
* @param aesKeyRaw - The raw AES key bytes
* @param publicKeyJwk - The recipient's RSA public key
* @returns The wrapped key as base64 string
*/
export async function wrapAESKey(
aesKeyRaw: ArrayBuffer,
publicKeyJwk: RSAPublicKeyJWK
): Promise<string> {
const subtle = getSubtle();
const rsaKey = await importPublicKey(publicKeyJwk);
const wrappedKey = await subtle.encrypt(
{ name: KEY_WRAP_ALGORITHM },
rsaKey,
aesKeyRaw
);
return arrayBufferToBase64(wrappedKey);
}
/**
* Unwrap (decrypt) an AES key with an RSA private key
*
* @param wrappedKeyBase64 - The wrapped key as base64 string
* @param privateKeyJwk - The user's RSA private key
* @returns The unwrapped AES key as ArrayBuffer
*/
export async function unwrapAESKey(
wrappedKeyBase64: string,
privateKeyJwk: RSAPrivateKeyJWK
): Promise<ArrayBuffer> {
const subtle = getSubtle();
const rsaKey = await importPrivateKey(privateKeyJwk);
const wrappedKey = base64ToArrayBuffer(wrappedKeyBase64);
return await subtle.decrypt(
{ name: KEY_WRAP_ALGORITHM },
rsaKey,
wrappedKey
);
}
/**
* Verify that a public/private key pair actually work together.
* This catches key mismatches that could cause unrecoverable encrypted messages.
*
* @param publicKeyJwk - The RSA public key
* @param privateKeyJwk - The RSA private key
* @returns true if the keys work together, false otherwise
*/
export async function verifyKeyPair(
publicKeyJwk: RSAPublicKeyJWK,
privateKeyJwk: RSAPrivateKeyJWK
): Promise<boolean> {
try {
const subtle = getSubtle();
// Create test data (32 bytes - same as AES-256 key)
const testData = getRandomValues(new Uint8Array(32));
// Import keys
const publicKey = await importPublicKey(publicKeyJwk);
const privateKey = await importPrivateKey(privateKeyJwk);
// Encrypt with public key
const encrypted = await subtle.encrypt(
{ name: KEY_WRAP_ALGORITHM },
publicKey,
testData as BufferSource
);
// Decrypt with private key
const decrypted = await subtle.decrypt(
{ name: KEY_WRAP_ALGORITHM },
privateKey,
encrypted
);
// Verify the data matches
const decryptedArray = new Uint8Array(decrypted);
if (decryptedArray.length !== testData.length) {
return false;
}
for (let i = 0; i < testData.length; i++) {
if (testData[i] !== decryptedArray[i]) {
return false;
}
}
return true;
} catch (error) {
console.error('[E2EE] Key pair verification failed:', error);
return false;
}
}
-70
View File
@@ -1,70 +0,0 @@
/**
* E2EE Encryption Library
*
* Public API exports for the Sauci E2EE implementation.
*/
// Types
export type {
KeysMetadata,
EncryptedMessagePayload,
EncryptedMediaPayload,
DecryptedContent,
DecryptedMessageState,
RSAPublicKeyJWK,
RSAPrivateKeyJWK,
EncryptionKeysState,
MasterKey,
EncryptionContext,
} from './types';
// Constants
export {
MESSAGE_VERSION_PLAINTEXT,
MESSAGE_VERSION_E2EE,
} from './constants';
// Key Management
export {
generateAndStoreKeyPair,
getPrivateKey,
getLocalPublicKey,
hasKeyPair,
isPublicKeyUploaded,
markPublicKeyUploaded,
clearKeys,
isCryptoAvailable,
isValidPublicKeyJwk,
sanitizePublicKeyJwk,
} from './keyManager';
// Text Message Encryption/Decryption
export { encryptTextMessage } from './messageEncryption';
export { decryptTextMessage } from './messageDecryption';
// Media Encryption/Decryption
export { encryptMediaFile, getEncryptedFileExtension } from './mediaEncryption';
export { decryptMediaFile, cleanupDecryptedMedia } from './mediaDecryption';
// Admin Keys
export {
getActiveMasterKey,
getAdminPublicKey,
getAdminKeyId,
clearAdminKeyCache,
} from './adminKeys';
// Utilities
export {
arrayBufferToBase64,
base64ToArrayBuffer,
} from './utils';
// Key Rotation
export { triggerKeyRotation, triggerAutoKeyRotation } from './triggerKeyRotation';
// Stale Key Repair
export { repairStaleKey } from './repairStaleKey';
// Crypto Operations
export { verifyKeyPair } from './crypto';
@@ -1,294 +0,0 @@
/**
* E2EE Key Manager
*
* Handles RSA key pair generation and secure storage using expo-secure-store.
* Keys are stored in chunks due to SecureStore's 2KB limit.
*/
import * as SecureStore from 'expo-secure-store';
import { Platform } from 'react-native';
import {
PRIVATE_KEY_PREFIX,
PUBLIC_KEY_UPLOADED,
LOCAL_PUBLIC_KEY,
SECURE_STORE_CHUNK_SIZE,
RSA_KEY_SIZE,
KEY_WRAP_ALGORITHM,
KEY_WRAP_HASH
} from './constants';
import { chunkString } from './utils';
import type { RSAPublicKeyJWK, RSAPrivateKeyJWK } from './types';
/**
* Check if we're running in a web environment
*/
function isWeb(): boolean {
return Platform.OS === 'web';
}
/**
* Check if crypto is available (polyfill installed and native modules built)
*/
export function isCryptoAvailable(): boolean {
if (isWeb()) return false;
return typeof crypto !== 'undefined' && crypto.subtle !== undefined;
}
/**
* Sanitize a public key by removing trailing dots that may appear
* due to react-native-quick-crypto encoding issues.
*/
export function sanitizePublicKeyJwk(publicKeyJwk: RSAPublicKeyJWK): RSAPublicKeyJWK {
return {
...publicKeyJwk,
n: publicKeyJwk.n.replace(/\.+$/, ''),
e: publicKeyJwk.e.replace(/\.+$/, ''),
};
}
/**
* Validate that a public key JWK looks well-formed for RSA import.
*/
export function isValidPublicKeyJwk(publicKeyJwk: RSAPublicKeyJWK | null): boolean {
if (!publicKeyJwk) return false;
if (publicKeyJwk.kty !== 'RSA') return false;
if (typeof publicKeyJwk.n !== 'string' || typeof publicKeyJwk.e !== 'string') return false;
// Sanitize before validation (handle trailing dots from quick-crypto)
const sanitized = sanitizePublicKeyJwk(publicKeyJwk);
const base64UrlPattern = /^[A-Za-z0-9_\-+/=]+$/;
return base64UrlPattern.test(sanitized.n) && base64UrlPattern.test(sanitized.e);
}
/**
* Get the Web Crypto API
* In React Native with react-native-quick-crypto, it's available globally
*/
function getSubtle(): SubtleCrypto {
// react-native-quick-crypto polyfills global.crypto
if (typeof crypto !== 'undefined' && crypto.subtle) {
return crypto.subtle;
}
throw new Error('Web Crypto API not available. Run: npx expo prebuild --clean');
}
/**
* Generate a new RSA-2048 key pair for E2EE
*
* @returns The public key in JWK format (private key is stored in SecureStore)
*/
export async function generateAndStoreKeyPair(): Promise<RSAPublicKeyJWK> {
if (isWeb()) {
console.warn('E2EE key generation not supported on web platform');
throw new Error('E2EE is not supported on web');
}
const subtle = getSubtle();
// Generate RSA-2048 key pair
const keyPair = await subtle.generateKey(
{
name: KEY_WRAP_ALGORITHM,
modulusLength: RSA_KEY_SIZE,
publicExponent: new Uint8Array([1, 0, 1]), // 65537
hash: KEY_WRAP_HASH,
},
true, // extractable
['encrypt', 'decrypt']
);
// Export keys as JWK
const publicKeyJwk = await subtle.exportKey('jwk', keyPair.publicKey) as RSAPublicKeyJWK;
const privateKeyJwk = await subtle.exportKey('jwk', keyPair.privateKey) as RSAPrivateKeyJWK;
// Store private key in SecureStore (chunked)
await storePrivateKey(privateKeyJwk);
// Store public key locally for quick access
await storeLocalPublicKey(publicKeyJwk);
return publicKeyJwk;
}
/**
* Store private key in SecureStore using chunked storage
* RSA-2048 JWK is ~1.7KB, SecureStore limit is 2KB
*/
async function storePrivateKey(privateKeyJwk: RSAPrivateKeyJWK): Promise<void> {
const json = JSON.stringify(privateKeyJwk);
const chunks = chunkString(json, SECURE_STORE_CHUNK_SIZE);
// Store chunk count
await SecureStore.setItemAsync(
`${PRIVATE_KEY_PREFIX}count`,
chunks.length.toString()
);
// Store each chunk with high security settings
for (let i = 0; i < chunks.length; i++) {
await SecureStore.setItemAsync(
`${PRIVATE_KEY_PREFIX}${i}`,
chunks[i],
{
keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY
}
);
}
}
/**
* Store public key locally for quick access during encryption
*/
async function storeLocalPublicKey(publicKeyJwk: RSAPublicKeyJWK): Promise<void> {
await SecureStore.setItemAsync(
LOCAL_PUBLIC_KEY,
JSON.stringify(publicKeyJwk)
);
}
/**
* Retrieve the private key from SecureStore
*
* @returns The private key in JWK format, or null if not found
*/
export async function getPrivateKey(): Promise<RSAPrivateKeyJWK | null> {
if (isWeb()) {
return null;
}
try {
const countStr = await SecureStore.getItemAsync(`${PRIVATE_KEY_PREFIX}count`);
if (!countStr) return null;
const count = parseInt(countStr, 10);
const chunks: string[] = [];
for (let i = 0; i < count; i++) {
const chunk = await SecureStore.getItemAsync(`${PRIVATE_KEY_PREFIX}${i}`);
if (!chunk) return null;
chunks.push(chunk);
}
return JSON.parse(chunks.join('')) as RSAPrivateKeyJWK;
} catch (error) {
console.error('Failed to retrieve private key:', error);
return null;
}
}
/**
* Retrieve the locally stored public key
*
* @returns The public key in JWK format, or null if not found
*/
export async function getLocalPublicKey(): Promise<RSAPublicKeyJWK | null> {
if (isWeb()) {
return null;
}
try {
const json = await SecureStore.getItemAsync(LOCAL_PUBLIC_KEY);
if (!json) return null;
return JSON.parse(json) as RSAPublicKeyJWK;
} catch (error) {
console.error('Failed to retrieve local public key:', error);
return null;
}
}
/**
* Check if a key pair exists in SecureStore
*/
export async function hasKeyPair(): Promise<boolean> {
if (isWeb()) {
return false;
}
try {
const countStr = await SecureStore.getItemAsync(`${PRIVATE_KEY_PREFIX}count`);
return countStr !== null;
} catch (error) {
console.error('Failed to check for key pair:', error);
return false;
}
}
/**
* Check if public key has been uploaded to Supabase
*/
export async function isPublicKeyUploaded(): Promise<boolean> {
if (isWeb()) {
return false;
}
try {
const uploaded = await SecureStore.getItemAsync(PUBLIC_KEY_UPLOADED);
return uploaded === 'true';
} catch (error) {
console.error('Failed to check public key upload status:', error);
return false;
}
}
/**
* Mark public key as uploaded to Supabase
*/
export async function markPublicKeyUploaded(): Promise<void> {
await SecureStore.setItemAsync(PUBLIC_KEY_UPLOADED, 'true');
}
/**
* Clear all encryption keys from SecureStore
* Used when user logs out or leaves a couple
*/
export async function clearKeys(): Promise<void> {
if (isWeb()) {
return;
}
try {
const countStr = await SecureStore.getItemAsync(`${PRIVATE_KEY_PREFIX}count`);
if (countStr) {
const count = parseInt(countStr, 10);
for (let i = 0; i < count; i++) {
await SecureStore.deleteItemAsync(`${PRIVATE_KEY_PREFIX}${i}`);
}
await SecureStore.deleteItemAsync(`${PRIVATE_KEY_PREFIX}count`);
}
await SecureStore.deleteItemAsync(PUBLIC_KEY_UPLOADED);
await SecureStore.deleteItemAsync(LOCAL_PUBLIC_KEY);
} catch (error) {
console.error('Failed to clear encryption keys:', error);
}
}
/**
* Import an RSA public key from JWK format for encryption operations
*/
export async function importPublicKey(publicKeyJwk: RSAPublicKeyJWK): Promise<CryptoKey> {
const subtle = getSubtle();
return await subtle.importKey(
'jwk',
publicKeyJwk,
{ name: KEY_WRAP_ALGORITHM, hash: KEY_WRAP_HASH },
false,
['encrypt']
);
}
/**
* Import an RSA private key from JWK format for decryption operations
*/
export async function importPrivateKey(privateKeyJwk: RSAPrivateKeyJWK): Promise<CryptoKey> {
const subtle = getSubtle();
return await subtle.importKey(
'jwk',
privateKeyJwk,
{ name: KEY_WRAP_ALGORITHM, hash: KEY_WRAP_HASH },
false,
['decrypt']
);
}
@@ -1,100 +0,0 @@
/**
* Media Decryption
*
* Decrypts E2EE images and videos using a download-first approach.
*/
import * as FileSystem from 'expo-file-system';
import { unwrapAESKey, importAESKey, decryptAES } from './crypto';
import { base64ToArrayBuffer, base64ToUint8Array, arrayBufferToBase64 } from './utils';
import type { KeysMetadata, RSAPrivateKeyJWK } from './types';
/**
* Decrypt an encrypted media file
*
* Downloads the encrypted file, decrypts it, and returns a local file URI.
*
* @param encryptedUrl - Signed URL to the .enc file in storage
* @param encryptionIv - Base64-encoded initialization vector
* @param keysMetadata - The wrapped keys metadata
* @param privateKeyJwk - User's RSA private key
* @param isRecipient - Whether the current user is the recipient
* @param mediaType - Type of media ('image' or 'video')
* @returns Local file URI to the decrypted media
*/
export async function decryptMediaFile(
encryptedUrl: string,
encryptionIv: string,
keysMetadata: KeysMetadata,
privateKeyJwk: RSAPrivateKeyJWK,
isRecipient: boolean,
mediaType: 'image' | 'video'
): Promise<string> {
// 1. Download entire encrypted file
const tempEncryptedPath = `${FileSystem.cacheDirectory}${Date.now()}_encrypted.enc`;
const downloadResult = await FileSystem.downloadAsync(
encryptedUrl,
tempEncryptedPath
);
if (downloadResult.status !== 200) {
throw new Error(`Failed to download encrypted file: ${downloadResult.status}`);
}
// 2. Read encrypted data
const encryptedBase64 = await FileSystem.readAsStringAsync(
downloadResult.uri,
{ encoding: FileSystem.EncodingType.Base64 }
);
const encryptedData = base64ToArrayBuffer(encryptedBase64);
// 3. Validate and decode IV (must be exactly 12 bytes for AES-GCM)
const iv = base64ToUint8Array(encryptionIv);
if (iv.length !== 12) {
throw new Error(`Invalid IV length: expected 12 bytes, got ${iv.length}`);
}
// 4. Unwrap AES key
const wrappedKeyBase64 = isRecipient
? keysMetadata.recipient_wrapped_key
: keysMetadata.sender_wrapped_key;
if (!wrappedKeyBase64) {
throw new Error('No wrapped key available for media decryption');
}
const rawAesKey = await unwrapAESKey(wrappedKeyBase64, privateKeyJwk);
const aesKey = await importAESKey(rawAesKey);
// 5. Decrypt file
const decryptedData = await decryptAES(encryptedData, aesKey, iv);
// 6. Write decrypted file
const ext = mediaType === 'video' ? 'mp4' : 'jpg';
const decryptedUri = `${FileSystem.cacheDirectory}${Date.now()}_decrypted.${ext}`;
await FileSystem.writeAsStringAsync(
decryptedUri,
arrayBufferToBase64(decryptedData),
{ encoding: FileSystem.EncodingType.Base64 }
);
// 6. Clean up encrypted temp file
await FileSystem.deleteAsync(downloadResult.uri, { idempotent: true });
return decryptedUri;
}
/**
* Clean up a decrypted media file from cache
*
* @param uri - Local file URI to delete
*/
export async function cleanupDecryptedMedia(uri: string): Promise<void> {
try {
await FileSystem.deleteAsync(uri, { idempotent: true });
} catch (error) {
console.warn('Failed to clean up decrypted media:', error);
}
}
@@ -1,82 +0,0 @@
/**
* Media Encryption
*
* Encrypts images and videos using AES-256-GCM with triple-wrapped keys.
* Uses a download-then-decrypt approach for simplicity.
*/
import * as FileSystem from 'expo-file-system';
import { generateAESKey, generateIV, encryptAES, exportAESKey, wrapAESKey } from './crypto';
import { arrayBufferToBase64, base64ToArrayBuffer } from './utils';
import type { KeysMetadata, EncryptedMediaPayload, RSAPublicKeyJWK } from './types';
/**
* Encrypt a media file (image or video) for E2EE
*
* @param sourceUri - Local file URI of the media to encrypt
* @param senderPublicKeyJwk - Sender's RSA public key
* @param recipientPublicKeyJwk - Recipient's RSA public key (may be null)
* @param adminPublicKeyJwk - Admin's RSA public key
* @param adminKeyId - UUID of the admin key record
* @returns Encrypted media payload with local file URI
*/
export async function encryptMediaFile(
sourceUri: string,
senderPublicKeyJwk: RSAPublicKeyJWK,
recipientPublicKeyJwk: RSAPublicKeyJWK | null,
adminPublicKeyJwk: RSAPublicKeyJWK,
adminKeyId: string
): Promise<EncryptedMediaPayload> {
// 1. Read entire file into memory as base64
const fileBase64 = await FileSystem.readAsStringAsync(sourceUri, {
encoding: FileSystem.EncodingType.Base64,
});
const fileBuffer = base64ToArrayBuffer(fileBase64);
// 2. Generate AES key and IV
const aesKey = await generateAESKey();
const iv = generateIV();
// 3. Encrypt entire file
const encryptedData = await encryptAES(fileBuffer, aesKey, iv);
// 4. Write encrypted file to temp location
const encryptedUri = `${FileSystem.cacheDirectory}${Date.now()}.enc`;
await FileSystem.writeAsStringAsync(
encryptedUri,
arrayBufferToBase64(encryptedData),
{ encoding: FileSystem.EncodingType.Base64 }
);
// 5. Wrap AES key for all parties
const rawAesKey = await exportAESKey(aesKey);
const senderWrappedKey = await wrapAESKey(rawAesKey, senderPublicKeyJwk);
const adminWrappedKey = await wrapAESKey(rawAesKey, adminPublicKeyJwk);
let recipientWrappedKey: string | undefined;
if (recipientPublicKeyJwk) {
recipientWrappedKey = await wrapAESKey(rawAesKey, recipientPublicKeyJwk);
}
return {
encryptedFileUri: encryptedUri,
encryption_iv: arrayBufferToBase64(iv.buffer as ArrayBuffer),
keys_metadata: {
sender_wrapped_key: senderWrappedKey,
recipient_wrapped_key: recipientWrappedKey,
admin_wrapped_key: adminWrappedKey,
admin_key_id: adminKeyId,
algorithm: 'AES-256-GCM',
key_wrap_algorithm: 'RSA-OAEP-SHA256',
pending_recipient: !recipientPublicKeyJwk,
},
};
}
/**
* Get the file extension for encrypted media
*/
export function getEncryptedFileExtension(): string {
return '.enc';
}
@@ -1,57 +0,0 @@
/**
* Text Message Decryption
*
* Decrypts E2EE text messages using the user's RSA private key.
*/
import { unwrapAESKey, importAESKey, decryptAES } from './crypto';
import { base64ToArrayBuffer, base64ToUint8Array } from './utils';
import type { KeysMetadata, RSAPrivateKeyJWK } from './types';
/**
* Decrypt an E2EE text message
*
* @param encryptedContent - Base64-encoded ciphertext
* @param encryptionIv - Base64-encoded initialization vector
* @param keysMetadata - The wrapped keys metadata
* @param privateKeyJwk - User's RSA private key
* @param isRecipient - Whether the current user is the recipient (true) or sender (false)
* @returns The decrypted plaintext message
*/
export async function decryptTextMessage(
encryptedContent: string,
encryptionIv: string,
keysMetadata: KeysMetadata,
privateKeyJwk: RSAPrivateKeyJWK,
isRecipient: boolean
): Promise<string> {
// 1. Select the correct wrapped key
const wrappedKeyBase64 = isRecipient
? keysMetadata.recipient_wrapped_key
: keysMetadata.sender_wrapped_key;
if (!wrappedKeyBase64) {
throw new Error('No wrapped key available for decryption');
}
// 2. Validate and decode IV (must be exactly 12 bytes for AES-GCM)
const iv = base64ToUint8Array(encryptionIv);
if (iv.length !== 12) {
throw new Error(`Invalid IV length: expected 12 bytes, got ${iv.length}`);
}
// 3. Unwrap AES key using RSA private key
const rawAesKey = await unwrapAESKey(wrappedKeyBase64, privateKeyJwk);
// 4. Import AES key
const aesKey = await importAESKey(rawAesKey);
// 5. Decrypt content
const ciphertext = base64ToArrayBuffer(encryptedContent);
const plaintext = await decryptAES(ciphertext, aesKey, iv);
// 6. Decode to string
return new TextDecoder().decode(plaintext);
}
@@ -1,67 +0,0 @@
/**
* Text Message Encryption
*
* Encrypts text messages using AES-256-GCM with triple-wrapped keys.
*/
import { generateAESKey, generateIV, encryptAES, exportAESKey, wrapAESKey } from './crypto';
import { arrayBufferToBase64 } from './utils';
import type { KeysMetadata, EncryptedMessagePayload, RSAPublicKeyJWK } from './types';
/**
* Encrypt a text message for E2EE
*
* The message is encrypted with a random AES-256 key, and that key is
* wrapped (encrypted) separately for the sender, recipient, and admin.
*
* @param plaintext - The message text to encrypt
* @param senderPublicKeyJwk - Sender's RSA public key (for their own decryption)
* @param recipientPublicKeyJwk - Recipient's RSA public key (may be null if not yet generated)
* @param adminPublicKeyJwk - Admin's RSA public key (for moderation access)
* @param adminKeyId - UUID of the admin key record in master_keys table
* @returns The encrypted message payload
*/
export async function encryptTextMessage(
plaintext: string,
senderPublicKeyJwk: RSAPublicKeyJWK,
recipientPublicKeyJwk: RSAPublicKeyJWK | null,
adminPublicKeyJwk: RSAPublicKeyJWK,
adminKeyId: string
): Promise<EncryptedMessagePayload> {
// 1. Generate random AES-256 key
const aesKey = await generateAESKey();
// 2. Generate random 96-bit IV
const iv = generateIV();
// 3. Encrypt plaintext
const ciphertext = await encryptAES(plaintext, aesKey, iv);
// 4. Export AES key for wrapping
const rawAesKey = await exportAESKey(aesKey);
// 5. Wrap AES key for each party
const senderWrappedKey = await wrapAESKey(rawAesKey, senderPublicKeyJwk);
const adminWrappedKey = await wrapAESKey(rawAesKey, adminPublicKeyJwk);
let recipientWrappedKey: string | undefined;
if (recipientPublicKeyJwk) {
recipientWrappedKey = await wrapAESKey(rawAesKey, recipientPublicKeyJwk);
}
return {
version: 2,
encrypted_content: arrayBufferToBase64(ciphertext),
encryption_iv: arrayBufferToBase64(iv.buffer as ArrayBuffer),
keys_metadata: {
sender_wrapped_key: senderWrappedKey,
recipient_wrapped_key: recipientWrappedKey,
admin_wrapped_key: adminWrappedKey,
admin_key_id: adminKeyId,
algorithm: 'AES-256-GCM',
key_wrap_algorithm: 'RSA-OAEP-SHA256',
pending_recipient: !recipientPublicKeyJwk,
},
};
}
@@ -1,77 +0,0 @@
/**
* Pending Message Re-encryption (Server Assisted)
*
* Triggers the Supabase Edge Function `reencrypt-pending-messages` to re-wrap
* AES keys for messages that were sent before the current user had E2EE keys.
*
* This does NOT decrypt message content on the server. It only re-wraps the
* per-message AES key using the user's public key (admin recovery model).
*/
import { supabase } from '../supabase';
export interface ReencryptPendingMessagesResult {
success: boolean;
updated: number;
reason?: string;
error?: string;
}
let inFlight: Promise<ReencryptPendingMessagesResult | null> | null = null;
let lastRunAtMs = 0;
const MIN_INTERVAL_MS = 15_000;
export async function reencryptPendingMessages(
options: { force?: boolean } = {}
): Promise<ReencryptPendingMessagesResult | null> {
const now = Date.now();
if (inFlight) return inFlight;
if (!options.force && now - lastRunAtMs < MIN_INTERVAL_MS) {
return null;
}
inFlight = (async () => {
try {
const { data: { session } } = await supabase.auth.getSession();
if (!session?.access_token) {
return null;
}
const baseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL;
if (!baseUrl) {
return { success: false, updated: 0, error: 'Missing EXPO_PUBLIC_SUPABASE_URL' };
}
const response = await fetch(`${baseUrl}/functions/v1/reencrypt-pending-messages`, {
method: 'POST',
headers: {
Authorization: `Bearer ${session.access_token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({}),
});
if (!response.ok) {
const text = await response.text();
return { success: false, updated: 0, error: text || 'Failed to re-encrypt pending messages' };
}
const result = (await response.json()) as ReencryptPendingMessagesResult;
lastRunAtMs = Date.now();
return result;
} catch (err) {
return {
success: false,
updated: 0,
error: err instanceof Error ? err.message : 'Unknown error',
};
} finally {
inFlight = null;
}
})();
return inFlight;
}
@@ -1,82 +0,0 @@
/**
* Stale Key Repair (Server Assisted)
*
* When a message was encrypted offline with a recipient's old public key,
* and the recipient has since regenerated their keys, this function requests
* the server to re-wrap the AES key using the admin recovery model.
*/
import { supabase } from '../supabase';
export interface RepairStaleKeyResult {
success: boolean;
message_id?: string;
error?: string;
}
// Track in-flight repairs to avoid duplicate requests
const inFlightRepairs = new Map<string, Promise<RepairStaleKeyResult>>();
/**
* Request the server to repair a message encrypted with a stale key.
*
* @param messageId - The ID of the message to repair
* @returns Result of the repair operation
*/
export async function repairStaleKey(
messageId: string
): Promise<RepairStaleKeyResult> {
// Check if already in flight
const existing = inFlightRepairs.get(messageId);
if (existing) {
return existing;
}
const promise = (async (): Promise<RepairStaleKeyResult> => {
try {
const { data: { session } } = await supabase.auth.getSession();
if (!session?.access_token) {
return { success: false, error: 'Not authenticated' };
}
const baseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL;
if (!baseUrl) {
return { success: false, error: 'Missing EXPO_PUBLIC_SUPABASE_URL' };
}
const response = await fetch(`${baseUrl}/functions/v1/repair-stale-key`, {
method: 'POST',
headers: {
Authorization: `Bearer ${session.access_token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ message_id: messageId }),
});
if (!response.ok) {
const text = await response.text();
let errorMessage = 'Failed to repair message key';
try {
const json = JSON.parse(text);
errorMessage = json.error || errorMessage;
} catch {
// Use default error message
}
return { success: false, error: errorMessage };
}
const result = (await response.json()) as RepairStaleKeyResult;
return result;
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : 'Unknown error',
};
} finally {
inFlightRepairs.delete(messageId);
}
})();
inFlightRepairs.set(messageId, promise);
return promise;
}
@@ -1,102 +0,0 @@
/**
* Trigger Key Rotation (Server Assisted)
*
* Calls the Supabase Edge Function `handle-key-rotation` to re-wrap
* all message AES keys with the user's current public key.
*
* This is needed when a user regenerates their keys (new device, app reinstall).
* The admin key is used to unwrap the message keys and re-wrap them for
* the user's new public key.
*/
import { supabase } from '../supabase';
import { reencryptPendingMessages } from './reencryptPendingMessages';
export interface KeyRotationResult {
success: boolean;
updated: number;
reason?: string;
error?: string;
}
let inFlight: Promise<KeyRotationResult | null> | null = null;
let autoRotationInFlight: Promise<KeyRotationResult | null> | null = null;
let lastAutoRotationAt = 0;
const AUTO_ROTATION_MIN_INTERVAL_MS = 60_000;
export async function triggerKeyRotation(): Promise<KeyRotationResult | null> {
// Prevent concurrent calls
if (inFlight) return inFlight;
inFlight = (async () => {
try {
const { data: { session } } = await supabase.auth.getSession();
if (!session?.access_token) {
return null;
}
const baseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL;
if (!baseUrl) {
return { success: false, updated: 0, error: 'Missing EXPO_PUBLIC_SUPABASE_URL' };
}
console.log('[E2EE] Triggering key rotation for regenerated keys...');
const response = await fetch(`${baseUrl}/functions/v1/handle-key-rotation`, {
method: 'POST',
headers: {
Authorization: `Bearer ${session.access_token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({}),
});
if (!response.ok) {
const text = await response.text();
console.error('[E2EE] Key rotation failed:', text);
return { success: false, updated: 0, error: text || 'Failed to rotate keys' };
}
const result = (await response.json()) as KeyRotationResult;
console.log(`[E2EE] Key rotation complete: ${result.updated} messages updated`);
return result;
} catch (err) {
console.error('[E2EE] Key rotation error:', err);
return {
success: false,
updated: 0,
error: err instanceof Error ? err.message : 'Unknown error',
};
} finally {
inFlight = null;
}
})();
return inFlight;
}
export function triggerAutoKeyRotation(): Promise<KeyRotationResult | null> | null {
const now = Date.now();
if (autoRotationInFlight) return autoRotationInFlight;
if (now - lastAutoRotationAt < AUTO_ROTATION_MIN_INTERVAL_MS) {
return null;
}
lastAutoRotationAt = now;
autoRotationInFlight = (async () => {
console.log('[E2EE] Auto key rotation triggered by decryption failure.');
const result = await triggerKeyRotation();
await reencryptPendingMessages({ force: true });
return result;
})().finally(() => {
autoRotationInFlight = null;
});
return autoRotationInFlight;
}
-133
View File
@@ -1,133 +0,0 @@
/**
* E2EE Types for Sauci Chat
*
* This file contains all TypeScript interfaces for the E2EE implementation.
*/
/**
* Metadata about how message keys are wrapped for each party
*/
export interface KeysMetadata {
/** AES key encrypted with sender's RSA public key (base64) */
sender_wrapped_key: string;
/** AES key encrypted with recipient's RSA public key (base64). Null if recipient hasn't generated keys yet */
recipient_wrapped_key?: string;
/** AES key encrypted with admin's RSA public key (base64) */
admin_wrapped_key: string;
/** UUID of the master_keys record used */
admin_key_id: string;
/** Symmetric encryption algorithm */
algorithm: 'AES-256-GCM';
/** Key wrapping algorithm */
key_wrap_algorithm: 'RSA-OAEP-SHA256';
/** True if recipient key needs to be added when they generate their keys */
pending_recipient?: boolean;
}
/**
* Payload for an encrypted text message
*/
export interface EncryptedMessagePayload {
version: 2;
encrypted_content: string;
encryption_iv: string;
keys_metadata: KeysMetadata;
}
/**
* Payload for encrypted media
*/
export interface EncryptedMediaPayload {
encryptedFileUri: string;
encryption_iv: string;
keys_metadata: KeysMetadata;
}
/**
* Decrypted content result
*/
export interface DecryptedContent {
content: string | null;
mediaUrl: string | null;
}
/**
* State for useDecryptedMessage hook
*/
export type DecryptedMessageErrorCode =
| 'E2EE_NOT_SUPPORTED'
| 'E2EE_KEYS_UNAVAILABLE'
| 'E2EE_MISSING_FIELDS'
| 'E2EE_PENDING_RECIPIENT_KEY'
| 'E2EE_REPAIRING_STALE_KEY'
| 'E2EE_DECRYPT_FAILED';
export interface DecryptedMessageState {
content: string | null;
mediaUri: string | null;
isDecrypting: boolean;
error: Error | null;
errorCode?: DecryptedMessageErrorCode;
}
/**
* RSA public key in JWK format (JSON Web Key)
*/
export interface RSAPublicKeyJWK {
kty: 'RSA';
n: string; // modulus
e: string; // exponent
alg?: string;
key_ops?: string[];
}
/**
* RSA private key in JWK format (includes public key components)
*/
export interface RSAPrivateKeyJWK extends RSAPublicKeyJWK {
d: string; // private exponent
p: string; // first prime factor
q: string; // second prime factor
dp: string; // first factor CRT exponent
dq: string; // second factor CRT exponent
qi: string; // first CRT coefficient
}
/**
* State for useEncryptionKeys hook
*/
export interface EncryptionKeysState {
privateKeyJwk: RSAPrivateKeyJWK | null;
publicKeyJwk: RSAPublicKeyJWK | null;
isLoading: boolean;
hasKeys: boolean;
error: Error | null;
}
/**
* Master key record from database
*/
export interface MasterKey {
id: string;
key_name: string;
public_key_jwk: RSAPublicKeyJWK;
is_active: boolean;
created_at: string;
rotated_at: string | null;
}
/**
* Encryption context for sending messages
*/
export interface EncryptionContext {
senderPublicKeyJwk: RSAPublicKeyJWK;
recipientPublicKeyJwk: RSAPublicKeyJWK | null;
adminPublicKeyJwk: RSAPublicKeyJWK;
adminKeyId: string;
}
-72
View File
@@ -1,72 +0,0 @@
/**
* E2EE Utility Functions
*
* Base64 and ArrayBuffer conversion utilities for encryption operations.
*/
/**
* Convert ArrayBuffer to base64 string
*/
export function arrayBufferToBase64(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
/**
* Convert base64 string to ArrayBuffer
*/
export function base64ToArrayBuffer(base64: string): ArrayBuffer {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer;
}
/**
* Convert Uint8Array to base64 string
*/
export function uint8ArrayToBase64(array: Uint8Array): string {
let binary = '';
for (let i = 0; i < array.length; i++) {
binary += String.fromCharCode(array[i]);
}
return btoa(binary);
}
/**
* Convert base64 string to Uint8Array
*/
export function base64ToUint8Array(base64: string): Uint8Array {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
/**
* Split string into chunks of specified size
*/
export function chunkString(str: string, size: number): string[] {
const chunks: string[] = [];
for (let i = 0; i < str.length; i += size) {
chunks.push(str.slice(i, i + size));
}
return chunks;
}
/**
* Generate a random string for unique identifiers
*/
export function generateRandomId(): string {
const array = new Uint8Array(16);
crypto.getRandomValues(array);
return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('');
}
-22
View File
@@ -1,22 +0,0 @@
/**
* Media Cache Utilities
*
* Extracted from useDecryptedMedia to avoid circular dependencies.
* This module handles caching of decrypted media files.
*/
import { cleanupDecryptedMedia } from './encryption';
// In-memory cache to avoid re-decrypting the same media
export const decryptedMediaCache = new Map<string, string>();
/**
* Clears all cached decrypted media files from disk and memory.
* Call this on sign out to free up storage.
*/
export async function clearDecryptedMediaCache(): Promise<void> {
for (const uri of decryptedMediaCache.values()) {
await cleanupDecryptedMedia(uri);
}
decryptedMediaCache.clear();
}
+25 -10
View File
@@ -1,13 +1,17 @@
import type { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
type IoniconsName = keyof typeof Ionicons.glyphMap; type IoniconsName = keyof typeof Ionicons.glyphMap;
/** /**
* Maps emoji icons to Ionicons for a more professional, consistent appearance. * Checks if a string is a valid Ionicons name.
* This eliminates the "AI slop" feel of heavy emoji usage while maintaining * Uses the actual Ionicons.glyphMap for runtime validation.
* visual differentiation between packs.
*/ */
export const PACK_ICON_MAP: Record<string, IoniconsName> = { function isValidIonicon(name: string): name is IoniconsName {
return name in Ionicons.glyphMap;
}
// Legacy emoji mappings (for backwards compatibility with existing packs)
const EMOJI_TO_IONICON: Record<string, IoniconsName> = {
// Career & Professional // Career & Professional
"💼": "briefcase-outline", "💼": "briefcase-outline",
@@ -49,13 +53,24 @@ export const PACK_ICON_MAP: Record<string, IoniconsName> = {
"📁": "folder-outline", "📁": "folder-outline",
}; };
export const PACK_ICON_MAP = EMOJI_TO_IONICON;
export const DEFAULT_PACK_ICON: IoniconsName = "layers-outline"; export const DEFAULT_PACK_ICON: IoniconsName = "layers-outline";
/** /**
* Converts an emoji icon string to an Ionicons name. * Converts an icon value to an Ionicons name.
* Falls back to DEFAULT_PACK_ICON if no mapping exists. * Handles both:
* - Any valid Ionicon name directly: "briefcase-outline" -> "briefcase-outline"
* - Legacy emojis (existing packs): "💼" -> "briefcase-outline"
*/ */
export function getPackIconName(emoji: string | null | undefined): IoniconsName { export function getPackIconName(icon: string | null | undefined): IoniconsName {
if (!emoji) return DEFAULT_PACK_ICON; if (!icon) return DEFAULT_PACK_ICON;
return PACK_ICON_MAP[emoji] || DEFAULT_PACK_ICON;
// Check if it's a valid Ionicon name (supports the full Ionicons set)
if (isValidIonicon(icon)) {
return icon;
}
// Fall back to emoji mapping for legacy packs
return EMOJI_TO_IONICON[icon] || DEFAULT_PACK_ICON;
} }
+1 -126
View File
@@ -1,32 +1,8 @@
import { create } from "zustand"; import { create } from "zustand";
import { supabase } from "../lib/supabase"; import { supabase } from "../lib/supabase";
import { Events } from "../lib/analytics"; import { Events } from "../lib/analytics";
import { clearKeys } from "../lib/encryption";
import { clearDecryptedMediaCache } from "../lib/mediaCache";
import type { Profile, Couple } from "@/types"; import type { Profile, Couple } from "@/types";
import type { RSAPublicKeyJWK, RSAPrivateKeyJWK } from "../lib/encryption/types";
/**
* Encryption key state - stored globally to ensure all components
* share the same key state and avoid race conditions during encryption.
*/
type EncryptionRecoveryReason = 'stale-key' | 'pending-recipient' | 'rotation' | 'keys-unavailable';
interface EncryptionKeyState {
privateKeyJwk: RSAPrivateKeyJWK | null;
publicKeyJwk: RSAPublicKeyJWK | null;
hasKeys: boolean;
isLoadingKeys: boolean;
keysError: Error | null;
/** Incremented when keys change - consumers can use this to trigger re-renders */
keysVersion: number;
/** Background recovery status for E2EE issues */
isRecovering: boolean;
recoveryReason: EncryptionRecoveryReason | null;
recoveryCount: number;
recoveryUpdatedAt: number | null;
}
interface AuthState { interface AuthState {
user: Profile | null; user: Profile | null;
@@ -35,22 +11,12 @@ interface AuthState {
isLoading: boolean; isLoading: boolean;
isAuthenticated: boolean; isAuthenticated: boolean;
// Encryption key state (global to avoid hook instance sync issues)
encryptionKeys: EncryptionKeyState;
// Actions // Actions
fetchUser: () => Promise<void>; fetchUser: () => Promise<void>;
fetchCouple: () => Promise<void>; fetchCouple: () => Promise<void>;
refreshPartner: () => Promise<Profile | null>; refreshPartner: () => Promise<Profile | null>;
signOut: () => Promise<void>; signOut: () => Promise<void>;
setUser: (user: Profile | null) => void; setUser: (user: Profile | null) => void;
// Encryption key actions
setEncryptionKeys: (keys: Partial<EncryptionKeyState>) => void;
clearEncryptionKeys: () => void;
beginEncryptionRecovery: (reason: EncryptionRecoveryReason) => void;
endEncryptionRecovery: () => void;
} }
// Import other stores lazily to avoid circular dependency issues // Import other stores lazily to avoid circular dependency issues
@@ -62,19 +28,6 @@ const getOtherStores = () => {
return { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore }; return { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore };
}; };
const initialEncryptionKeyState: EncryptionKeyState = {
privateKeyJwk: null,
publicKeyJwk: null,
hasKeys: false,
isLoadingKeys: true,
keysError: null,
keysVersion: 0,
isRecovering: false,
recoveryReason: null,
recoveryCount: 0,
recoveryUpdatedAt: null,
};
export const useAuthStore = create<AuthState>((set, get) => ({ export const useAuthStore = create<AuthState>((set, get) => ({
user: null, user: null,
@@ -82,7 +35,6 @@ export const useAuthStore = create<AuthState>((set, get) => ({
partner: null, partner: null,
isLoading: true, isLoading: true,
isAuthenticated: false, isAuthenticated: false,
encryptionKeys: initialEncryptionKeyState,
fetchUser: async () => { fetchUser: async () => {
try { try {
@@ -100,13 +52,6 @@ export const useAuthStore = create<AuthState>((set, get) => ({
if (authError || !authUser) { if (authError || !authUser) {
console.log("[Auth] Session invalid, signing out:", authError?.message); console.log("[Auth] Session invalid, signing out:", authError?.message);
// Clear E2EE keys
try {
await clearKeys();
} catch (error) {
console.error("Failed to clear encryption keys:", error);
}
// Session is invalid - clear everything // Session is invalid - clear everything
set({ user: null, couple: null, partner: null, isAuthenticated: false, isLoading: false }); set({ user: null, couple: null, partner: null, isAuthenticated: false, isLoading: false });
@@ -191,20 +136,6 @@ export const useAuthStore = create<AuthState>((set, get) => ({
signOut: async () => { signOut: async () => {
Events.signOut(); Events.signOut();
// Clear E2EE keys from storage
try {
await clearKeys();
} catch (error) {
console.error("Failed to clear encryption keys:", error);
}
// Clear decrypted media cache to free storage
try {
await clearDecryptedMediaCache();
} catch (error) {
console.error("Failed to clear media cache:", error);
}
// Clear local state FIRST to ensure UI updates even if Supabase call fails // Clear local state FIRST to ensure UI updates even if Supabase call fails
set({ set({
user: null, user: null,
@@ -212,7 +143,6 @@ export const useAuthStore = create<AuthState>((set, get) => ({
partner: null, partner: null,
isAuthenticated: false, isAuthenticated: false,
isLoading: false, isLoading: false,
encryptionKeys: initialEncryptionKeyState,
}); });
// Clear other stores // Clear other stores
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore } = getOtherStores(); const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore } = getOtherStores();
@@ -246,60 +176,5 @@ export const useAuthStore = create<AuthState>((set, get) => ({
useSubscriptionStore.getState().clearSubscription(); useSubscriptionStore.getState().clearSubscription();
} }
}, },
}));
/**
* Update encryption key state. This is the ONLY way to update keys
* to ensure all consumers see the same state.
*/
setEncryptionKeys: (keys) => {
set((state) => ({
encryptionKeys: {
...state.encryptionKeys,
...keys,
// Increment version when keys actually change
keysVersion: (keys.publicKeyJwk !== undefined || keys.privateKeyJwk !== undefined)
? state.encryptionKeys.keysVersion + 1
: state.encryptionKeys.keysVersion,
},
}));
},
beginEncryptionRecovery: (reason) => {
set((state) => {
const nextCount = state.encryptionKeys.recoveryCount + 1;
return {
encryptionKeys: {
...state.encryptionKeys,
recoveryCount: nextCount,
isRecovering: true,
recoveryReason: reason,
recoveryUpdatedAt: Date.now(),
},
};
});
},
endEncryptionRecovery: () => {
set((state) => {
const nextCount = Math.max(0, state.encryptionKeys.recoveryCount - 1);
const isRecovering = nextCount > 0;
return {
encryptionKeys: {
...state.encryptionKeys,
recoveryCount: nextCount,
isRecovering,
recoveryReason: isRecovering ? state.encryptionKeys.recoveryReason : null,
recoveryUpdatedAt: Date.now(),
},
};
});
},
/**
* Clear encryption keys (called on sign out or when leaving couple)
*/
clearEncryptionKeys: () => {
set({ encryptionKeys: initialEncryptionKeyState });
},
}));
+1 -1
View File
@@ -5,4 +5,4 @@ export { usePacksStore } from "./packsStore";
export { useMessageStore } from "./messageStore"; export { useMessageStore } from "./messageStore";
export { useSubscriptionStore } from "./subscriptionStore"; export { useSubscriptionStore } from "./subscriptionStore";
export { useResponsesStore, groupResponses } from "./responsesStore"; export { useResponsesStore, groupResponses } from "./responsesStore";
export type { ResponseWithQuestion, UpdateResponseResult, GroupByOption } from "./responsesStore"; export type { ResponseWithQuestion, UpdateResponseResult, GroupByOption, DateSortOrder } from "./responsesStore";
+15 -3
View File
@@ -147,15 +147,27 @@ export const useMatchStore = create<MatchState>((set, get) => ({
// Ideally we should have a separate query for count, but we'll just count in current batch // Ideally we should have a separate query for count, but we'll just count in current batch
const newMatchesInBatch = sortedData.filter((m) => m.is_new).length; const newMatchesInBatch = sortedData.filter((m) => m.is_new).length;
set((state) => ({ set((state) => {
matches: refresh ? sortedData : [...state.matches, ...sortedData], // Deduplicate matches by ID when appending new pages
const mergedMatches = refresh
? sortedData
: [...state.matches, ...sortedData].reduce<Match[]>((acc, match) => {
if (!acc.some((m: Match) => m.id === match.id)) {
acc.push(match);
}
return acc;
}, []);
return {
matches: mergedMatches,
newMatchesCount: refresh ? newMatchesInBatch : state.newMatchesCount + newMatchesInBatch, newMatchesCount: refresh ? newMatchesInBatch : state.newMatchesCount + newMatchesInBatch,
totalCount: refresh ? totalCount : state.totalCount, totalCount: refresh ? totalCount : state.totalCount,
isLoading: false, isLoading: false,
isLoadingMore: false, isLoadingMore: false,
page: currentPage + 1, page: currentPage + 1,
hasMore: matches.length === BATCH_SIZE hasMore: matches.length === BATCH_SIZE
})); };
});
} catch (err) { } catch (err) {
console.error("Error fetching matches:", err); console.error("Error fetching matches:", err);
set({ error: "Failed to load matches", isLoading: false, isLoadingMore: false }); set({ error: "Failed to load matches", isLoading: false, isLoadingMore: false });
+28 -5
View File
@@ -32,6 +32,7 @@ export interface UpdateResponseResult {
} }
export type GroupByOption = "pack" | "date" | "answer"; export type GroupByOption = "pack" | "date" | "answer";
export type DateSortOrder = "newest" | "oldest";
const BATCH_SIZE = 20; const BATCH_SIZE = 20;
@@ -40,6 +41,7 @@ interface ResponsesState {
isLoading: boolean; isLoading: boolean;
isLoadingMore: boolean; isLoadingMore: boolean;
groupBy: GroupByOption; groupBy: GroupByOption;
dateSortOrder: DateSortOrder;
hasMore: boolean; hasMore: boolean;
page: number; page: number;
totalCount: number | null; totalCount: number | null;
@@ -50,6 +52,8 @@ interface ResponsesState {
confirmDelete?: boolean confirmDelete?: boolean
) => Promise<UpdateResponseResult>; ) => Promise<UpdateResponseResult>;
setGroupBy: (groupBy: GroupByOption) => void; setGroupBy: (groupBy: GroupByOption) => void;
setDateSortOrder: (order: DateSortOrder) => void;
toggleDateSortOrder: () => void;
clearResponses: () => void; clearResponses: () => void;
} }
@@ -57,7 +61,8 @@ export const useResponsesStore = create<ResponsesState>((set, get) => ({
responses: [], responses: [],
isLoading: false, isLoading: false,
isLoadingMore: false, isLoadingMore: false,
groupBy: "pack", groupBy: "date",
dateSortOrder: "newest",
hasMore: true, hasMore: true,
page: 0, page: 0,
totalCount: null, totalCount: null,
@@ -270,15 +275,26 @@ export const useResponsesStore = create<ResponsesState>((set, get) => ({
set({ groupBy }); set({ groupBy });
}, },
clearResponses: () => { setDateSortOrder: (order: DateSortOrder) => {
set({ responses: [], isLoading: false, groupBy: "pack", page: 0, hasMore: true, isLoadingMore: false, totalCount: null }); set({ dateSortOrder: order });
},
toggleDateSortOrder: () => {
set((state) => ({
dateSortOrder: state.dateSortOrder === "newest" ? "oldest" : "newest",
}));
},
clearResponses: () => {
set({ responses: [], isLoading: false, groupBy: "date", dateSortOrder: "newest", page: 0, hasMore: true, isLoadingMore: false, totalCount: null });
}, },
})); }));
// Helper function to group responses // Helper function to group responses
export function groupResponses( export function groupResponses(
responses: ResponseWithQuestion[], responses: ResponseWithQuestion[],
groupBy: GroupByOption groupBy: GroupByOption,
dateSortOrder: DateSortOrder = "newest"
): { title: string; data: ResponseWithQuestion[] }[] { ): { title: string; data: ResponseWithQuestion[] }[] {
if (groupBy === "pack") { if (groupBy === "pack") {
// Group by pack name // Group by pack name
@@ -307,9 +323,16 @@ export function groupResponses(
} }
if (groupBy === "date") { if (groupBy === "date") {
// Sort responses by date based on sort order
const sortedResponses = [...responses].sort((a, b) => {
const dateA = new Date(a.created_at).getTime();
const dateB = new Date(b.created_at).getTime();
return dateSortOrder === "newest" ? dateB - dateA : dateA - dateB;
});
// Group by date (day) // Group by date (day)
const groups = new Map<string, ResponseWithQuestion[]>(); const groups = new Map<string, ResponseWithQuestion[]>();
responses.forEach((r) => { sortedResponses.forEach((r) => {
const date = new Date(r.created_at).toLocaleDateString("en-US", { const date = new Date(r.created_at).toLocaleDateString("en-US", {
year: "numeric", year: "numeric",
month: "long", month: "long",
@@ -1,315 +0,0 @@
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
};
// Only re-wrap messages from the last 7 days synchronously
// Older messages will be repaired on-demand via repair-stale-key
const RECENT_DAYS = 7;
const MAX_MESSAGES_PER_REQUEST = 500;
/**
* Handles key rotation when a user gets a new device.
*
* When a user's public key changes (detected via database trigger or called manually),
* this function re-wraps RECENT message AES keys with the user's new public key.
* Older messages are repaired on-demand when the user tries to view them.
* This prevents timeouts for couples with large message histories.
*/
Deno.serve(async (req) => {
// Handle CORS preflight
if (req.method === "OPTIONS") {
return new Response("ok", { headers: corsHeaders });
}
try {
const supabaseAdmin = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
);
// Get user from JWT
const authHeader = req.headers.get("Authorization");
if (!authHeader) {
return new Response(
JSON.stringify({ error: "Missing authorization header" }),
{ status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
const { data: { user }, error: authError } = await supabaseAdmin.auth.getUser(
authHeader.replace("Bearer ", "")
);
if (authError || !user) {
return new Response(
JSON.stringify({ error: `Invalid token: ${authError?.message || 'No user found'}` }),
{ status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
// Get user's couple_id and new public key
const { data: profile, error: profileError } = await supabaseAdmin
.from("profiles")
.select("couple_id, public_key_jwk")
.eq("id", user.id)
.maybeSingle();
if (profileError || !profile) {
return new Response(
JSON.stringify({ error: "Profile not found" }),
{ status: 404, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
if (!profile.couple_id) {
return new Response(
JSON.stringify({ success: true, updated: 0, reason: "No couple" }),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
if (!profile.public_key_jwk) {
return new Response(
JSON.stringify({ error: "User has no public key" }),
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
// Find all matches for this couple
const { data: matches } = await supabaseAdmin
.from("matches")
.select("id")
.eq("couple_id", profile.couple_id);
const matchIds = matches?.map(m => m.id) || [];
if (matchIds.length === 0) {
return new Response(
JSON.stringify({ success: true, updated: 0, reason: "No matches found" }),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
// Calculate cutoff date for recent messages
const recentCutoff = new Date(Date.now() - RECENT_DAYS * 24 * 60 * 60 * 1000);
// Find RECENT v2 messages in these matches (lazy re-encryption)
const { data: messages, error: messagesError } = await supabaseAdmin
.from("messages")
.select("id, user_id, keys_metadata, created_at")
.eq("version", 2)
.in("match_id", matchIds)
.gte("created_at", recentCutoff.toISOString())
.order("created_at", { ascending: false })
.limit(MAX_MESSAGES_PER_REQUEST);
if (messagesError) {
console.error("Error fetching messages:", messagesError);
return new Response(
JSON.stringify({ error: "Failed to fetch messages" }),
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
// Check if there are older messages that weren't processed
const { count: totalCount } = await supabaseAdmin
.from("messages")
.select("id", { count: "exact", head: true })
.eq("version", 2)
.in("match_id", matchIds);
const hasOlderMessages = (totalCount ?? 0) > (messages?.length ?? 0);
let updatedCount = 0;
let errorCount = 0;
for (const message of messages || []) {
const metadata = message.keys_metadata as {
sender_wrapped_key: string;
recipient_wrapped_key?: string;
admin_wrapped_key: string;
admin_key_id: string;
algorithm: string;
key_wrap_algorithm: string;
pending_recipient?: boolean;
};
const isSender = message.user_id === user.id;
try {
// Get admin private key - supports multi-key lookup
const adminPrivateKeyJwk = getAdminPrivateKey(metadata.admin_key_id);
if (!adminPrivateKeyJwk) {
console.error(`Admin key ${metadata.admin_key_id} not found for message ${message.id}`);
errorCount++;
continue;
}
// Unwrap AES key using admin key
const rawAesKey = await unwrapWithAdminKey(
metadata.admin_wrapped_key,
adminPrivateKeyJwk
);
// Re-wrap with new public key
const newWrappedKey = await wrapForRecipient(
rawAesKey,
profile.public_key_jwk
);
// Update the appropriate field based on whether user is sender or recipient
const updatedMetadata = { ...metadata };
if (isSender) {
updatedMetadata.sender_wrapped_key = newWrappedKey;
} else {
updatedMetadata.recipient_wrapped_key = newWrappedKey;
updatedMetadata.pending_recipient = false;
}
const { error: updateError } = await supabaseAdmin
.from("messages")
.update({ keys_metadata: updatedMetadata })
.eq("id", message.id);
if (updateError) {
console.error(`Failed to update message ${message.id}:`, updateError);
errorCount++;
} else {
updatedCount++;
}
} catch (err) {
console.error(`Failed to re-encrypt message ${message.id}:`, err);
errorCount++;
}
}
// Log the key rotation event for audit purposes
console.log(`Key rotation completed for user ${user.id}: ${updatedCount} messages updated, ${errorCount} errors, hasOlderMessages=${hasOlderMessages}`);
return new Response(
JSON.stringify({
success: true,
updated: updatedCount,
errors: errorCount,
has_older_messages: hasOlderMessages,
recent_days: RECENT_DAYS,
}),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
} catch (error) {
console.error("Error:", error);
return new Response(
JSON.stringify({ error: "Internal server error" }),
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
});
/**
* Get admin private key by ID.
* Supports multi-key format: ADMIN_KEYS_JSON = { "uuid1": {...}, "uuid2": {...} }
* Falls back to single-key format: ADMIN_PRIVATE_KEY_JWK = {...}
*/
function getAdminPrivateKey(keyId: string): JsonWebKey | null {
// Try multi-key format first
const keysJson = Deno.env.get("ADMIN_KEYS_JSON");
if (keysJson) {
try {
const keys = JSON.parse(keysJson);
if (keys[keyId]) {
return keys[keyId];
}
} catch {
console.error("Failed to parse ADMIN_KEYS_JSON");
}
}
// Fall back to single-key format
const singleKeyJson = Deno.env.get("ADMIN_PRIVATE_KEY_JWK");
if (singleKeyJson) {
try {
return JSON.parse(singleKeyJson);
} catch {
console.error("Failed to parse ADMIN_PRIVATE_KEY_JWK");
}
}
return null;
}
// Crypto helper functions
async function unwrapWithAdminKey(
wrappedKeyBase64: string,
adminPrivateKeyJwk: JsonWebKey
): Promise<ArrayBuffer> {
const privateKey = await crypto.subtle.importKey(
"jwk",
adminPrivateKeyJwk,
{ name: "RSA-OAEP", hash: "SHA-256" },
false,
["decrypt"]
);
const wrappedKey = base64ToArrayBuffer(wrappedKeyBase64);
return await crypto.subtle.decrypt(
{ name: "RSA-OAEP" },
privateKey,
wrappedKey
);
}
async function wrapForRecipient(
rawAesKey: ArrayBuffer,
recipientPublicKeyJwk: JsonWebKey
): Promise<string> {
// Sanitize JWK to ensure Base64URL encoding for n and e
const sanitizedJwk = { ...recipientPublicKeyJwk };
if (sanitizedJwk.n) {
sanitizedJwk.n = toBase64Url(sanitizedJwk.n);
}
if (sanitizedJwk.e) {
sanitizedJwk.e = toBase64Url(sanitizedJwk.e);
}
const publicKey = await crypto.subtle.importKey(
"jwk",
sanitizedJwk,
{ name: "RSA-OAEP", hash: "SHA-256" },
false,
["encrypt"]
);
const wrappedKey = await crypto.subtle.encrypt(
{ name: "RSA-OAEP" },
publicKey,
rawAesKey
);
return arrayBufferToBase64(wrappedKey);
}
function toBase64Url(base64: string): string {
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
function base64ToArrayBuffer(base64: string): ArrayBuffer {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer;
}
function arrayBufferToBase64(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let binary = "";
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
@@ -0,0 +1,355 @@
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
import {
unwrapWithAdminKey,
base64ToUint8Array,
KeysMetadata,
} from "../_shared/encryption.ts";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers":
"authorization, x-client-info, apikey, content-type",
"Access-Control-Allow-Methods": "POST, OPTIONS",
};
/**
* Migration function to decrypt all E2EE v2 messages and convert to v1 plaintext.
*
* This is a one-time migration function that:
* 1. Fetches all messages with version = 2
* 2. Decrypts text content using admin key
* 3. Downloads, decrypts, and re-uploads encrypted media files
* 4. Updates messages to version = 1 with plaintext content
* 5. Clears E2EE metadata fields
*
* Requires super_admin authentication.
*/
Deno.serve(async (req) => {
if (req.method === "OPTIONS") {
return new Response("ok", { headers: corsHeaders });
}
if (req.method !== "POST") {
return json({ error: "Method not allowed" }, 405);
}
try {
const supabaseAdmin = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
);
// Authenticate and verify super_admin
const user = await getUserFromRequest(supabaseAdmin, req);
await assertSuperAdmin(supabaseAdmin, user.id);
// Parse options
const body = await req.json().catch(() => ({}));
const dryRun = body?.dryRun === true;
const batchSize = Math.min(body?.batchSize || 50, 100);
console.log(
`Starting E2EE migration (dryRun=${dryRun}, batchSize=${batchSize})`
);
// Fetch all v2 messages
const { data: messages, error: fetchError } = await supabaseAdmin
.from("messages")
.select(
"id, user_id, match_id, content, media_path, media_type, encrypted_content, encryption_iv, keys_metadata, created_at"
)
.eq("version", 2)
.order("created_at", { ascending: true })
.limit(batchSize);
if (fetchError) {
console.error("Failed to fetch messages:", fetchError);
return json({ error: "Failed to fetch messages" }, 500);
}
if (!messages || messages.length === 0) {
return json({
success: true,
message: "No v2 messages to migrate",
stats: { total: 0, text: 0, media: 0, errors: 0 },
});
}
// Get total count for progress tracking
const { count: totalRemaining } = await supabaseAdmin
.from("messages")
.select("id", { count: "exact", head: true })
.eq("version", 2);
const stats = {
total: messages.length,
text: 0,
media: 0,
errors: 0,
remaining: (totalRemaining ?? 0) - messages.length,
};
const errors: Array<{ id: string; error: string }> = [];
for (const message of messages) {
try {
const keysMetadata = message.keys_metadata as KeysMetadata | null;
const hasEncryptedContent = !!message.encrypted_content;
const hasEncryptedMedia =
message.media_path?.endsWith(".enc") ?? false;
// Get admin private key
const adminPrivateKeyJwk = getAdminPrivateKey(keysMetadata?.admin_key_id);
if (!adminPrivateKeyJwk && (hasEncryptedContent || hasEncryptedMedia)) {
throw new Error(
`Admin key ${keysMetadata?.admin_key_id} not configured`
);
}
let decryptedContent: string | null = null;
let newMediaPath: string | null = message.media_path;
// Decrypt text content
if (hasEncryptedContent && keysMetadata && adminPrivateKeyJwk) {
const encryptionIv = message.encryption_iv as string;
if (!encryptionIv) {
throw new Error("Missing encryption_iv for encrypted content");
}
const rawAesKey = await unwrapWithAdminKey(
keysMetadata.admin_wrapped_key,
adminPrivateKeyJwk
);
const aesKey = await crypto.subtle.importKey(
"raw",
rawAesKey,
{ name: "AES-GCM" },
false,
["decrypt"]
);
const plaintext = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: base64ToUint8Array(encryptionIv) },
aesKey,
base64ToUint8Array(message.encrypted_content as string)
);
decryptedContent = new TextDecoder().decode(plaintext);
stats.text++;
}
// Decrypt and re-upload media
if (hasEncryptedMedia && keysMetadata && adminPrivateKeyJwk) {
const encryptionIv = message.encryption_iv as string;
if (!encryptionIv) {
throw new Error("Missing encryption_iv for encrypted media");
}
// Download encrypted file from storage
const { data: encryptedData, error: downloadError } =
await supabaseAdmin.storage
.from("chat-media")
.download(message.media_path);
if (downloadError || !encryptedData) {
throw new Error(`Failed to download encrypted media: ${downloadError?.message}`);
}
// Decrypt the media
const rawAesKey = await unwrapWithAdminKey(
keysMetadata.admin_wrapped_key,
adminPrivateKeyJwk
);
const aesKey = await crypto.subtle.importKey(
"raw",
rawAesKey,
{ name: "AES-GCM" },
false,
["decrypt"]
);
const encryptedBytes = await encryptedData.arrayBuffer();
const decryptedBytes = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: base64ToUint8Array(encryptionIv) },
aesKey,
encryptedBytes
);
// New path without .enc extension
newMediaPath = message.media_path.replace(/\.enc$/, "");
if (!dryRun) {
// Upload decrypted file
const { error: uploadError } = await supabaseAdmin.storage
.from("chat-media")
.upload(newMediaPath, decryptedBytes, {
contentType: getContentType(message.media_type),
upsert: true,
});
if (uploadError) {
throw new Error(`Failed to upload decrypted media: ${uploadError.message}`);
}
// Delete old encrypted file
await supabaseAdmin.storage
.from("chat-media")
.remove([message.media_path]);
}
stats.media++;
}
// Update message to v1
if (!dryRun) {
const updateData: Record<string, unknown> = {
version: 1,
content: decryptedContent ?? message.content,
media_path: newMediaPath,
encrypted_content: null,
encryption_iv: null,
keys_metadata: null,
};
const { error: updateError } = await supabaseAdmin
.from("messages")
.update(updateData)
.eq("id", message.id);
if (updateError) {
throw new Error(`Failed to update message: ${updateError.message}`);
}
}
console.log(
`${dryRun ? "[DRY RUN] " : ""}Migrated message ${message.id} (text=${hasEncryptedContent}, media=${hasEncryptedMedia})`
);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
console.error(`Failed to migrate message ${message.id}:`, errorMessage);
errors.push({ id: message.id, error: errorMessage });
stats.errors++;
}
}
return json({
success: true,
dryRun,
stats,
errors: errors.length > 0 ? errors : undefined,
message:
stats.remaining > 0
? `Migrated ${stats.total - stats.errors}/${stats.total} messages. ${stats.remaining} remaining - run again to continue.`
: `Migration complete. ${stats.total - stats.errors}/${stats.total} messages migrated.`,
});
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
if (
message.includes("authorization header") ||
message.startsWith("Invalid token")
) {
return json({ error: "Unauthorized" }, 401);
}
if (message === "Access denied") {
return json({ error: "Forbidden" }, 403);
}
console.error("migrate-e2ee-to-plaintext error:", error);
return json({ error: "Internal server error" }, 500);
}
});
function json(
body: unknown,
status = 200,
extraHeaders: Record<string, string> = {}
) {
return new Response(JSON.stringify(body, null, 2), {
status,
headers: {
...corsHeaders,
"Content-Type": "application/json",
"Cache-Control": "no-store",
...extraHeaders,
},
});
}
async function getUserFromRequest(
supabaseAdmin: ReturnType<typeof createClient>,
req: Request
) {
const authHeader = req.headers.get("Authorization");
if (!authHeader) {
throw new Error("Missing authorization header");
}
const { data, error } = await supabaseAdmin.auth.getUser(
authHeader.replace("Bearer ", "")
);
if (error || !data.user) {
throw new Error(`Invalid token: ${error?.message || "No user"}`);
}
return data.user;
}
async function assertSuperAdmin(
supabaseAdmin: ReturnType<typeof createClient>,
userId: string
) {
const { data, error } = await supabaseAdmin
.from("admin_users")
.select("role")
.eq("user_id", userId)
.maybeSingle();
if (error || !data) {
throw new Error("Access denied");
}
if (data.role !== "super_admin") {
throw new Error("Access denied");
}
}
function getAdminPrivateKey(keyId?: string | null): JsonWebKey | null {
const keysJson = Deno.env.get("ADMIN_KEYS_JSON");
if (keysJson && keyId) {
try {
const keys = JSON.parse(keysJson);
if (keys[keyId]) {
return keys[keyId];
}
} catch {
console.error("Failed to parse ADMIN_KEYS_JSON");
}
}
const singleKeyJson = Deno.env.get("ADMIN_PRIVATE_KEY_JWK");
if (singleKeyJson) {
try {
return JSON.parse(singleKeyJson);
} catch {
console.error("Failed to parse ADMIN_PRIVATE_KEY_JWK");
}
}
return null;
}
function getContentType(mediaType: string | null): string {
switch (mediaType) {
case "video":
return "video/mp4";
case "image":
return "image/jpeg";
default:
return "application/octet-stream";
}
}
@@ -1,282 +0,0 @@
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
};
/**
* Re-encrypts pending messages when a partner generates their encryption keys.
*
* Called when a user uploads their public key and their partner already
* sent messages with pending_recipient = true.
*/
Deno.serve(async (req) => {
// Handle CORS preflight
if (req.method === "OPTIONS") {
return new Response("ok", { headers: corsHeaders });
}
try {
const supabaseAdmin = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
);
// Get user from JWT
const authHeader = req.headers.get("Authorization");
if (!authHeader) {
return new Response(
JSON.stringify({ error: "Missing authorization header" }),
{ status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
const { data: { user }, error: authError } = await supabaseAdmin.auth.getUser(
authHeader.replace("Bearer ", "")
);
if (authError || !user) {
return new Response(
JSON.stringify({ error: `Invalid token: ${authError?.message || 'No user found'}` }),
{ status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
// Get user's couple_id and public key
const { data: profile, error: profileError } = await supabaseAdmin
.from("profiles")
.select("couple_id, public_key_jwk")
.eq("id", user.id)
.maybeSingle();
if (profileError || !profile) {
return new Response(
JSON.stringify({ error: "Profile not found" }),
{ status: 404, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
if (!profile.couple_id) {
return new Response(
JSON.stringify({ success: true, updated: 0, reason: "No couple" }),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
if (!profile.public_key_jwk) {
return new Response(
JSON.stringify({ error: "User has no public key" }),
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
// Find matches for this couple
const { data: matches } = await supabaseAdmin
.from("matches")
.select("id")
.eq("couple_id", profile.couple_id);
const matchIds = matches?.map(m => m.id) || [];
if (matchIds.length === 0) {
return new Response(
JSON.stringify({ success: true, updated: 0, reason: "No matches found" }),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
// Find v2 messages where this user is the recipient but doesn't have their wrapped key yet
const { data: messages, error: messagesError } = await supabaseAdmin
.from("messages")
.select("id, user_id, keys_metadata")
.eq("version", 2)
.in("match_id", matchIds)
.neq("user_id", user.id); // Messages sent TO this user (they are recipient)
if (messagesError) {
console.error("Error fetching messages:", messagesError);
return new Response(
JSON.stringify({ error: "Failed to fetch messages" }),
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
let updatedCount = 0;
for (const message of messages || []) {
const metadata = message.keys_metadata as {
sender_wrapped_key: string;
recipient_wrapped_key?: string;
admin_wrapped_key: string;
admin_key_id: string;
algorithm: string;
key_wrap_algorithm: string;
pending_recipient?: boolean;
};
// Only process if pending_recipient OR missing recipient key
if (!metadata.pending_recipient && metadata.recipient_wrapped_key) {
continue;
}
try {
// Get admin private key - supports multi-key lookup
const adminPrivateKeyJwk = getAdminPrivateKey(metadata.admin_key_id);
if (!adminPrivateKeyJwk) {
console.error(`Admin key ${metadata.admin_key_id} not found for message ${message.id}`);
continue;
}
// Unwrap AES key using admin key
const rawAesKey = await unwrapWithAdminKey(
metadata.admin_wrapped_key,
adminPrivateKeyJwk
);
// Re-wrap for recipient
const recipientWrappedKey = await wrapForRecipient(
rawAesKey,
profile.public_key_jwk
);
// Update message
const { error: updateError } = await supabaseAdmin
.from("messages")
.update({
keys_metadata: {
...metadata,
recipient_wrapped_key: recipientWrappedKey,
pending_recipient: false,
}
})
.eq("id", message.id);
if (updateError) {
console.error(`Failed to update message ${message.id}:`, updateError);
} else {
updatedCount++;
}
} catch (err) {
console.error(`Failed to re-encrypt message ${message.id}:`, err);
}
}
return new Response(
JSON.stringify({ success: true, updated: updatedCount }),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
} catch (error) {
console.error("Error:", error);
return new Response(
JSON.stringify({ error: "Internal server error" }),
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
});
/**
* Get admin private key by ID.
* Supports multi-key format: ADMIN_KEYS_JSON = { "uuid1": {...}, "uuid2": {...} }
* Falls back to single-key format: ADMIN_PRIVATE_KEY_JWK = {...}
*/
function getAdminPrivateKey(keyId: string): JsonWebKey | null {
// Try multi-key format first
const keysJson = Deno.env.get("ADMIN_KEYS_JSON");
if (keysJson) {
try {
const keys = JSON.parse(keysJson);
if (keys[keyId]) {
return keys[keyId];
}
} catch {
console.error("Failed to parse ADMIN_KEYS_JSON");
}
}
// Fall back to single-key format
const singleKeyJson = Deno.env.get("ADMIN_PRIVATE_KEY_JWK");
if (singleKeyJson) {
try {
return JSON.parse(singleKeyJson);
} catch {
console.error("Failed to parse ADMIN_PRIVATE_KEY_JWK");
}
}
return null;
}
// Crypto helper functions
async function unwrapWithAdminKey(
wrappedKeyBase64: string,
adminPrivateKeyJwk: JsonWebKey
): Promise<ArrayBuffer> {
const privateKey = await crypto.subtle.importKey(
"jwk",
adminPrivateKeyJwk,
{ name: "RSA-OAEP", hash: "SHA-256" },
false,
["decrypt"]
);
const wrappedKey = base64ToArrayBuffer(wrappedKeyBase64);
return await crypto.subtle.decrypt(
{ name: "RSA-OAEP" },
privateKey,
wrappedKey
);
}
async function wrapForRecipient(
rawAesKey: ArrayBuffer,
recipientPublicKeyJwk: JsonWebKey
): Promise<string> {
// Sanitize JWK to ensure Base64URL encoding for n and e
const sanitizedJwk = { ...recipientPublicKeyJwk };
if (sanitizedJwk.n) {
sanitizedJwk.n = toBase64Url(sanitizedJwk.n);
}
if (sanitizedJwk.e) {
sanitizedJwk.e = toBase64Url(sanitizedJwk.e);
}
const publicKey = await crypto.subtle.importKey(
"jwk",
sanitizedJwk,
{ name: "RSA-OAEP", hash: "SHA-256" },
false,
["encrypt"]
);
const wrappedKey = await crypto.subtle.encrypt(
{ name: "RSA-OAEP" },
publicKey,
rawAesKey
);
return arrayBufferToBase64(wrappedKey);
}
function toBase64Url(base64: string): string {
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
function base64ToArrayBuffer(base64: string): ArrayBuffer {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer;
}
function arrayBufferToBase64(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let binary = "";
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
@@ -1,289 +0,0 @@
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
};
/**
* Repairs a message encrypted with a stale recipient key.
*
* When a message was encrypted while offline and the recipient's key changed
* before sync, the recipient can't decrypt. This function uses the admin key
* to re-wrap the AES key for the recipient's current public key.
*/
Deno.serve(async (req) => {
// Handle CORS preflight
if (req.method === "OPTIONS") {
return new Response("ok", { headers: corsHeaders });
}
try {
const supabaseAdmin = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
);
// Get user from JWT
const authHeader = req.headers.get("Authorization");
if (!authHeader) {
return new Response(
JSON.stringify({ error: "Missing authorization header" }),
{ status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
const { data: { user }, error: authError } = await supabaseAdmin.auth.getUser(
authHeader.replace("Bearer ", "")
);
if (authError || !user) {
return new Response(
JSON.stringify({ error: `Invalid token: ${authError?.message || 'No user found'}` }),
{ status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
// Parse request body
const { message_id } = await req.json();
if (!message_id) {
return new Response(
JSON.stringify({ error: "Missing message_id" }),
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
// Get user's current public key
const { data: profile, error: profileError } = await supabaseAdmin
.from("profiles")
.select("id, couple_id, public_key_jwk")
.eq("id", user.id)
.maybeSingle();
if (profileError || !profile) {
return new Response(
JSON.stringify({ error: "Profile not found" }),
{ status: 404, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
if (!profile.public_key_jwk) {
return new Response(
JSON.stringify({ error: "User has no public key" }),
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
// Get the message
const { data: message, error: messageError } = await supabaseAdmin
.from("messages")
.select("id, user_id, match_id, version, keys_metadata")
.eq("id", message_id)
.maybeSingle();
if (messageError || !message) {
return new Response(
JSON.stringify({ error: "Message not found" }),
{ status: 404, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
// Verify this is a v2 message
if (message.version !== 2) {
return new Response(
JSON.stringify({ error: "Message is not encrypted (v1)" }),
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
// Verify user has access to this message (via match -> couple)
const { data: match } = await supabaseAdmin
.from("matches")
.select("couple_id")
.eq("id", message.match_id)
.maybeSingle();
if (!match || match.couple_id !== profile.couple_id) {
return new Response(
JSON.stringify({ error: "Access denied" }),
{ status: 403, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
// Determine if user is sender or recipient
const isSender = message.user_id === user.id;
const metadata = message.keys_metadata as {
sender_wrapped_key: string;
recipient_wrapped_key?: string;
admin_wrapped_key: string;
admin_key_id: string;
algorithm: string;
key_wrap_algorithm: string;
pending_recipient?: boolean;
};
// Get admin private key - supports multi-key lookup
const adminPrivateKeyJwk = getAdminPrivateKey(metadata.admin_key_id);
if (!adminPrivateKeyJwk) {
return new Response(
JSON.stringify({ error: "Admin key not found for this message" }),
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
// Unwrap AES key using admin key
const rawAesKey = await unwrapWithAdminKey(
metadata.admin_wrapped_key,
adminPrivateKeyJwk
);
// Re-wrap with user's current public key
const newWrappedKey = await wrapForRecipient(
rawAesKey,
profile.public_key_jwk
);
// Update the appropriate field
const updatedMetadata = { ...metadata };
if (isSender) {
updatedMetadata.sender_wrapped_key = newWrappedKey;
} else {
updatedMetadata.recipient_wrapped_key = newWrappedKey;
updatedMetadata.pending_recipient = false;
}
const { error: updateError } = await supabaseAdmin
.from("messages")
.update({ keys_metadata: updatedMetadata })
.eq("id", message.id);
if (updateError) {
console.error(`Failed to update message ${message.id}:`, updateError);
return new Response(
JSON.stringify({ error: "Failed to update message" }),
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
console.log(`Repaired stale key for message ${message.id}, user ${user.id} (${isSender ? 'sender' : 'recipient'})`);
return new Response(
JSON.stringify({ success: true, message_id: message.id }),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
} catch (error) {
console.error("Error:", error);
return new Response(
JSON.stringify({ error: "Internal server error" }),
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
});
/**
* Get admin private key by ID.
* Supports multi-key format: ADMIN_KEYS_JSON = { "uuid1": {...}, "uuid2": {...} }
* Falls back to single-key format: ADMIN_PRIVATE_KEY_JWK = {...}
*/
function getAdminPrivateKey(keyId: string): JsonWebKey | null {
// Try multi-key format first
const keysJson = Deno.env.get("ADMIN_KEYS_JSON");
if (keysJson) {
try {
const keys = JSON.parse(keysJson);
if (keys[keyId]) {
return keys[keyId];
}
} catch {
console.error("Failed to parse ADMIN_KEYS_JSON");
}
}
// Fall back to single-key format
const singleKeyJson = Deno.env.get("ADMIN_PRIVATE_KEY_JWK");
if (singleKeyJson) {
try {
return JSON.parse(singleKeyJson);
} catch {
console.error("Failed to parse ADMIN_PRIVATE_KEY_JWK");
}
}
return null;
}
// Crypto helper functions
async function unwrapWithAdminKey(
wrappedKeyBase64: string,
adminPrivateKeyJwk: JsonWebKey
): Promise<ArrayBuffer> {
const privateKey = await crypto.subtle.importKey(
"jwk",
adminPrivateKeyJwk,
{ name: "RSA-OAEP", hash: "SHA-256" },
false,
["decrypt"]
);
const wrappedKey = base64ToArrayBuffer(wrappedKeyBase64);
return await crypto.subtle.decrypt(
{ name: "RSA-OAEP" },
privateKey,
wrappedKey
);
}
async function wrapForRecipient(
rawAesKey: ArrayBuffer,
recipientPublicKeyJwk: JsonWebKey
): Promise<string> {
// Sanitize JWK to ensure Base64URL encoding for n and e
const sanitizedJwk = { ...recipientPublicKeyJwk };
if (sanitizedJwk.n) {
sanitizedJwk.n = toBase64Url(sanitizedJwk.n);
}
if (sanitizedJwk.e) {
sanitizedJwk.e = toBase64Url(sanitizedJwk.e);
}
const publicKey = await crypto.subtle.importKey(
"jwk",
sanitizedJwk,
{ name: "RSA-OAEP", hash: "SHA-256" },
false,
["encrypt"]
);
const wrappedKey = await crypto.subtle.encrypt(
{ name: "RSA-OAEP" },
publicKey,
rawAesKey
);
return arrayBufferToBase64(wrappedKey);
}
function toBase64Url(base64: string): string {
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
function base64ToArrayBuffer(base64: string): ArrayBuffer {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer;
}
function arrayBufferToBase64(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let binary = "";
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
@@ -0,0 +1,173 @@
-- Filter individual questions by user's max_intensity preference
-- This fixes a bug where users would see intense questions despite having a gentle comfort zone
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;
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 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;
v_has_premium := public.has_premium_access(v_user_id);
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 user is too far ahead of partner (answer gap threshold exceeded), returns empty.';
@@ -0,0 +1,377 @@
-- Dares Feature Migration
-- Creates dare_packs, dares, sent_dares, dare_messages tables
-- with RLS policies, triggers, and stats functions
-- ============================================================================
-- CUSTOM TYPES
-- ============================================================================
CREATE TYPE dare_status AS ENUM (
'pending', -- Sent, waiting for recipient to accept/decline
'active', -- Recipient accepted, timer started (if applicable)
'completed', -- Sender marked as complete
'expired', -- Time ran out (only if had deadline)
'declined', -- Recipient declined
'cancelled' -- Sender cancelled before completion
);
-- ============================================================================
-- TABLES
-- ============================================================================
-- Dare packs table (mirrors question_packs structure)
CREATE TABLE dare_packs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
description TEXT,
icon TEXT,
is_premium BOOLEAN NOT NULL DEFAULT false,
is_public BOOLEAN NOT NULL DEFAULT true,
is_explicit BOOLEAN NOT NULL DEFAULT false,
sort_order INTEGER NOT NULL DEFAULT 0,
category_id UUID REFERENCES categories(id) ON DELETE SET NULL,
min_intensity INTEGER,
max_intensity INTEGER,
avg_intensity NUMERIC(3,2),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
COMMENT ON TABLE dare_packs IS 'Collections of dares organized by theme or intensity level';
COMMENT ON COLUMN dare_packs.min_intensity IS 'Auto-calculated minimum intensity from dares in this pack';
COMMENT ON COLUMN dare_packs.max_intensity IS 'Auto-calculated maximum intensity from dares in this pack';
COMMENT ON COLUMN dare_packs.avg_intensity IS 'Auto-calculated average intensity from dares in this pack';
-- Create indexes for dare_packs
CREATE INDEX idx_dare_packs_sort_order ON dare_packs(sort_order);
CREATE INDEX idx_dare_packs_visibility ON dare_packs(is_public, is_premium);
CREATE INDEX idx_dare_packs_category ON dare_packs(category_id);
-- Dares table (individual dare content)
CREATE TABLE dares (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
pack_id UUID NOT NULL REFERENCES dare_packs(id) ON DELETE CASCADE,
text TEXT NOT NULL,
intensity INTEGER NOT NULL DEFAULT 1 CHECK (intensity >= 1 AND intensity <= 5),
suggested_duration_hours INTEGER,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
COMMENT ON TABLE dares IS 'Individual dares within dare packs';
COMMENT ON COLUMN dares.intensity IS 'Difficulty/spiciness level from 1 (mild) to 5 (intense)';
COMMENT ON COLUMN dares.suggested_duration_hours IS 'Suggested time to complete the dare in hours (null = no suggestion)';
-- Create indexes for dares
CREATE INDEX idx_dares_pack_id ON dares(pack_id);
CREATE INDEX idx_dares_intensity ON dares(intensity);
-- Sent dares table (tracks dare instances between partners)
CREATE TABLE sent_dares (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
couple_id UUID NOT NULL REFERENCES couples(id) ON DELETE CASCADE,
dare_id UUID REFERENCES dares(id) ON DELETE SET NULL,
custom_dare_text TEXT,
custom_dare_intensity INTEGER CHECK (custom_dare_intensity IS NULL OR (custom_dare_intensity >= 1 AND custom_dare_intensity <= 5)),
sender_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
recipient_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
status dare_status NOT NULL DEFAULT 'pending',
sent_at TIMESTAMPTZ NOT NULL DEFAULT now(),
accepted_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
sender_notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT dare_or_custom CHECK (dare_id IS NOT NULL OR custom_dare_text IS NOT NULL)
);
COMMENT ON TABLE sent_dares IS 'Dare instances sent between partners in a couple';
COMMENT ON COLUMN sent_dares.dare_id IS 'Reference to the dare from a pack (null for custom dares)';
COMMENT ON COLUMN sent_dares.custom_dare_text IS 'Text for custom dares created by premium users';
COMMENT ON COLUMN sent_dares.custom_dare_intensity IS 'Intensity for custom dares (1-5)';
COMMENT ON COLUMN sent_dares.expires_at IS 'Deadline for completing the dare (null = no time limit)';
COMMENT ON COLUMN sent_dares.sender_notes IS 'Optional message from sender when sending the dare';
-- Create indexes for sent_dares
CREATE INDEX idx_sent_dares_couple_id ON sent_dares(couple_id);
CREATE INDEX idx_sent_dares_sender_id ON sent_dares(sender_id);
CREATE INDEX idx_sent_dares_recipient_id ON sent_dares(recipient_id);
CREATE INDEX idx_sent_dares_status ON sent_dares(status);
CREATE INDEX idx_sent_dares_expires_at ON sent_dares(expires_at) WHERE expires_at IS NOT NULL AND status = 'active';
-- Dare messages table (chat for each dare)
CREATE TABLE dare_messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
sent_dare_id UUID NOT NULL REFERENCES sent_dares(id) ON DELETE CASCADE,
sender_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
content TEXT NOT NULL,
read_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
COMMENT ON TABLE dare_messages IS 'Chat messages associated with each sent dare';
-- Create indexes for dare_messages
CREATE INDEX idx_dare_messages_sent_dare_id ON dare_messages(sent_dare_id);
CREATE INDEX idx_dare_messages_created_at ON dare_messages(sent_dare_id, created_at);
-- ============================================================================
-- TRIGGERS
-- ============================================================================
-- Trigger function to auto-calculate dare pack intensity stats
CREATE OR REPLACE FUNCTION update_dare_pack_intensity_stats()
RETURNS TRIGGER AS $$
DECLARE
target_pack_id UUID;
BEGIN
-- Get the pack_id to update
target_pack_id := COALESCE(NEW.pack_id, OLD.pack_id);
-- Update the dare pack stats
UPDATE dare_packs
SET
min_intensity = (SELECT MIN(intensity) FROM dares WHERE pack_id = target_pack_id),
max_intensity = (SELECT MAX(intensity) FROM dares WHERE pack_id = target_pack_id),
avg_intensity = (SELECT ROUND(AVG(intensity)::numeric, 2) FROM dares WHERE pack_id = target_pack_id)
WHERE id = target_pack_id;
RETURN COALESCE(NEW, OLD);
END;
$$ LANGUAGE plpgsql;
-- Create trigger on dares table
CREATE TRIGGER dare_intensity_stats_trigger
AFTER INSERT OR UPDATE OR DELETE ON dares
FOR EACH ROW EXECUTE FUNCTION update_dare_pack_intensity_stats();
-- ============================================================================
-- STATS FUNCTIONS
-- ============================================================================
-- Function to get dare stats for a specific user
CREATE OR REPLACE FUNCTION get_user_dare_stats(p_user_id UUID)
RETURNS TABLE (
dares_sent_count BIGINT,
dares_received_count BIGINT,
dares_completed_as_sender BIGINT,
dares_completed_as_recipient BIGINT
) AS $$
BEGIN
RETURN QUERY
SELECT
(SELECT COUNT(*) FROM sent_dares WHERE sender_id = p_user_id)::BIGINT,
(SELECT COUNT(*) FROM sent_dares WHERE recipient_id = p_user_id)::BIGINT,
(SELECT COUNT(*) FROM sent_dares WHERE sender_id = p_user_id AND status = 'completed')::BIGINT,
(SELECT COUNT(*) FROM sent_dares WHERE recipient_id = p_user_id AND status = 'completed')::BIGINT;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Function to get dare stats for a couple
CREATE OR REPLACE FUNCTION get_couple_dare_stats(p_couple_id UUID)
RETURNS TABLE (
total_dares_sent BIGINT,
total_dares_completed BIGINT,
total_dares_active BIGINT
) AS $$
BEGIN
RETURN QUERY
SELECT
(SELECT COUNT(*) FROM sent_dares WHERE couple_id = p_couple_id)::BIGINT,
(SELECT COUNT(*) FROM sent_dares WHERE couple_id = p_couple_id AND status = 'completed')::BIGINT,
(SELECT COUNT(*) FROM sent_dares WHERE couple_id = p_couple_id AND status = 'active')::BIGINT;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- ============================================================================
-- ROW LEVEL SECURITY
-- ============================================================================
-- Enable RLS on all tables
ALTER TABLE dare_packs ENABLE ROW LEVEL SECURITY;
ALTER TABLE dares ENABLE ROW LEVEL SECURITY;
ALTER TABLE sent_dares ENABLE ROW LEVEL SECURITY;
ALTER TABLE dare_messages ENABLE ROW LEVEL SECURITY;
-- -------------------------
-- dare_packs policies
-- -------------------------
-- Anyone can view public dare packs
CREATE POLICY "Public dare packs are viewable by everyone"
ON dare_packs FOR SELECT
USING (is_public = true);
-- Admins can view all dare packs
CREATE POLICY "Admins can view all dare packs"
ON dare_packs FOR SELECT
USING (
EXISTS (
SELECT 1 FROM admin_users WHERE user_id = auth.uid()
)
);
-- Admins can insert dare packs
CREATE POLICY "Admins can insert dare packs"
ON dare_packs FOR INSERT
WITH CHECK (
EXISTS (
SELECT 1 FROM admin_users WHERE user_id = auth.uid()
)
);
-- Admins can update dare packs
CREATE POLICY "Admins can update dare packs"
ON dare_packs FOR UPDATE
USING (
EXISTS (
SELECT 1 FROM admin_users WHERE user_id = auth.uid()
)
);
-- Admins can delete dare packs
CREATE POLICY "Admins can delete dare packs"
ON dare_packs FOR DELETE
USING (
EXISTS (
SELECT 1 FROM admin_users WHERE user_id = auth.uid()
)
);
-- -------------------------
-- dares policies
-- -------------------------
-- Users can view dares in accessible packs (public and free, or public and user is premium)
CREATE POLICY "Users can view dares in accessible packs"
ON dares FOR SELECT
USING (
EXISTS (
SELECT 1 FROM dare_packs dp
WHERE dp.id = dares.pack_id
AND dp.is_public = true
AND (
dp.is_premium = false
OR EXISTS (SELECT 1 FROM profiles WHERE id = auth.uid() AND is_premium = true)
)
)
);
-- Admins can view all dares
CREATE POLICY "Admins can view all dares"
ON dares FOR SELECT
USING (
EXISTS (
SELECT 1 FROM admin_users WHERE user_id = auth.uid()
)
);
-- Admins can insert dares
CREATE POLICY "Admins can insert dares"
ON dares FOR INSERT
WITH CHECK (
EXISTS (
SELECT 1 FROM admin_users WHERE user_id = auth.uid()
)
);
-- Admins can update dares
CREATE POLICY "Admins can update dares"
ON dares FOR UPDATE
USING (
EXISTS (
SELECT 1 FROM admin_users WHERE user_id = auth.uid()
)
);
-- Admins can delete dares
CREATE POLICY "Admins can delete dares"
ON dares FOR DELETE
USING (
EXISTS (
SELECT 1 FROM admin_users WHERE user_id = auth.uid()
)
);
-- -------------------------
-- sent_dares policies
-- -------------------------
-- Users can view sent dares in their couple
CREATE POLICY "Users can view their couple's sent dares"
ON sent_dares FOR SELECT
USING (couple_id = get_auth_user_couple_id());
-- Users can insert sent dares (sender must be the authenticated user)
CREATE POLICY "Users can send dares to their partner"
ON sent_dares FOR INSERT
WITH CHECK (
sender_id = auth.uid()
AND couple_id = get_auth_user_couple_id()
);
-- Users can update sent dares in their couple
CREATE POLICY "Users can update their couple's sent dares"
ON sent_dares FOR UPDATE
USING (couple_id = get_auth_user_couple_id());
-- Admins can view all sent dares
CREATE POLICY "Admins can view all sent dares"
ON sent_dares FOR SELECT
USING (
EXISTS (
SELECT 1 FROM admin_users WHERE user_id = auth.uid()
)
);
-- -------------------------
-- dare_messages policies
-- -------------------------
-- Users can view messages for their couple's dares
CREATE POLICY "Users can view messages for their dares"
ON dare_messages FOR SELECT
USING (
EXISTS (
SELECT 1 FROM sent_dares sd
WHERE sd.id = dare_messages.sent_dare_id
AND sd.couple_id = get_auth_user_couple_id()
)
);
-- Users can insert messages for their couple's dares
CREATE POLICY "Users can send messages for their dares"
ON dare_messages FOR INSERT
WITH CHECK (
sender_id = auth.uid()
AND EXISTS (
SELECT 1 FROM sent_dares sd
WHERE sd.id = dare_messages.sent_dare_id
AND sd.couple_id = get_auth_user_couple_id()
)
);
-- Users can update messages in their dares (for read_at)
CREATE POLICY "Users can update message read status"
ON dare_messages FOR UPDATE
USING (
EXISTS (
SELECT 1 FROM sent_dares sd
WHERE sd.id = dare_messages.sent_dare_id
AND sd.couple_id = get_auth_user_couple_id()
)
);
-- Admins can view all dare messages
CREATE POLICY "Admins can view all dare messages"
ON dare_messages FOR SELECT
USING (
EXISTS (
SELECT 1 FROM admin_users WHERE user_id = auth.uid()
)
);
-- ============================================================================
-- REALTIME
-- ============================================================================
-- Add tables to realtime publication for live updates
ALTER PUBLICATION supabase_realtime ADD TABLE sent_dares;
ALTER PUBLICATION supabase_realtime ADD TABLE dare_messages;
+3 -2
View File
@@ -3,5 +3,6 @@
NEXT_PUBLIC_POSTHOG_KEY=phc_your_project_key_here NEXT_PUBLIC_POSTHOG_KEY=phc_your_project_key_here
NEXT_PUBLIC_POSTHOG_HOST=https://eu.i.posthog.com NEXT_PUBLIC_POSTHOG_HOST=https://eu.i.posthog.com
# Supabase # Supabase
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co # Production: https://ckjcrkjpmhqhiucifukx.supabase.co
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
+8 -6
View File
@@ -3,12 +3,14 @@ FROM node:20-alpine AS builder
WORKDIR /app WORKDIR /app
# Build args for Next.js public env vars (inlined at build time) # Build args for Next.js public env vars (inlined at build time)
ARG NEXT_PUBLIC_POSTHOG_KEY ARG NEXT_PUBLIC_POSTHOG_KEY
ARG NEXT_PUBLIC_POSTHOG_HOST=https://eu.i.posthog.com ARG NEXT_PUBLIC_POSTHOG_HOST=https://eu.i.posthog.com
ARG NEXT_PUBLIC_SUPABASE_URL=https://ckjcrkjpmhqhiucifukx.supabase.co
ENV NEXT_PUBLIC_POSTHOG_KEY=$NEXT_PUBLIC_POSTHOG_KEY
ENV NEXT_PUBLIC_POSTHOG_HOST=$NEXT_PUBLIC_POSTHOG_HOST ENV NEXT_PUBLIC_POSTHOG_KEY=$NEXT_PUBLIC_POSTHOG_KEY
ENV NEXT_PUBLIC_POSTHOG_HOST=$NEXT_PUBLIC_POSTHOG_HOST
ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL
# Install dependencies # Install dependencies
COPY package*.json ./ COPY package*.json ./
+4 -3
View File
@@ -5,9 +5,10 @@ services:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
args: args:
- NEXT_PUBLIC_POSTHOG_KEY=${NEXT_PUBLIC_POSTHOG_KEY} - NEXT_PUBLIC_POSTHOG_KEY=${NEXT_PUBLIC_POSTHOG_KEY}
- NEXT_PUBLIC_POSTHOG_HOST=${NEXT_PUBLIC_POSTHOG_HOST:-https://eu.i.posthog.com} - NEXT_PUBLIC_POSTHOG_HOST=${NEXT_PUBLIC_POSTHOG_HOST:-https://eu.i.posthog.com}
- NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL:-https://ckjcrkjpmhqhiucifukx.supabase.co}
environment: environment:
- NODE_ENV=production - NODE_ENV=production
- NEXT_TELEMETRY_DISABLED=1 - NEXT_TELEMETRY_DISABLED=1
+625
View File
@@ -0,0 +1,625 @@
# Dares Feature - Architecture & Implementation Plan
> **Status**: Phase 1 Complete (Database + Admin Portal)
> **Created**: 2026-01-08
> **Updated**: 2026-01-08
> **Target**: Schema + Admin Portal (Phase 1 - DONE), Mobile App (Phase 2 - Later)
## Implementation Summary
### Completed (Phase 1)
| Component | Files | Status |
|-----------|-------|--------|
| Database Migration | `apps/supabase/migrations/20260109000000_create_dares_feature.sql` | Applied to prod |
| Dare Packs Page | `apps/admin/src/pages/content/DarePacksPage.tsx` | Complete |
| Dares Page | `apps/admin/src/pages/content/DaresPage.tsx` | Complete |
| Routes | `apps/admin/src/App.tsx` | Updated |
| Sidebar | `apps/admin/src/components/layout/Sidebar.tsx` | Updated |
### Database Tables Created
- `dare_packs` - Collections of dares (0 rows)
- `dares` - Individual dare content (0 rows)
- `sent_dares` - Dare instances between partners (0 rows)
- `dare_messages` - Chat messages for dares (0 rows)
- `dare_status` enum type with states: pending, active, completed, expired, declined, cancelled
### Admin Routes Added
- `/dare-packs` - List all dare packs
- `/dare-packs/:packId/dares` - List dares in a pack
## Overview
The Dares feature allows users to send challenges/dares to their partner from curated packs. Partners can accept or decline dares, and the sender marks them as complete. A scoreboard tracks total completed dares per user/couple.
### Key Decisions
| Aspect | Decision |
|--------|----------|
| Dare Selection | User browses list, picks dare to send |
| Decline | Recipient can decline (no negative impact) |
| Cancel | Sender can cancel before completion |
| Timeframes | Pick from preset list + "no time limit" option |
| Scoreboard | Simple count of completed dares |
| Custom Dares | Premium users can create their own |
| Gender Filtering | Not needed (user chooses which dare to send) |
| Premium Model | Same as question packs (free/premium packs) |
| Chat | Auto-create chat thread when dare is sent |
| Completion | Sender marks dare as complete |
---
## Database Schema
### New Enum Types
```sql
CREATE TYPE dare_status AS ENUM (
'pending', -- Sent, waiting for recipient to accept/decline
'active', -- Recipient accepted, timer started (if applicable)
'completed', -- Sender marked as complete
'expired', -- Time ran out (only if had deadline)
'declined', -- Recipient declined
'cancelled' -- Sender cancelled before completion
);
```
### Table: `dare_packs`
Mirrors `question_packs` structure for consistency.
| Column | Type | Nullable | Default | Description |
|--------|------|----------|---------|-------------|
| `id` | UUID | NO | `gen_random_uuid()` | Primary key |
| `name` | TEXT | NO | | Pack name |
| `description` | TEXT | YES | | Pack description |
| `icon` | TEXT | YES | | Emoji icon |
| `is_premium` | BOOLEAN | NO | `false` | Requires premium access |
| `is_public` | BOOLEAN | NO | `true` | Visible to users |
| `is_explicit` | BOOLEAN | NO | `false` | Contains explicit content |
| `sort_order` | INTEGER | NO | `0` | Display ordering |
| `category_id` | UUID | YES | | FK to `categories` (optional) |
| `min_intensity` | INTEGER | YES | | Auto-calculated from dares |
| `max_intensity` | INTEGER | YES | | Auto-calculated from dares |
| `avg_intensity` | NUMERIC(3,2) | YES | | Auto-calculated from dares |
| `created_at` | TIMESTAMPTZ | NO | `now()` | Creation timestamp |
**Indexes:**
- Primary key on `id`
- Index on `sort_order` for ordering
- Index on `is_public, is_premium` for filtering
### Table: `dares`
Individual dare content within packs.
| Column | Type | Nullable | Default | Description |
|--------|------|----------|---------|-------------|
| `id` | UUID | NO | `gen_random_uuid()` | Primary key |
| `pack_id` | UUID | NO | | FK to `dare_packs` (CASCADE delete) |
| `text` | TEXT | NO | | Dare description/instruction |
| `intensity` | INTEGER | NO | `1` | 1-5 scale (CHECK constraint) |
| `suggested_duration_hours` | INTEGER | YES | | Suggested timeframe in hours |
| `created_at` | TIMESTAMPTZ | NO | `now()` | Creation timestamp |
**Constraints:**
- `CHECK (intensity >= 1 AND intensity <= 5)`
- Foreign key to `dare_packs` with `ON DELETE CASCADE`
**Indexes:**
- Primary key on `id`
- Index on `pack_id` for lookups
### Table: `sent_dares`
Tracks dare instances sent between partners.
| Column | Type | Nullable | Default | Description |
|--------|------|----------|---------|-------------|
| `id` | UUID | NO | `gen_random_uuid()` | Primary key |
| `couple_id` | UUID | NO | | FK to `couples` (CASCADE delete) |
| `dare_id` | UUID | YES | | FK to `dares` (null for custom dares) |
| `custom_dare_text` | TEXT | YES | | Text for custom dares |
| `custom_dare_intensity` | INTEGER | YES | | Intensity for custom dares (1-5) |
| `sender_id` | UUID | NO | | FK to `profiles` |
| `recipient_id` | UUID | NO | | FK to `profiles` |
| `status` | `dare_status` | NO | `'pending'` | Current status |
| `sent_at` | TIMESTAMPTZ | NO | `now()` | When dare was sent |
| `accepted_at` | TIMESTAMPTZ | YES | | When recipient accepted |
| `expires_at` | TIMESTAMPTZ | YES | | Deadline (null = no limit) |
| `completed_at` | TIMESTAMPTZ | YES | | When marked complete |
| `sender_notes` | TEXT | YES | | Optional message from sender |
| `created_at` | TIMESTAMPTZ | NO | `now()` | Creation timestamp |
**Constraints:**
- `CHECK (custom_dare_intensity IS NULL OR (custom_dare_intensity >= 1 AND custom_dare_intensity <= 5))`
- `CHECK ((dare_id IS NOT NULL) OR (custom_dare_text IS NOT NULL))` -- Must have either a dare_id or custom text
- Foreign keys with appropriate cascade behavior
**Indexes:**
- Primary key on `id`
- Index on `couple_id` for couple lookups
- Index on `sender_id` for sender stats
- Index on `recipient_id` for recipient stats
- Index on `status` for filtering active dares
- Index on `expires_at` for expiry job
### Table: `dare_messages`
Chat messages for each dare instance.
| Column | Type | Nullable | Default | Description |
|--------|------|----------|---------|-------------|
| `id` | UUID | NO | `gen_random_uuid()` | Primary key |
| `sent_dare_id` | UUID | NO | | FK to `sent_dares` (CASCADE delete) |
| `sender_id` | UUID | NO | | FK to `profiles` |
| `content` | TEXT | NO | | Message content |
| `read_at` | TIMESTAMPTZ | YES | | When recipient read message |
| `created_at` | TIMESTAMPTZ | NO | `now()` | Creation timestamp |
**Indexes:**
- Primary key on `id`
- Index on `sent_dare_id` for message lookups
- Index on `created_at` for ordering
---
## Database Functions & Triggers
### 1. Intensity Stats Trigger
Auto-calculate `min_intensity`, `max_intensity`, `avg_intensity` on `dare_packs` when dares are inserted/updated/deleted.
```sql
CREATE OR REPLACE FUNCTION update_dare_pack_intensity_stats()
RETURNS TRIGGER AS $$
BEGIN
UPDATE dare_packs
SET
min_intensity = (SELECT MIN(intensity) FROM dares WHERE pack_id = COALESCE(NEW.pack_id, OLD.pack_id)),
max_intensity = (SELECT MAX(intensity) FROM dares WHERE pack_id = COALESCE(NEW.pack_id, OLD.pack_id)),
avg_intensity = (SELECT ROUND(AVG(intensity)::numeric, 2) FROM dares WHERE pack_id = COALESCE(NEW.pack_id, OLD.pack_id))
WHERE id = COALESCE(NEW.pack_id, OLD.pack_id);
RETURN COALESCE(NEW, OLD);
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER dare_intensity_stats_trigger
AFTER INSERT OR UPDATE OR DELETE ON dares
FOR EACH ROW EXECUTE FUNCTION update_dare_pack_intensity_stats();
```
### 2. User Dare Stats Function
```sql
CREATE OR REPLACE FUNCTION get_user_dare_stats(p_user_id UUID)
RETURNS TABLE (
dares_sent_count BIGINT,
dares_received_count BIGINT,
dares_completed_as_sender BIGINT,
dares_completed_as_recipient BIGINT
) AS $$
BEGIN
RETURN QUERY
SELECT
(SELECT COUNT(*) FROM sent_dares WHERE sender_id = p_user_id),
(SELECT COUNT(*) FROM sent_dares WHERE recipient_id = p_user_id),
(SELECT COUNT(*) FROM sent_dares WHERE sender_id = p_user_id AND status = 'completed'),
(SELECT COUNT(*) FROM sent_dares WHERE recipient_id = p_user_id AND status = 'completed');
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
```
### 3. Couple Dare Stats Function
```sql
CREATE OR REPLACE FUNCTION get_couple_dare_stats(p_couple_id UUID)
RETURNS TABLE (
total_dares_sent BIGINT,
total_dares_completed BIGINT,
total_dares_active BIGINT
) AS $$
BEGIN
RETURN QUERY
SELECT
(SELECT COUNT(*) FROM sent_dares WHERE couple_id = p_couple_id),
(SELECT COUNT(*) FROM sent_dares WHERE couple_id = p_couple_id AND status = 'completed'),
(SELECT COUNT(*) FROM sent_dares WHERE couple_id = p_couple_id AND status = 'active');
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
```
---
## RLS Policies
### `dare_packs`
```sql
-- Anyone can view public packs
CREATE POLICY "Public dare packs are viewable by everyone"
ON dare_packs FOR SELECT
USING (is_public = true);
-- Admins can do everything
CREATE POLICY "Admins can manage dare packs"
ON dare_packs FOR ALL
USING (is_admin());
```
### `dares`
```sql
-- View dares if pack is accessible (public, or premium pack + premium user)
CREATE POLICY "Users can view dares in accessible packs"
ON dares FOR SELECT
USING (
EXISTS (
SELECT 1 FROM dare_packs dp
WHERE dp.id = dares.pack_id
AND dp.is_public = true
AND (
dp.is_premium = false
OR EXISTS (SELECT 1 FROM profiles WHERE id = auth.uid() AND is_premium = true)
)
)
);
-- Admins can do everything
CREATE POLICY "Admins can manage dares"
ON dares FOR ALL
USING (is_admin());
```
### `sent_dares`
```sql
-- Users can view sent dares in their couple
CREATE POLICY "Users can view their couple's sent dares"
ON sent_dares FOR SELECT
USING (couple_id = get_auth_user_couple_id());
-- Users can update sent dares in their couple (for status changes)
CREATE POLICY "Users can update their couple's sent dares"
ON sent_dares FOR UPDATE
USING (couple_id = get_auth_user_couple_id());
-- Insert via edge function only (service role)
```
### `dare_messages`
```sql
-- Users can view messages for their couple's dares
CREATE POLICY "Users can view messages for their dares"
ON dare_messages FOR SELECT
USING (
EXISTS (
SELECT 1 FROM sent_dares sd
WHERE sd.id = dare_messages.sent_dare_id
AND sd.couple_id = get_auth_user_couple_id()
)
);
-- Users can insert messages for their couple's dares
CREATE POLICY "Users can send messages for their dares"
ON dare_messages FOR INSERT
WITH CHECK (
sender_id = auth.uid()
AND EXISTS (
SELECT 1 FROM sent_dares sd
WHERE sd.id = dare_messages.sent_dare_id
AND sd.couple_id = get_auth_user_couple_id()
)
);
-- Users can update their own messages (for read_at)
CREATE POLICY "Users can update read status"
ON dare_messages FOR UPDATE
USING (
EXISTS (
SELECT 1 FROM sent_dares sd
WHERE sd.id = dare_messages.sent_dare_id
AND sd.couple_id = get_auth_user_couple_id()
)
);
```
---
## Preset Duration Options
App constants (not in database, but used in app/admin):
| Label | Value (hours) | Display |
|-------|---------------|---------|
| 1 hour | `1` | "1 hour" |
| 6 hours | `6` | "6 hours" |
| 12 hours | `12` | "12 hours" |
| 24 hours | `24` | "24 hours" |
| 3 days | `72` | "3 days" |
| 1 week | `168` | "1 week" |
| No time limit | `null` | "No time limit" |
---
## Edge Functions (Future - App Phase)
### `send-dare`
Send a dare to partner.
**Input:**
```typescript
interface SendDareInput {
dare_id?: string; // null for custom dare
custom_dare_text?: string; // required if dare_id is null
custom_dare_intensity?: number; // 1-5, required if custom
duration_hours?: number; // null = no limit
sender_notes?: string; // optional message
}
```
**Logic:**
1. Validate user is in a couple
2. Get partner ID from couple
3. If custom dare, validate user is premium
4. Create `sent_dares` record with status `'pending'`
5. Calculate `expires_at` if duration provided
6. Trigger push notification to recipient
### `respond-dare`
Accept or decline a dare.
**Input:**
```typescript
interface RespondDareInput {
sent_dare_id: string;
action: 'accept' | 'decline';
}
```
**Logic:**
1. Validate user is recipient of the dare
2. Validate status is `'pending'`
3. If accept: set status to `'active'`, set `accepted_at`
4. If decline: set status to `'declined'`
5. Trigger push notification to sender
### `complete-dare`
Sender marks dare as complete.
**Input:**
```typescript
interface CompleteDareInput {
sent_dare_id: string;
}
```
**Logic:**
1. Validate user is sender of the dare
2. Validate status is `'active'`
3. Set status to `'completed'`, set `completed_at`
4. Trigger push notification to recipient
### `cancel-dare`
Sender cancels a dare.
**Input:**
```typescript
interface CancelDareInput {
sent_dare_id: string;
}
```
**Logic:**
1. Validate user is sender
2. Validate status is `'pending'` or `'active'`
3. Set status to `'cancelled'`
4. Trigger push notification to recipient
### Background Job: Expire Dares
Cron job (pg_cron or external) to expire overdue dares:
```sql
UPDATE sent_dares
SET status = 'expired'
WHERE status = 'active'
AND expires_at IS NOT NULL
AND expires_at < NOW();
```
---
## Notifications (Future - App Phase)
| Event | Recipient | Title | Body |
|-------|-----------|-------|------|
| Dare sent | Partner | "New Dare!" | "{name} sent you a dare" |
| Dare accepted | Sender | "Dare Accepted" | "{name} accepted your dare" |
| Dare declined | Sender | "Dare Declined" | "{name} declined your dare" |
| Dare completed | Recipient | "Dare Complete!" | "Your dare was marked complete" |
| Dare cancelled | Recipient | "Dare Cancelled" | "{name} cancelled the dare" |
| Dare expiring (1hr) | Recipient | "Dare Expiring Soon" | "Your dare expires in 1 hour" |
| Dare expired | Both | "Dare Expired" | "The dare has expired" |
---
## Admin Portal Implementation
### New Files Structure
```
apps/admin/src/
├── pages/
│ ├── DarePacksPage.tsx # List all dare packs
│ ├── DarePackDetailPage.tsx # Create/edit dare pack
│ ├── DaresPage.tsx # List dares in a pack
│ └── DareDetailPage.tsx # Create/edit individual dare
├── components/
│ └── IntensityIndicator.tsx # Shared intensity display (1-5)
└── App.tsx # Add new routes
```
### Routes to Add
```typescript
// In App.tsx
<Route path="/dare-packs" element={<DarePacksPage />} />
<Route path="/dare-packs/new" element={<DarePackDetailPage />} />
<Route path="/dare-packs/:packId" element={<DarePackDetailPage />} />
<Route path="/dare-packs/:packId/dares" element={<DaresPage />} />
<Route path="/dare-packs/:packId/dares/new" element={<DareDetailPage />} />
<Route path="/dare-packs/:packId/dares/:dareId" element={<DareDetailPage />} />
```
### Sidebar Navigation Update
Add under "Content" section:
```
📦 Content
├── Question Packs
└── Dare Packs (new)
```
### Page Specifications
#### DarePacksPage
- **Table columns**: Icon, Name, Description (truncated), Dares Count, Intensity Range, Premium, Public, Actions
- **Actions**: Edit, Delete, View Dares
- **Create button**: Top right
- **Empty state**: "No dare packs yet. Create your first pack!"
#### DarePackDetailPage
- **Form fields**:
- Name (text input, required)
- Description (textarea)
- Icon (emoji picker or text input)
- Category (dropdown, optional)
- Is Premium (toggle)
- Is Public (toggle)
- Is Explicit (toggle)
- Sort Order (number input)
- **Read-only display**: Intensity stats (min/max/avg)
- **Actions**: Save, Cancel, Delete (if editing)
#### DaresPage
- **Table columns**: Text (truncated), Intensity (visual indicator), Suggested Duration, Actions
- **Actions**: Edit, Delete
- **Create button**: Top right
- **Back link**: To dare pack detail
#### DareDetailPage
- **Form fields**:
- Text (textarea, required)
- Intensity (slider 1-5 with labels)
- Suggested Duration (dropdown with preset options + "None")
- **Actions**: Save, Cancel, Delete (if editing)
---
## Implementation Tasks
### Phase 1: Database Schema (Supabase)
- [ ] **1.1** Create migration: `dare_status` enum type
- [ ] **1.2** Create migration: `dare_packs` table
- [ ] **1.3** Create migration: `dares` table with intensity trigger
- [ ] **1.4** Create migration: `sent_dares` table
- [ ] **1.5** Create migration: `dare_messages` table
- [ ] **1.6** Create migration: RLS policies for all tables
- [ ] **1.7** Create migration: Stats functions (`get_user_dare_stats`, `get_couple_dare_stats`)
- [ ] **1.8** Create migration: Add tables to realtime publication
- [ ] **1.9** Test migrations in non-prod environment
- [ ] **1.10** Apply migrations to production
### Phase 2: Admin Portal
- [ ] **2.1** Create `IntensityIndicator` component (if not exists)
- [ ] **2.2** Create `DarePacksPage` - list view
- [ ] **2.3** Create `DarePackDetailPage` - create/edit form
- [ ] **2.4** Create `DaresPage` - list dares in pack
- [ ] **2.5** Create `DareDetailPage` - create/edit dare form
- [ ] **2.6** Add routes to `App.tsx`
- [ ] **2.7** Update sidebar navigation
- [ ] **2.8** Test full CRUD flow in admin portal
### Phase 3: Edge Functions (Future - App Phase)
- [ ] **3.1** Create `send-dare` edge function
- [ ] **3.2** Create `respond-dare` edge function
- [ ] **3.3** Create `complete-dare` edge function
- [ ] **3.4** Create `cancel-dare` edge function
- [ ] **3.5** Set up dare expiry cron job
- [ ] **3.6** Integrate with notification system
### Phase 4: Mobile App (Future)
- [ ] **4.1** Create dare packs browsing UI
- [ ] **4.2** Create dare selection/sending flow
- [ ] **4.3** Create incoming dare notification + response UI
- [ ] **4.4** Create active dare tracking UI
- [ ] **4.5** Create dare chat integration
- [ ] **4.6** Create scoreboard/stats display
- [ ] **4.7** Create custom dare creation (premium)
- [ ] **4.8** Add to Zustand stores
---
## Competitor Analysis Notes
Features observed in competitor apps (Desire, Kindu, Spicer, Lovewick):
| Feature | In Our Plan | Notes |
|---------|-------------|-------|
| Dare Packs/Categories | ✅ | Themed collections |
| Intensity Levels | ✅ | 1-5 scale, reuses user preference |
| Time Limits | ✅ | Preset options + no limit |
| Points/Scoring | ✅ Simple | Just count, not weighted points |
| Custom Dares | ✅ Premium | Users create their own |
| Decline Option | ✅ | No negative impact |
| Dare History | ✅ | Via `sent_dares` table |
| Chat per Dare | ✅ | `dare_messages` table |
| Proof/Verification | ❌ | Not in v1 (could add photo proof later) |
| Daily/Random Dares | ❌ | Not in v1 (could add "random dare" button) |
| Dare Rewards | ❌ | Not in v1 (could unlock content) |
| Streak Tracking | ❌ | Not in v1 (could add later) |
### Potential Future Enhancements
1. **Photo/Video Proof**: Allow recipient to submit proof of completion
2. **Random Dare Button**: "Surprise me" feature that picks a random dare
3. **Daily Dare Suggestions**: Push notification with suggested dare
4. **Streak Tracking**: Consecutive days/weeks with completed dares
5. **Achievements/Badges**: Unlock badges for milestones (10 dares, 50 dares, etc.)
6. **Couple Leaderboard**: Compare with other couples (opt-in)
---
## Open Questions (Resolved)
| Question | Answer |
|----------|--------|
| Should dare packs use same `categories` table? | Yes, reuse existing categories |
| Can same dare be sent multiple times? | Yes, each is a new instance |
| Bulk import for dares in admin? | Not in v1, can add later |
| Migration target | Non-prod first, then prod |
---
## References
- Existing schema patterns: `apps/supabase/migrations/20251230000000_v1_initial_schema.sql`
- Question packs structure: `question_packs`, `questions` tables
- Match/messaging pattern: `matches`, `messages` tables
- Admin portal patterns: `apps/admin/src/pages/QuestionPacksPage.tsx`
File diff suppressed because it is too large. Load diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 755 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 613 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 646 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 863 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 966 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 780 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 979 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 314 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 703 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 825 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

+58 -96
View File
@@ -45,6 +45,7 @@
"openai": "^4.24.0", "openai": "^4.24.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-icons": "^4.12.0",
"react-router-dom": "^6.21.0", "react-router-dom": "^6.21.0",
"recharts": "^3.6.0", "recharts": "^3.6.0",
"sonner": "^1.3.1", "sonner": "^1.3.1",
@@ -183,6 +184,7 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz",
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.27.1", "@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5", "@babel/generator": "^7.28.5",
@@ -586,7 +588,6 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.28.5.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.28.5.tgz",
"integrity": "sha512-87GDMS3tsmMSi/3bWOte1UblL+YUTFMV8SZPZ2eSEL17s74Cw/l63rR6NmGVKMYW2GYi85nE+/d6Hw5N0bEk2Q==", "integrity": "sha512-87GDMS3tsmMSi/3bWOte1UblL+YUTFMV8SZPZ2eSEL17s74Cw/l63rR6NmGVKMYW2GYi85nE+/d6Hw5N0bEk2Q==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1",
"@babel/traverse": "^7.28.5" "@babel/traverse": "^7.28.5"
@@ -603,7 +604,6 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.27.1.tgz",
"integrity": "sha512-qNeq3bCKnGgLkEXUuFry6dPlGfCdQNZbn7yUAPCInwAJHMU7THJfrBSozkcWq5sNM6RcF3S8XyQL2A52KNR9IA==", "integrity": "sha512-qNeq3bCKnGgLkEXUuFry6dPlGfCdQNZbn7yUAPCInwAJHMU7THJfrBSozkcWq5sNM6RcF3S8XyQL2A52KNR9IA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/helper-plugin-utils": "^7.27.1" "@babel/helper-plugin-utils": "^7.27.1"
}, },
@@ -619,7 +619,6 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.27.1.tgz",
"integrity": "sha512-g4L7OYun04N1WyqMNjldFwlfPCLVkgB54A/YCXICZYBsvJJE3kByKv9c9+R/nAfmIfjl2rKYLNyMHboYbZaWaA==", "integrity": "sha512-g4L7OYun04N1WyqMNjldFwlfPCLVkgB54A/YCXICZYBsvJJE3kByKv9c9+R/nAfmIfjl2rKYLNyMHboYbZaWaA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/helper-plugin-utils": "^7.27.1" "@babel/helper-plugin-utils": "^7.27.1"
}, },
@@ -635,7 +634,6 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.27.1.tgz",
"integrity": "sha512-oO02gcONcD5O1iTLi/6frMJBIwWEHceWGSGqrpCmEL8nogiS6J9PBlE48CaK20/Jx1LuRml9aDftLgdjXT8+Cw==", "integrity": "sha512-oO02gcONcD5O1iTLi/6frMJBIwWEHceWGSGqrpCmEL8nogiS6J9PBlE48CaK20/Jx1LuRml9aDftLgdjXT8+Cw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1",
"@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1",
@@ -653,7 +651,6 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.28.3.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.28.3.tgz",
"integrity": "sha512-b6YTX108evsvE4YgWyQ921ZAFFQm3Bn+CA3+ZXlNVnPhx+UfsVURoPjfGAPCjBgrqo30yX/C2nZGX96DxvR9Iw==", "integrity": "sha512-b6YTX108evsvE4YgWyQ921ZAFFQm3Bn+CA3+ZXlNVnPhx+UfsVURoPjfGAPCjBgrqo30yX/C2nZGX96DxvR9Iw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1",
"@babel/traverse": "^7.28.3" "@babel/traverse": "^7.28.3"
@@ -754,7 +751,6 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz",
"integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
}, },
@@ -875,7 +871,6 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.27.1.tgz",
"integrity": "sha512-UT/Jrhw57xg4ILHLFnzFpPDlMbcdEicaAtjPQpbj9wa8T4r5KVWCimHcL/460g8Ht0DMxDyjsLgiWSkVjnwPFg==", "integrity": "sha512-UT/Jrhw57xg4ILHLFnzFpPDlMbcdEicaAtjPQpbj9wa8T4r5KVWCimHcL/460g8Ht0DMxDyjsLgiWSkVjnwPFg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/helper-plugin-utils": "^7.27.1" "@babel/helper-plugin-utils": "^7.27.1"
}, },
@@ -1062,7 +1057,6 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz",
"integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/helper-create-regexp-features-plugin": "^7.18.6", "@babel/helper-create-regexp-features-plugin": "^7.18.6",
"@babel/helper-plugin-utils": "^7.18.6" "@babel/helper-plugin-utils": "^7.18.6"
@@ -1128,7 +1122,6 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.27.1.tgz",
"integrity": "sha512-cnqkuOtZLapWYZUYM5rVIdv1nXYuFVIltZ6ZJ7nIj585QsjKM5dhL2Fu/lICXZ1OyIAFc7Qy+bvDAtTXqGrlhg==", "integrity": "sha512-cnqkuOtZLapWYZUYM5rVIdv1nXYuFVIltZ6ZJ7nIj585QsjKM5dhL2Fu/lICXZ1OyIAFc7Qy+bvDAtTXqGrlhg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/helper-plugin-utils": "^7.27.1" "@babel/helper-plugin-utils": "^7.27.1"
}, },
@@ -1175,7 +1168,6 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.28.3.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.28.3.tgz",
"integrity": "sha512-LtPXlBbRoc4Njl/oh1CeD/3jC+atytbnf/UqLoqTDcEYGUPj022+rvfkbDYieUrSj3CaV4yHDByPE+T2HwfsJg==", "integrity": "sha512-LtPXlBbRoc4Njl/oh1CeD/3jC+atytbnf/UqLoqTDcEYGUPj022+rvfkbDYieUrSj3CaV4yHDByPE+T2HwfsJg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/helper-create-class-features-plugin": "^7.28.3", "@babel/helper-create-class-features-plugin": "^7.28.3",
"@babel/helper-plugin-utils": "^7.27.1" "@babel/helper-plugin-utils": "^7.27.1"
@@ -1244,7 +1236,6 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.27.1.tgz",
"integrity": "sha512-gEbkDVGRvjj7+T1ivxrfgygpT7GUd4vmODtYpbs0gZATdkX8/iSnOtZSxiZnsgm1YjTgjI6VKBGSJJevkrclzw==", "integrity": "sha512-gEbkDVGRvjj7+T1ivxrfgygpT7GUd4vmODtYpbs0gZATdkX8/iSnOtZSxiZnsgm1YjTgjI6VKBGSJJevkrclzw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/helper-create-regexp-features-plugin": "^7.27.1", "@babel/helper-create-regexp-features-plugin": "^7.27.1",
"@babel/helper-plugin-utils": "^7.27.1" "@babel/helper-plugin-utils": "^7.27.1"
@@ -1261,7 +1252,6 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.27.1.tgz",
"integrity": "sha512-MTyJk98sHvSs+cvZ4nOauwTTG1JeonDjSGvGGUNHreGQns+Mpt6WX/dVzWBHgg+dYZhkC4X+zTDfkTU+Vy9y7Q==", "integrity": "sha512-MTyJk98sHvSs+cvZ4nOauwTTG1JeonDjSGvGGUNHreGQns+Mpt6WX/dVzWBHgg+dYZhkC4X+zTDfkTU+Vy9y7Q==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/helper-plugin-utils": "^7.27.1" "@babel/helper-plugin-utils": "^7.27.1"
}, },
@@ -1277,7 +1267,6 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.27.1.tgz",
"integrity": "sha512-hkGcueTEzuhB30B3eJCbCYeCaaEQOmQR0AdvzpD4LoN0GXMWzzGSuRrxR2xTnCrvNbVwK9N6/jQ92GSLfiZWoQ==", "integrity": "sha512-hkGcueTEzuhB30B3eJCbCYeCaaEQOmQR0AdvzpD4LoN0GXMWzzGSuRrxR2xTnCrvNbVwK9N6/jQ92GSLfiZWoQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/helper-create-regexp-features-plugin": "^7.27.1", "@babel/helper-create-regexp-features-plugin": "^7.27.1",
"@babel/helper-plugin-utils": "^7.27.1" "@babel/helper-plugin-utils": "^7.27.1"
@@ -1294,7 +1283,6 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.27.1.tgz",
"integrity": "sha512-MHzkWQcEmjzzVW9j2q8LGjwGWpG2mjwaaB0BNQwst3FIjqsg8Ct/mIZlvSPJvfi9y2AC8mi/ktxbFVL9pZ1I4A==", "integrity": "sha512-MHzkWQcEmjzzVW9j2q8LGjwGWpG2mjwaaB0BNQwst3FIjqsg8Ct/mIZlvSPJvfi9y2AC8mi/ktxbFVL9pZ1I4A==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/helper-plugin-utils": "^7.27.1" "@babel/helper-plugin-utils": "^7.27.1"
}, },
@@ -1310,7 +1298,6 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.28.0.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.28.0.tgz",
"integrity": "sha512-K8nhUcn3f6iB+P3gwCv/no7OdzOZQcKchW6N389V6PD8NUWKZHzndOd9sPDVbMoBsbmjMqlB4L9fm+fEFNVlwQ==", "integrity": "sha512-K8nhUcn3f6iB+P3gwCv/no7OdzOZQcKchW6N389V6PD8NUWKZHzndOd9sPDVbMoBsbmjMqlB4L9fm+fEFNVlwQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1",
"@babel/plugin-transform-destructuring": "^7.28.0" "@babel/plugin-transform-destructuring": "^7.28.0"
@@ -1327,7 +1314,6 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.28.5.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.28.5.tgz",
"integrity": "sha512-D4WIMaFtwa2NizOp+dnoFjRez/ClKiC2BqqImwKd1X28nqBtZEyCYJ2ozQrrzlxAFrcrjxo39S6khe9RNDlGzw==", "integrity": "sha512-D4WIMaFtwa2NizOp+dnoFjRez/ClKiC2BqqImwKd1X28nqBtZEyCYJ2ozQrrzlxAFrcrjxo39S6khe9RNDlGzw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/helper-plugin-utils": "^7.27.1" "@babel/helper-plugin-utils": "^7.27.1"
}, },
@@ -1407,7 +1393,6 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.27.1.tgz",
"integrity": "sha512-6WVLVJiTjqcQauBhn1LkICsR2H+zm62I3h9faTDKt1qP4jn2o72tSvqMwtGFKGTpojce0gJs+76eZ2uCHRZh0Q==", "integrity": "sha512-6WVLVJiTjqcQauBhn1LkICsR2H+zm62I3h9faTDKt1qP4jn2o72tSvqMwtGFKGTpojce0gJs+76eZ2uCHRZh0Q==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/helper-plugin-utils": "^7.27.1" "@babel/helper-plugin-utils": "^7.27.1"
}, },
@@ -1453,7 +1438,6 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.27.1.tgz",
"integrity": "sha512-hqoBX4dcZ1I33jCSWcXrP+1Ku7kdqXf1oeah7ooKOIiAdKQ+uqftgCFNOSzA5AMS2XIHEYeGFg4cKRCdpxzVOQ==", "integrity": "sha512-hqoBX4dcZ1I33jCSWcXrP+1Ku7kdqXf1oeah7ooKOIiAdKQ+uqftgCFNOSzA5AMS2XIHEYeGFg4cKRCdpxzVOQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/helper-plugin-utils": "^7.27.1" "@babel/helper-plugin-utils": "^7.27.1"
}, },
@@ -1469,7 +1453,6 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.27.1.tgz",
"integrity": "sha512-iCsytMg/N9/oFq6n+gFTvUYDZQOMK5kEdeYxmxt91fcJGycfxVP9CnrxoliM0oumFERba2i8ZtwRUCMhvP1LnA==", "integrity": "sha512-iCsytMg/N9/oFq6n+gFTvUYDZQOMK5kEdeYxmxt91fcJGycfxVP9CnrxoliM0oumFERba2i8ZtwRUCMhvP1LnA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/helper-module-transforms": "^7.27.1", "@babel/helper-module-transforms": "^7.27.1",
"@babel/helper-plugin-utils": "^7.27.1" "@babel/helper-plugin-utils": "^7.27.1"
@@ -1502,7 +1485,6 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.28.5.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.28.5.tgz",
"integrity": "sha512-vn5Jma98LCOeBy/KpeQhXcV2WZgaRUtjwQmjoBuLNlOmkg0fB5pdvYVeWRYI69wWKwK2cD1QbMiUQnoujWvrew==", "integrity": "sha512-vn5Jma98LCOeBy/KpeQhXcV2WZgaRUtjwQmjoBuLNlOmkg0fB5pdvYVeWRYI69wWKwK2cD1QbMiUQnoujWvrew==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/helper-module-transforms": "^7.28.3", "@babel/helper-module-transforms": "^7.28.3",
"@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1",
@@ -1521,7 +1503,6 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.27.1.tgz",
"integrity": "sha512-iQBE/xC5BV1OxJbp6WG7jq9IWiD+xxlZhLrdwpPkTX3ydmXdvoCpyfJN7acaIBZaOqTfr76pgzqBJflNbeRK+w==", "integrity": "sha512-iQBE/xC5BV1OxJbp6WG7jq9IWiD+xxlZhLrdwpPkTX3ydmXdvoCpyfJN7acaIBZaOqTfr76pgzqBJflNbeRK+w==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/helper-module-transforms": "^7.27.1", "@babel/helper-module-transforms": "^7.27.1",
"@babel/helper-plugin-utils": "^7.27.1" "@babel/helper-plugin-utils": "^7.27.1"
@@ -1554,7 +1535,6 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.27.1.tgz",
"integrity": "sha512-f6PiYeqXQ05lYq3TIfIDu/MtliKUbNwkGApPUvyo6+tc7uaR4cPjPe7DFPr15Uyycg2lZU6btZ575CuQoYh7MQ==", "integrity": "sha512-f6PiYeqXQ05lYq3TIfIDu/MtliKUbNwkGApPUvyo6+tc7uaR4cPjPe7DFPr15Uyycg2lZU6btZ575CuQoYh7MQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/helper-plugin-utils": "^7.27.1" "@babel/helper-plugin-utils": "^7.27.1"
}, },
@@ -1619,7 +1599,6 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.27.1.tgz",
"integrity": "sha512-SFy8S9plRPbIcxlJ8A6mT/CxFdJx/c04JEctz4jf8YZaVS2px34j7NXRrlGlHkN/M2gnpL37ZpGRGVFLd3l8Ng==", "integrity": "sha512-SFy8S9plRPbIcxlJ8A6mT/CxFdJx/c04JEctz4jf8YZaVS2px34j7NXRrlGlHkN/M2gnpL37ZpGRGVFLd3l8Ng==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1",
"@babel/helper-replace-supers": "^7.27.1" "@babel/helper-replace-supers": "^7.27.1"
@@ -1715,7 +1694,6 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.27.1.tgz",
"integrity": "sha512-oThy3BCuCha8kDZ8ZkgOg2exvPYUlprMukKQXI1r1pJ47NCvxfkEy8vK+r/hT9nF0Aa4H1WUPZZjHTFtAhGfmQ==", "integrity": "sha512-oThy3BCuCha8kDZ8ZkgOg2exvPYUlprMukKQXI1r1pJ47NCvxfkEy8vK+r/hT9nF0Aa4H1WUPZZjHTFtAhGfmQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/helper-plugin-utils": "^7.27.1" "@babel/helper-plugin-utils": "^7.27.1"
}, },
@@ -1841,7 +1819,6 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.27.1.tgz",
"integrity": "sha512-TtEciroaiODtXvLZv4rmfMhkCv8jx3wgKpL68PuiPh2M4fvz5jhsA7697N1gMvkvr/JTF13DrFYyEbY9U7cVPA==", "integrity": "sha512-TtEciroaiODtXvLZv4rmfMhkCv8jx3wgKpL68PuiPh2M4fvz5jhsA7697N1gMvkvr/JTF13DrFYyEbY9U7cVPA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/helper-create-regexp-features-plugin": "^7.27.1", "@babel/helper-create-regexp-features-plugin": "^7.27.1",
"@babel/helper-plugin-utils": "^7.27.1" "@babel/helper-plugin-utils": "^7.27.1"
@@ -1858,7 +1835,6 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.27.1.tgz",
"integrity": "sha512-V2ABPHIJX4kC7HegLkYoDpfg9PVmuWy/i6vUM5eGK22bx4YVFD3M5F0QQnWQoDs6AGsUWTVOopBiMFQgHaSkVw==", "integrity": "sha512-V2ABPHIJX4kC7HegLkYoDpfg9PVmuWy/i6vUM5eGK22bx4YVFD3M5F0QQnWQoDs6AGsUWTVOopBiMFQgHaSkVw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/helper-plugin-utils": "^7.27.1" "@babel/helper-plugin-utils": "^7.27.1"
}, },
@@ -1955,7 +1931,6 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.27.1.tgz",
"integrity": "sha512-RiSILC+nRJM7FY5srIyc4/fGIwUhyDuuBSdWn4y6yT6gm652DpCHZjIipgn6B7MQ1ITOUnAKWixEUjQRIBIcLw==", "integrity": "sha512-RiSILC+nRJM7FY5srIyc4/fGIwUhyDuuBSdWn4y6yT6gm652DpCHZjIipgn6B7MQ1ITOUnAKWixEUjQRIBIcLw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/helper-plugin-utils": "^7.27.1" "@babel/helper-plugin-utils": "^7.27.1"
}, },
@@ -1990,7 +1965,6 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.27.1.tgz",
"integrity": "sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg==", "integrity": "sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/helper-plugin-utils": "^7.27.1" "@babel/helper-plugin-utils": "^7.27.1"
}, },
@@ -2006,7 +1980,6 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.27.1.tgz",
"integrity": "sha512-uW20S39PnaTImxp39O5qFlHLS9LJEmANjMG7SxIhap8rCHqu0Ik+tLEPX5DKmHn6CsWQ7j3lix2tFOa5YtL12Q==", "integrity": "sha512-uW20S39PnaTImxp39O5qFlHLS9LJEmANjMG7SxIhap8rCHqu0Ik+tLEPX5DKmHn6CsWQ7j3lix2tFOa5YtL12Q==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/helper-create-regexp-features-plugin": "^7.27.1", "@babel/helper-create-regexp-features-plugin": "^7.27.1",
"@babel/helper-plugin-utils": "^7.27.1" "@babel/helper-plugin-utils": "^7.27.1"
@@ -2039,7 +2012,6 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.27.1.tgz",
"integrity": "sha512-EtkOujbc4cgvb0mlpQefi4NTPBzhSIevblFevACNLUspmrALgmEBdL/XfnyyITfd8fKBZrZys92zOWcik7j9Tw==", "integrity": "sha512-EtkOujbc4cgvb0mlpQefi4NTPBzhSIevblFevACNLUspmrALgmEBdL/XfnyyITfd8fKBZrZys92zOWcik7j9Tw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/helper-create-regexp-features-plugin": "^7.27.1", "@babel/helper-create-regexp-features-plugin": "^7.27.1",
"@babel/helper-plugin-utils": "^7.27.1" "@babel/helper-plugin-utils": "^7.27.1"
@@ -2158,7 +2130,6 @@
"resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz",
"integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==", "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/helper-plugin-utils": "^7.0.0", "@babel/helper-plugin-utils": "^7.0.0",
"@babel/types": "^7.4.4", "@babel/types": "^7.4.4",
@@ -6268,6 +6239,7 @@
"resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-1.23.1.tgz", "resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-1.23.1.tgz",
"integrity": "sha512-Qd2kQ3yi6Y3+AcUlrHxSLlnBvpdCEMVGFlVBneVOjaFaPU61g1huc38g339ysXspwY1QZA2aNhrk/KlHGO+ewA==", "integrity": "sha512-Qd2kQ3yi6Y3+AcUlrHxSLlnBvpdCEMVGFlVBneVOjaFaPU61g1huc38g339ysXspwY1QZA2aNhrk/KlHGO+ewA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"merge-options": "^3.0.4" "merge-options": "^3.0.4"
}, },
@@ -6713,6 +6685,7 @@
"resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.1.26.tgz", "resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.1.26.tgz",
"integrity": "sha512-RhKmeD0E2ejzKS6z8elAfdfwShpcdkYY8zJzvHYLq+wv183BBcElTeyMLcIX6wIn7QutXeI92Yi21t7aUWfqNQ==", "integrity": "sha512-RhKmeD0E2ejzKS6z8elAfdfwShpcdkYY8zJzvHYLq+wv183BBcElTeyMLcIX6wIn7QutXeI92Yi21t7aUWfqNQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@react-navigation/core": "^7.13.7", "@react-navigation/core": "^7.13.7",
"escape-string-regexp": "^4.0.0", "escape-string-regexp": "^4.0.0",
@@ -7884,7 +7857,6 @@
"integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/estree": "*", "@types/estree": "*",
"@types/json-schema": "*" "@types/json-schema": "*"
@@ -7896,7 +7868,6 @@
"integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/eslint": "*", "@types/eslint": "*",
"@types/estree": "*" "@types/estree": "*"
@@ -8110,6 +8081,7 @@
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/prop-types": "*", "@types/prop-types": "*",
"csstype": "^3.2.2" "csstype": "^3.2.2"
@@ -8121,6 +8093,7 @@
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"peerDependencies": { "peerDependencies": {
"@types/react": "^18.0.0" "@types/react": "^18.0.0"
} }
@@ -8237,7 +8210,6 @@
"integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@webassemblyjs/helper-numbers": "1.13.2", "@webassemblyjs/helper-numbers": "1.13.2",
"@webassemblyjs/helper-wasm-bytecode": "1.13.2" "@webassemblyjs/helper-wasm-bytecode": "1.13.2"
@@ -8248,24 +8220,21 @@
"resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz",
"integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/@webassemblyjs/helper-api-error": { "node_modules/@webassemblyjs/helper-api-error": {
"version": "1.13.2", "version": "1.13.2",
"resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz",
"integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/@webassemblyjs/helper-buffer": { "node_modules/@webassemblyjs/helper-buffer": {
"version": "1.14.1", "version": "1.14.1",
"resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz",
"integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/@webassemblyjs/helper-numbers": { "node_modules/@webassemblyjs/helper-numbers": {
"version": "1.13.2", "version": "1.13.2",
@@ -8273,7 +8242,6 @@
"integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@webassemblyjs/floating-point-hex-parser": "1.13.2", "@webassemblyjs/floating-point-hex-parser": "1.13.2",
"@webassemblyjs/helper-api-error": "1.13.2", "@webassemblyjs/helper-api-error": "1.13.2",
@@ -8285,8 +8253,7 @@
"resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz",
"integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/@webassemblyjs/helper-wasm-section": { "node_modules/@webassemblyjs/helper-wasm-section": {
"version": "1.14.1", "version": "1.14.1",
@@ -8294,7 +8261,6 @@
"integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@webassemblyjs/ast": "1.14.1", "@webassemblyjs/ast": "1.14.1",
"@webassemblyjs/helper-buffer": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1",
@@ -8308,7 +8274,6 @@
"integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@xtuc/ieee754": "^1.2.0" "@xtuc/ieee754": "^1.2.0"
} }
@@ -8319,7 +8284,6 @@
"integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@xtuc/long": "4.2.2" "@xtuc/long": "4.2.2"
} }
@@ -8329,8 +8293,7 @@
"resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz",
"integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/@webassemblyjs/wasm-edit": { "node_modules/@webassemblyjs/wasm-edit": {
"version": "1.14.1", "version": "1.14.1",
@@ -8338,7 +8301,6 @@
"integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@webassemblyjs/ast": "1.14.1", "@webassemblyjs/ast": "1.14.1",
"@webassemblyjs/helper-buffer": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1",
@@ -8356,7 +8318,6 @@
"integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@webassemblyjs/ast": "1.14.1", "@webassemblyjs/ast": "1.14.1",
"@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/helper-wasm-bytecode": "1.13.2",
@@ -8371,7 +8332,6 @@
"integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@webassemblyjs/ast": "1.14.1", "@webassemblyjs/ast": "1.14.1",
"@webassemblyjs/helper-buffer": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1",
@@ -8385,7 +8345,6 @@
"integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@webassemblyjs/ast": "1.14.1", "@webassemblyjs/ast": "1.14.1",
"@webassemblyjs/helper-api-error": "1.13.2", "@webassemblyjs/helper-api-error": "1.13.2",
@@ -8401,7 +8360,6 @@
"integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@webassemblyjs/ast": "1.14.1", "@webassemblyjs/ast": "1.14.1",
"@xtuc/long": "4.2.2" "@xtuc/long": "4.2.2"
@@ -8422,16 +8380,14 @@
"resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz",
"integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==",
"dev": true, "dev": true,
"license": "BSD-3-Clause", "license": "BSD-3-Clause"
"peer": true
}, },
"node_modules/@xtuc/long": { "node_modules/@xtuc/long": {
"version": "4.2.2", "version": "4.2.2",
"resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz",
"integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0"
"peer": true
}, },
"node_modules/abab": { "node_modules/abab": {
"version": "2.0.6", "version": "2.0.6",
@@ -8471,6 +8427,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -8495,7 +8452,6 @@
"integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=10.13.0" "node": ">=10.13.0"
}, },
@@ -8571,6 +8527,7 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.3", "fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1", "fast-uri": "^3.0.1",
@@ -9160,8 +9117,7 @@
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
"license": "ISC", "license": "ISC"
"peer": true
}, },
"node_modules/bplist-creator": { "node_modules/bplist-creator": {
"version": "0.0.7", "version": "0.0.7",
@@ -9225,6 +9181,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.9.0", "baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759", "caniuse-lite": "^1.0.30001759",
@@ -9621,7 +9578,6 @@
"integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=6.0" "node": ">=6.0"
} }
@@ -10113,7 +10069,6 @@
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
"integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"peer": true,
"dependencies": { "dependencies": {
"boolbase": "^1.0.0", "boolbase": "^1.0.0",
"css-what": "^6.1.0", "css-what": "^6.1.0",
@@ -10130,7 +10085,6 @@
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz",
"integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==", "integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"mdn-data": "2.0.14", "mdn-data": "2.0.14",
"source-map": "^0.6.1" "source-map": "^0.6.1"
@@ -10144,7 +10098,6 @@
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -10154,7 +10107,6 @@
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
"integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"peer": true,
"engines": { "engines": {
"node": ">= 6" "node": ">= 6"
}, },
@@ -10687,7 +10639,6 @@
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"domelementtype": "^2.3.0", "domelementtype": "^2.3.0",
"domhandler": "^5.0.2", "domhandler": "^5.0.2",
@@ -10707,8 +10658,7 @@
"url": "https://github.com/sponsors/fb55" "url": "https://github.com/sponsors/fb55"
} }
], ],
"license": "BSD-2-Clause", "license": "BSD-2-Clause"
"peer": true
}, },
"node_modules/domexception": { "node_modules/domexception": {
"version": "4.0.0", "version": "4.0.0",
@@ -10739,7 +10689,6 @@
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"peer": true,
"dependencies": { "dependencies": {
"domelementtype": "^2.3.0" "domelementtype": "^2.3.0"
}, },
@@ -10755,7 +10704,6 @@
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"peer": true,
"dependencies": { "dependencies": {
"dom-serializer": "^2.0.0", "dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0", "domelementtype": "^2.3.0",
@@ -10899,7 +10847,8 @@
"version": "5.6.0", "version": "5.6.0",
"resolved": "https://registry.npmjs.org/emoji-mart/-/emoji-mart-5.6.0.tgz", "resolved": "https://registry.npmjs.org/emoji-mart/-/emoji-mart-5.6.0.tgz",
"integrity": "sha512-eJp3QRe79pjwa+duv+n7+5YsNhRcMl812EcFVwrnRvYKoNPoQb5qxU8DG6Bgwji0akHdp6D4Ln6tYLG58MFSow==", "integrity": "sha512-eJp3QRe79pjwa+duv+n7+5YsNhRcMl812EcFVwrnRvYKoNPoQb5qxU8DG6Bgwji0akHdp6D4Ln6tYLG58MFSow==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/emoji-regex": { "node_modules/emoji-regex": {
"version": "8.0.0", "version": "8.0.0",
@@ -10931,7 +10880,6 @@
"integrity": "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==", "integrity": "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"graceful-fs": "^4.2.4", "graceful-fs": "^4.2.4",
"tapable": "^2.2.0" "tapable": "^2.2.0"
@@ -10945,7 +10893,6 @@
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"peer": true,
"engines": { "engines": {
"node": ">=0.12" "node": ">=0.12"
}, },
@@ -11003,8 +10950,7 @@
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",
"integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/es-object-atoms": { "node_modules/es-object-atoms": {
"version": "1.1.1", "version": "1.1.1",
@@ -11148,7 +11094,6 @@
"integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==",
"dev": true, "dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"peer": true,
"dependencies": { "dependencies": {
"esrecurse": "^4.3.0", "esrecurse": "^4.3.0",
"estraverse": "^4.1.1" "estraverse": "^4.1.1"
@@ -11163,7 +11108,6 @@
"integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==",
"dev": true, "dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"peer": true,
"engines": { "engines": {
"node": ">=4.0" "node": ">=4.0"
} }
@@ -11187,7 +11131,6 @@
"integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
"dev": true, "dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"peer": true,
"dependencies": { "dependencies": {
"estraverse": "^5.2.0" "estraverse": "^5.2.0"
}, },
@@ -11571,6 +11514,7 @@
"resolved": "https://registry.npmjs.org/expo/-/expo-52.0.48.tgz", "resolved": "https://registry.npmjs.org/expo/-/expo-52.0.48.tgz",
"integrity": "sha512-/HR/vuo57KGEWlvF3GWaquwEAjXuA5hrOCsaLcZ3pMSA8mQ27qKd1jva4GWzpxXYedlzs/7LLP1XpZo6hXTsog==", "integrity": "sha512-/HR/vuo57KGEWlvF3GWaquwEAjXuA5hrOCsaLcZ3pMSA8mQ27qKd1jva4GWzpxXYedlzs/7LLP1XpZo6hXTsog==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/runtime": "^7.20.0", "@babel/runtime": "^7.20.0",
"@expo/cli": "0.22.27", "@expo/cli": "0.22.27",
@@ -11720,6 +11664,7 @@
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-17.0.8.tgz", "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-17.0.8.tgz",
"integrity": "sha512-XfWRyQAf1yUNgWZ1TnE8pFBMqGmFP5Gb+SFSgszxDdOoheB/NI5D4p7q86kI2fvGyfTrxAe+D+74nZkfsGvUlg==", "integrity": "sha512-XfWRyQAf1yUNgWZ1TnE8pFBMqGmFP5Gb+SFSgszxDdOoheB/NI5D4p7q86kI2fvGyfTrxAe+D+74nZkfsGvUlg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@expo/config": "~10.0.11", "@expo/config": "~10.0.11",
"@expo/env": "~0.4.2" "@expo/env": "~0.4.2"
@@ -11802,6 +11747,7 @@
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-18.0.12.tgz", "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-18.0.12.tgz",
"integrity": "sha512-HAkrd/mb8r+G3lJ9MzmGeuW2B+BxQR1joKfeCyY4deLl1zoZ48FrAWjgZjHK9aHUVhJ0ehzInu/NQtikKytaeg==", "integrity": "sha512-HAkrd/mb8r+G3lJ9MzmGeuW2B+BxQR1joKfeCyY4deLl1zoZ48FrAWjgZjHK9aHUVhJ0ehzInu/NQtikKytaeg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"web-streams-polyfill": "^3.3.2" "web-streams-polyfill": "^3.3.2"
}, },
@@ -11902,6 +11848,7 @@
"resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-7.0.5.tgz", "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-7.0.5.tgz",
"integrity": "sha512-3KptlJtcYDPWohk0MfJU75MJFh2ybavbtcSd84zEPfw9s1q3hjimw3sXnH03ZxP54kiEWldvKmmnGcVffBDB1g==", "integrity": "sha512-3KptlJtcYDPWohk0MfJU75MJFh2ybavbtcSd84zEPfw9s1q3hjimw3sXnH03ZxP54kiEWldvKmmnGcVffBDB1g==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"expo-constants": "~17.0.5", "expo-constants": "~17.0.5",
"invariant": "^2.2.4" "invariant": "^2.2.4"
@@ -12669,8 +12616,7 @@
"resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz",
"integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==",
"dev": true, "dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause"
"peer": true
}, },
"node_modules/globby": { "node_modules/globby": {
"version": "11.1.0", "version": "11.1.0",
@@ -13670,6 +13616,7 @@
"integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@jest/core": "^29.7.0", "@jest/core": "^29.7.0",
"@jest/types": "^29.6.3", "@jest/types": "^29.6.3",
@@ -14112,8 +14059,7 @@
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0-rc-6230622a1a-20240610.tgz", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0-rc-6230622a1a-20240610.tgz",
"integrity": "sha512-GTIQdJXthps5mgkIFo7yAq03M0QQYTfN8z+GrnMC/SCKFSuyFP5tk2BMaaWUsVy4u4r+dTLdiXH8JEivVls0Bw==", "integrity": "sha512-GTIQdJXthps5mgkIFo7yAq03M0QQYTfN8z+GrnMC/SCKFSuyFP5tk2BMaaWUsVy4u4r+dTLdiXH8JEivVls0Bw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/jest-get-type": { "node_modules/jest-get-type": {
"version": "29.6.3", "version": "29.6.3",
@@ -14688,6 +14634,7 @@
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"jiti": "bin/jiti.js" "jiti": "bin/jiti.js"
} }
@@ -14985,6 +14932,7 @@
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.27.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.27.0.tgz",
"integrity": "sha512-8f7aNmS1+etYSLHht0fQApPc2kNO8qGRutifN5rVIc6Xo6ABsEbqOr758UwI7ALVbTt4x1fllKt0PYgzD9S3yQ==", "integrity": "sha512-8f7aNmS1+etYSLHht0fQApPc2kNO8qGRutifN5rVIc6Xo6ABsEbqOr758UwI7ALVbTt4x1fllKt0PYgzD9S3yQ==",
"license": "MPL-2.0", "license": "MPL-2.0",
"peer": true,
"dependencies": { "dependencies": {
"detect-libc": "^1.0.3" "detect-libc": "^1.0.3"
}, },
@@ -15233,7 +15181,6 @@
"integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==", "integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=6.11.5" "node": ">=6.11.5"
}, },
@@ -15474,8 +15421,7 @@
"version": "2.0.14", "version": "2.0.14",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz",
"integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==", "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==",
"license": "CC0-1.0", "license": "CC0-1.0"
"peer": true
}, },
"node_modules/memoize-one": { "node_modules/memoize-one": {
"version": "5.2.1", "version": "5.2.1",
@@ -16321,7 +16267,6 @@
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"peer": true,
"dependencies": { "dependencies": {
"boolbase": "^1.0.0" "boolbase": "^1.0.0"
}, },
@@ -16990,6 +16935,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"nanoid": "^3.3.11", "nanoid": "^3.3.11",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@@ -17392,7 +17338,6 @@
"integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"safe-buffer": "^5.1.0" "safe-buffer": "^5.1.0"
} }
@@ -17426,6 +17371,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0" "loose-envify": "^1.1.0"
}, },
@@ -17469,6 +17415,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0", "loose-envify": "^1.1.0",
"scheduler": "^0.23.2" "scheduler": "^0.23.2"
@@ -17512,17 +17459,28 @@
"react-dom": "^16.6.0 || ^17.0.0 || ^18.0.0" "react-dom": "^16.6.0 || ^17.0.0 || ^18.0.0"
} }
}, },
"node_modules/react-icons": {
"version": "4.12.0",
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-4.12.0.tgz",
"integrity": "sha512-IBaDuHiShdZqmfc/TwHu6+d6k2ltNCf3AszxNmjJc1KUfXdEeRJOKyNvLmAHaarhzGmTSVygNdyu8/opXv2gaw==",
"license": "MIT",
"peerDependencies": {
"react": "*"
}
},
"node_modules/react-is": { "node_modules/react-is": {
"version": "19.2.3", "version": "19.2.3",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.3.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.3.tgz",
"integrity": "sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA==", "integrity": "sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/react-native": { "node_modules/react-native": {
"version": "0.76.9", "version": "0.76.9",
"resolved": "https://registry.npmjs.org/react-native/-/react-native-0.76.9.tgz", "resolved": "https://registry.npmjs.org/react-native/-/react-native-0.76.9.tgz",
"integrity": "sha512-+LRwecWmTDco7OweGsrECIqJu0iyrREd6CTCgC/uLLYipiHvk+MH9nd6drFtCw/6Blz6eoKTcH9YTTJusNtrWg==", "integrity": "sha512-+LRwecWmTDco7OweGsrECIqJu0iyrREd6CTCgC/uLLYipiHvk+MH9nd6drFtCw/6Blz6eoKTcH9YTTJusNtrWg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@jest/create-cache-key-function": "^29.6.3", "@jest/create-cache-key-function": "^29.6.3",
"@react-native/assets-registry": "0.76.9", "@react-native/assets-registry": "0.76.9",
@@ -17691,6 +17649,7 @@
"resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-3.16.7.tgz", "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-3.16.7.tgz",
"integrity": "sha512-qoUUQOwE1pHlmQ9cXTJ2MX9FQ9eHllopCLiWOkDkp6CER95ZWeXhJCP4cSm6AD4jigL5jHcZf/SkWrg8ttZUsw==", "integrity": "sha512-qoUUQOwE1pHlmQ9cXTJ2MX9FQ9eHllopCLiWOkDkp6CER95ZWeXhJCP4cSm6AD4jigL5jHcZf/SkWrg8ttZUsw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/plugin-transform-arrow-functions": "^7.0.0-0", "@babel/plugin-transform-arrow-functions": "^7.0.0-0",
"@babel/plugin-transform-class-properties": "^7.0.0-0", "@babel/plugin-transform-class-properties": "^7.0.0-0",
@@ -17715,6 +17674,7 @@
"resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-4.12.0.tgz", "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-4.12.0.tgz",
"integrity": "sha512-ukk5PxcF4p3yu6qMZcmeiZgowhb5AsKRnil54YFUUAXVIS7PJcMHGGC+q44fCiBg44/1AJk5njGMez1m9H0BVQ==", "integrity": "sha512-ukk5PxcF4p3yu6qMZcmeiZgowhb5AsKRnil54YFUUAXVIS7PJcMHGGC+q44fCiBg44/1AJk5njGMez1m9H0BVQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"peerDependencies": { "peerDependencies": {
"react": "*", "react": "*",
"react-native": "*" "react-native": "*"
@@ -17725,6 +17685,7 @@
"resolved": "https://registry.npmjs.org/react-native-screens/-/react-native-screens-4.4.0.tgz", "resolved": "https://registry.npmjs.org/react-native-screens/-/react-native-screens-4.4.0.tgz",
"integrity": "sha512-c7zc7Zwjty6/pGyuuvh9gK3YBYqHPOxrhXfG1lF4gHlojQSmIx2piNbNaV+Uykj+RDTmFXK0e/hA+fucw/Qozg==", "integrity": "sha512-c7zc7Zwjty6/pGyuuvh9gK3YBYqHPOxrhXfG1lF4gHlojQSmIx2piNbNaV+Uykj+RDTmFXK0e/hA+fucw/Qozg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"react-freeze": "^1.0.0", "react-freeze": "^1.0.0",
"warn-once": "^0.1.0" "warn-once": "^0.1.0"
@@ -17767,6 +17728,7 @@
"resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.19.13.tgz", "resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.19.13.tgz",
"integrity": "sha512-etv3bN8rJglrRCp/uL4p7l8QvUNUC++QwDbdZ8CB7BvZiMvsxfFIRM1j04vxNldG3uo2puRd6OSWR3ibtmc29A==", "integrity": "sha512-etv3bN8rJglrRCp/uL4p7l8QvUNUC++QwDbdZ8CB7BvZiMvsxfFIRM1j04vxNldG3uo2puRd6OSWR3ibtmc29A==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/runtime": "^7.18.6", "@babel/runtime": "^7.18.6",
"@react-native/normalize-colors": "^0.74.1", "@react-native/normalize-colors": "^0.74.1",
@@ -17865,6 +17827,7 @@
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/use-sync-external-store": "^0.0.6", "@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0" "use-sync-external-store": "^1.4.0"
@@ -18021,6 +17984,7 @@
"integrity": "sha512-KkAgygexHUkQqtvvx/otwxtuFu5cVjfzTCtjXLH9boS19/Nbtg84zS7wIQn39G8IlrhThBpQsMKkq5ZHZIYFXA==", "integrity": "sha512-KkAgygexHUkQqtvvx/otwxtuFu5cVjfzTCtjXLH9boS19/Nbtg84zS7wIQn39G8IlrhThBpQsMKkq5ZHZIYFXA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"react-is": "^18.3.1", "react-is": "^18.3.1",
"react-shallow-renderer": "^16.15.0", "react-shallow-renderer": "^16.15.0",
@@ -18201,7 +18165,8 @@
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/redux-thunk": { "node_modules/redux-thunk": {
"version": "3.1.0", "version": "3.1.0",
@@ -18706,7 +18671,6 @@
"integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==",
"dev": true, "dev": true,
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"peer": true,
"dependencies": { "dependencies": {
"randombytes": "^2.1.0" "randombytes": "^2.1.0"
} }
@@ -19504,7 +19468,6 @@
"integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=6" "node": ">=6"
}, },
@@ -19679,6 +19642,7 @@
"resolved": "https://registry.npmjs.org/terser/-/terser-5.44.1.tgz", "resolved": "https://registry.npmjs.org/terser/-/terser-5.44.1.tgz",
"integrity": "sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==", "integrity": "sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==",
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"peer": true,
"dependencies": { "dependencies": {
"@jridgewell/source-map": "^0.3.3", "@jridgewell/source-map": "^0.3.3",
"acorn": "^8.15.0", "acorn": "^8.15.0",
@@ -19698,7 +19662,6 @@
"integrity": "sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q==", "integrity": "sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@jridgewell/trace-mapping": "^0.3.25", "@jridgewell/trace-mapping": "^0.3.25",
"jest-worker": "^27.4.5", "jest-worker": "^27.4.5",
@@ -19734,7 +19697,6 @@
"integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/node": "*", "@types/node": "*",
"merge-stream": "^2.0.0", "merge-stream": "^2.0.0",
@@ -19750,7 +19712,6 @@
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"has-flag": "^4.0.0" "has-flag": "^4.0.0"
}, },
@@ -20365,6 +20326,7 @@
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.21.3", "esbuild": "^0.21.3",
"postcss": "^8.4.43", "postcss": "^8.4.43",
@@ -20459,7 +20421,6 @@
"integrity": "sha512-e6vZvY6xboSwLz2GD36c16+O/2Z6fKvIf4pOXptw2rY9MVwE/TXc6RGqxD3I3x0a28lwBY7DE+76uTPSsBrrCA==", "integrity": "sha512-e6vZvY6xboSwLz2GD36c16+O/2Z6fKvIf4pOXptw2rY9MVwE/TXc6RGqxD3I3x0a28lwBY7DE+76uTPSsBrrCA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"glob-to-regexp": "^0.4.1", "glob-to-regexp": "^0.4.1",
"graceful-fs": "^4.1.2" "graceful-fs": "^4.1.2"
@@ -20548,7 +20509,6 @@
"integrity": "sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==", "integrity": "sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=10.13.0" "node": ">=10.13.0"
} }
@@ -20739,6 +20699,7 @@
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=10.0.0" "node": ">=10.0.0"
}, },
@@ -20845,6 +20806,7 @@
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
"integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
"license": "ISC", "license": "ISC",
"peer": true,
"engines": { "engines": {
"node": ">= 6" "node": ">= 6"
} }
+15 -15
View File
@@ -16,46 +16,46 @@ const DIMENSIONS = {
// Screenshot configurations with marketing text // Screenshot configurations with marketing text
const SCREENSHOTS = [ const SCREENSHOTS = [
{ {
source: "Simulator Screenshot - iPhone 16 Pro - 2025-12-30 at 19.57.14.png", source: "Simulator Screenshot - iPhone 16e - 2026-01-08 at 19.18.40.png",
headline: "Your Couple Dashboard", headline: "Your Couple Dashboard",
subheadline: "Track matches, packs & explore together", subheadline: "Track matches, packs & explore together",
outputName: "01-home", outputName: "01-home",
}, },
{ {
source: "Simulator Screenshot - iPhone 16 Pro - 2025-12-30 at 20.10.53.png", source: "Simulator Screenshot - iPhone 16e - 2026-01-08 at 19.10.03.png",
headline: "Curated Question Packs", headline: "Curated Question Packs",
subheadline: "From first dates to spicy adventures", subheadline: "From first dates to spicy adventures",
outputName: "02-packs", outputName: "02-packs",
}, },
{ {
source: "Simulator Screenshot - iPhone 16 Pro - 2025-12-30 at 19.57.35.png", source: "Simulator Screenshot - iPhone 16e - 2026-01-08 at 19.06.40.png",
headline: "Swipe on Questions", headline: "Swipe on Questions",
subheadline: "Answer yes, no, or maybe together", subheadline: "Answer yes, no, or maybe together",
outputName: "03-swipe", outputName: "03-swipe",
}, },
{ {
source: "Simulator Screenshot - iPhone 16 Pro - 2025-12-30 at 21.00.55.png", source: "Simulator Screenshot - iPhone 16e - 2026-01-08 at 19.07.29.png",
headline: "Express Yourself",
subheadline: "Swipe right on what excites you",
outputName: "04-swipe-yes",
},
{
source: "Simulator Screenshot - iPhone 16 Pro - 2025-12-30 at 19.57.51.png",
headline: "It's a Match!", headline: "It's a Match!",
subheadline: "Celebrate when you both say yes", subheadline: "Celebrate when you both say yes",
outputName: "05-match", outputName: "04-match",
}, },
{ {
source: "Simulator Screenshot - iPhone 16 Pro - 2025-12-30 at 20.02.50.png", source: "Simulator Screenshot - iPhone 16e - 2026-01-08 at 19.09.54.png",
headline: "Discover Your Matches", headline: "Discover Your Matches",
subheadline: "See where you both agree", subheadline: "See where you both agree",
outputName: "06-home-matches", outputName: "05-matches",
}, },
{ {
source: "Simulator Screenshot - iPhone 16 Pro - 2025-12-30 at 20.01.31.png", source: "Simulator Screenshot - iPhone 16e - 2026-01-08 at 19.08.25.png",
headline: "Chat About It", headline: "Chat About It",
subheadline: "Every match unlocks a conversation", subheadline: "Every match unlocks a conversation",
outputName: "07-chat", outputName: "06-chat",
},
{
source: "Simulator Screenshot - iPhone 16e - 2026-01-08 at 19.09.48.png",
headline: "Set Your Comfort Zone",
subheadline: "Customize your intimacy preferences",
outputName: "07-settings",
}, },
]; ];
Binary file not shown.

Before

Width:  |  Height:  |  Size: 629 KiB

After

Width:  |  Height:  |  Size: 658 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 858 KiB

After

Width:  |  Height:  |  Size: 837 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 852 KiB

After

Width:  |  Height:  |  Size: 757 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 567 KiB

After

Width:  |  Height:  |  Size: 561 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 757 KiB

After

Width:  |  Height:  |  Size: 683 KiB

Loaded 100 of 128 files, more files were not shown because too many files have changed in this diff. Show more