mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-11-02 18:19:44 +00:00
Compare commits
5
Commits
e23f4557bb
..
v1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c48936686e | ||
|
|
43fa7033dc | ||
|
|
953bd7c03d | ||
|
|
4b8f4315e9 | ||
|
|
0a919f06fc |
No files matched your search
@@ -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 |
|
|
||||||
|
|
||||||
@@ -30,9 +30,11 @@
|
|||||||
"emoji-mart": "^5.6.0",
|
"emoji-mart": "^5.6.0",
|
||||||
"lucide-react": "^0.309.0",
|
"lucide-react": "^0.309.0",
|
||||||
"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-router-dom": "^6.21.0",
|
"react-icons": "^4.12.0",
|
||||||
|
"react-router-dom": "^6.21.0",
|
||||||
|
|
||||||
"recharts": "^3.6.0",
|
"recharts": "^3.6.0",
|
||||||
"sonner": "^1.3.1",
|
"sonner": "^1.3.1",
|
||||||
"tailwind-merge": "^2.2.0"
|
"tailwind-merge": "^2.2.0"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,231 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import type { IconType } from 'react-icons';
|
||||||
|
import {
|
||||||
|
IoHeartOutline,
|
||||||
|
IoHeartHalfOutline,
|
||||||
|
IoHeartCircleOutline,
|
||||||
|
IoChatbubblesOutline,
|
||||||
|
IoChatboxOutline,
|
||||||
|
IoMailOutline,
|
||||||
|
IoFlowerOutline,
|
||||||
|
IoWineOutline,
|
||||||
|
IoRestaurantOutline,
|
||||||
|
IoCafeOutline,
|
||||||
|
IoAirplaneOutline,
|
||||||
|
IoCarOutline,
|
||||||
|
IoCompassOutline,
|
||||||
|
IoMapOutline,
|
||||||
|
IoHomeOutline,
|
||||||
|
IoPeopleOutline,
|
||||||
|
IoPersonOutline,
|
||||||
|
IoEyeOffOutline,
|
||||||
|
IoKeyOutline,
|
||||||
|
IoLockClosedOutline,
|
||||||
|
IoDiceOutline,
|
||||||
|
IoGiftOutline,
|
||||||
|
IoSparklesOutline,
|
||||||
|
IoStarOutline,
|
||||||
|
IoFlameOutline,
|
||||||
|
IoFlashOutline,
|
||||||
|
IoMoonOutline,
|
||||||
|
IoSunnyOutline,
|
||||||
|
IoFlagOutline,
|
||||||
|
IoCalendarOutline,
|
||||||
|
IoCheckboxOutline,
|
||||||
|
IoTrophyOutline,
|
||||||
|
IoLayersOutline,
|
||||||
|
IoCubeOutline,
|
||||||
|
IoFolderOutline,
|
||||||
|
IoBookmarkOutline,
|
||||||
|
IoBulbOutline,
|
||||||
|
IoColorWandOutline,
|
||||||
|
IoSyncOutline,
|
||||||
|
IoRefreshOutline,
|
||||||
|
IoBriefcaseOutline,
|
||||||
|
} from 'react-icons/io5';
|
||||||
|
import { Button } from './button';
|
||||||
|
import { Input } from './input';
|
||||||
|
|
||||||
|
interface IconOption {
|
||||||
|
name: string;
|
||||||
|
Icon: IconType;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ICON_OPTIONS: IconOption[] = [
|
||||||
|
// Relationships & Love
|
||||||
|
{ name: 'heart-outline', Icon: IoHeartOutline },
|
||||||
|
{ name: 'heart-half-outline', Icon: IoHeartHalfOutline },
|
||||||
|
{ name: 'heart-circle-outline', Icon: IoHeartCircleOutline },
|
||||||
|
|
||||||
|
// Communication
|
||||||
|
{ name: 'chatbubbles-outline', Icon: IoChatbubblesOutline },
|
||||||
|
{ name: 'chatbox-outline', Icon: IoChatboxOutline },
|
||||||
|
{ name: 'mail-outline', Icon: IoMailOutline },
|
||||||
|
|
||||||
|
// Romance & Dates
|
||||||
|
{ name: 'flower-outline', Icon: IoFlowerOutline },
|
||||||
|
{ name: 'wine-outline', Icon: IoWineOutline },
|
||||||
|
{ name: 'restaurant-outline', Icon: IoRestaurantOutline },
|
||||||
|
{ name: 'cafe-outline', Icon: IoCafeOutline },
|
||||||
|
|
||||||
|
// Adventure & Travel
|
||||||
|
{ name: 'airplane-outline', Icon: IoAirplaneOutline },
|
||||||
|
{ name: 'car-outline', Icon: IoCarOutline },
|
||||||
|
{ name: 'compass-outline', Icon: IoCompassOutline },
|
||||||
|
{ name: 'map-outline', Icon: IoMapOutline },
|
||||||
|
|
||||||
|
// Home & Family
|
||||||
|
{ name: 'home-outline', Icon: IoHomeOutline },
|
||||||
|
{ name: 'people-outline', Icon: IoPeopleOutline },
|
||||||
|
{ name: 'person-outline', Icon: IoPersonOutline },
|
||||||
|
|
||||||
|
// Mystery & Secrets
|
||||||
|
{ name: 'eye-off-outline', Icon: IoEyeOffOutline },
|
||||||
|
{ name: 'key-outline', Icon: IoKeyOutline },
|
||||||
|
{ name: 'lock-closed-outline', Icon: IoLockClosedOutline },
|
||||||
|
|
||||||
|
// Fun & Games
|
||||||
|
{ name: 'dice-outline', Icon: IoDiceOutline },
|
||||||
|
{ name: 'gift-outline', Icon: IoGiftOutline },
|
||||||
|
{ name: 'sparkles-outline', Icon: IoSparklesOutline },
|
||||||
|
{ name: 'star-outline', Icon: IoStarOutline },
|
||||||
|
|
||||||
|
// Intimacy
|
||||||
|
{ name: 'flame-outline', Icon: IoFlameOutline },
|
||||||
|
{ name: 'flash-outline', Icon: IoFlashOutline },
|
||||||
|
{ name: 'moon-outline', Icon: IoMoonOutline },
|
||||||
|
{ name: 'sunny-outline', Icon: IoSunnyOutline },
|
||||||
|
|
||||||
|
// Goals & Planning
|
||||||
|
{ name: 'flag-outline', Icon: IoFlagOutline },
|
||||||
|
{ name: 'calendar-outline', Icon: IoCalendarOutline },
|
||||||
|
{ name: 'checkbox-outline', Icon: IoCheckboxOutline },
|
||||||
|
{ name: 'trophy-outline', Icon: IoTrophyOutline },
|
||||||
|
|
||||||
|
// General
|
||||||
|
{ name: 'layers-outline', Icon: IoLayersOutline },
|
||||||
|
{ name: 'cube-outline', Icon: IoCubeOutline },
|
||||||
|
{ name: 'folder-outline', Icon: IoFolderOutline },
|
||||||
|
{ name: 'bookmark-outline', Icon: IoBookmarkOutline },
|
||||||
|
{ name: 'bulb-outline', Icon: IoBulbOutline },
|
||||||
|
{ name: 'color-wand-outline', Icon: IoColorWandOutline },
|
||||||
|
{ name: 'sync-outline', Icon: IoSyncOutline },
|
||||||
|
{ name: 'refresh-outline', Icon: IoRefreshOutline },
|
||||||
|
{ name: 'briefcase-outline', Icon: IoBriefcaseOutline },
|
||||||
|
];
|
||||||
|
|
||||||
|
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 SelectedIcon = ICONS_BY_NAME[value]?.Icon;
|
||||||
|
const buttonLabel = SelectedIcon ? 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}".`;
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ 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 {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -56,11 +56,11 @@ export function CategoriesPage() {
|
|||||||
|
|
||||||
// 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',
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -118,7 +118,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',
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -261,9 +261,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 +363,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
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ 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
|
||||||
@@ -65,7 +66,7 @@ export function PacksPage() {
|
|||||||
{
|
{
|
||||||
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 +74,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,
|
||||||
@@ -265,7 +266,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 +332,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 +440,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 && (
|
||||||
|
|||||||
@@ -23,8 +23,8 @@
|
|||||||
"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."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -25,13 +25,14 @@ import Animated, {
|
|||||||
import { GradientBackground } from '../../src/components/ui/GradientBackground';
|
import { GradientBackground } from '../../src/components/ui/GradientBackground';
|
||||||
import { GlassCard } from '../../src/components/ui/GlassCard';
|
import { GlassCard } from '../../src/components/ui/GlassCard';
|
||||||
import { GlassButton } from '../../src/components/ui/GlassButton';
|
import { GlassButton } from '../../src/components/ui/GlassButton';
|
||||||
|
import { IntensitySlider } from '../../src/components/ui/IntensitySlider';
|
||||||
import { colors, gradients, spacing, typography, radius, shadows } from '../../src/theme';
|
import { colors, gradients, spacing, typography, radius, shadows } from '../../src/theme';
|
||||||
import { useAuthStore } from '../../src/store';
|
import { useAuthStore } from '../../src/store';
|
||||||
import { supabase } from '../../src/lib/supabase';
|
import { supabase } from '../../src/lib/supabase';
|
||||||
import { getProfileError } from '../../src/lib/errors';
|
import { getProfileError } from '../../src/lib/errors';
|
||||||
import { registerForPushNotificationsAsync, savePushToken } from '../../src/lib/notifications';
|
import { registerForPushNotificationsAsync, savePushToken } from '../../src/lib/notifications';
|
||||||
import { Events } from '../../src/lib/analytics';
|
import { Events } from '../../src/lib/analytics';
|
||||||
import type { Gender } from '../../src/types';
|
import type { Gender, IntensityLevel } from '../../src/types';
|
||||||
|
|
||||||
const GENDER_OPTIONS: { value: Gender; label: string; icon: string }[] = [
|
const GENDER_OPTIONS: { value: Gender; label: string; icon: string }[] = [
|
||||||
{ value: 'male', label: 'Male', icon: 'male' },
|
{ value: 'male', label: 'Male', icon: 'male' },
|
||||||
@@ -50,6 +51,19 @@ const PURPOSE_OPTIONS: { value: UsageReason; label: string; icon: string }[] = [
|
|||||||
{ value: 'strengthen_relationship', label: 'Strengthen our relationship', icon: 'shield-checkmark' },
|
{ value: 'strengthen_relationship', label: 'Strengthen our relationship', icon: 'shield-checkmark' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const DEFAULT_MAX_INTENSITY: IntensityLevel = 2;
|
||||||
|
|
||||||
|
const normalizeIntensity = (value: number | null | undefined): IntensityLevel | null => {
|
||||||
|
if (value === 1 || value === 2 || value === 3 || value === 4 || value === 5) return value;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getInitialIntensity = (profile?: { max_intensity?: number | null; show_explicit_content?: boolean | null } | null): IntensityLevel => {
|
||||||
|
const normalized = normalizeIntensity(profile?.max_intensity);
|
||||||
|
if (normalized) return normalized;
|
||||||
|
return profile?.show_explicit_content ? 5 : DEFAULT_MAX_INTENSITY;
|
||||||
|
};
|
||||||
|
|
||||||
type Stage = 'name' | 'gender' | 'purpose' | 'explicit' | 'notifications';
|
type Stage = 'name' | 'gender' | 'purpose' | 'explicit' | 'notifications';
|
||||||
|
|
||||||
export default function OnboardingScreen() {
|
export default function OnboardingScreen() {
|
||||||
@@ -59,7 +73,7 @@ export default function OnboardingScreen() {
|
|||||||
const [name, setName] = useState(user?.name || '');
|
const [name, setName] = useState(user?.name || '');
|
||||||
const [gender, setGender] = useState<Gender | null>(user?.gender || null);
|
const [gender, setGender] = useState<Gender | null>(user?.gender || null);
|
||||||
const [usageReason, setUsageReason] = useState<UsageReason | null>(null);
|
const [usageReason, setUsageReason] = useState<UsageReason | null>(null);
|
||||||
const [showExplicit, setShowExplicit] = useState(user?.show_explicit_content ?? true);
|
const [maxIntensity, setMaxIntensity] = useState<IntensityLevel>(() => getInitialIntensity(user));
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const inputRef = useRef<TextInput>(null);
|
const inputRef = useRef<TextInput>(null);
|
||||||
@@ -107,7 +121,7 @@ export default function OnboardingScreen() {
|
|||||||
Events.onboardingStageComplete('purpose');
|
Events.onboardingStageComplete('purpose');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleExplicitContinue = () => {
|
const handleIntensityContinue = () => {
|
||||||
setStage('notifications');
|
setStage('notifications');
|
||||||
Events.onboardingStageComplete('explicit');
|
Events.onboardingStageComplete('explicit');
|
||||||
};
|
};
|
||||||
@@ -139,13 +153,16 @@ export default function OnboardingScreen() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const showExplicitContent = maxIntensity >= 3;
|
||||||
|
|
||||||
const { error: updateError } = await supabase
|
const { error: updateError } = await supabase
|
||||||
.from('profiles')
|
.from('profiles')
|
||||||
.update({
|
.update({
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
gender,
|
gender,
|
||||||
usage_reason: usageReason,
|
usage_reason: usageReason,
|
||||||
show_explicit_content: showExplicit,
|
max_intensity: maxIntensity,
|
||||||
|
show_explicit_content: showExplicitContent,
|
||||||
onboarding_completed: true,
|
onboarding_completed: true,
|
||||||
updated_at: new Date().toISOString(),
|
updated_at: new Date().toISOString(),
|
||||||
})
|
})
|
||||||
@@ -159,7 +176,7 @@ export default function OnboardingScreen() {
|
|||||||
console.log('[Onboarding] Profile updated successfully, fetching user...');
|
console.log('[Onboarding] Profile updated successfully, fetching user...');
|
||||||
await fetchUser();
|
await fetchUser();
|
||||||
console.log('[Onboarding] User fetched, navigating to home...');
|
console.log('[Onboarding] User fetched, navigating to home...');
|
||||||
Events.profileUpdated(["name", "gender", "usage_reason", "show_explicit_content"]);
|
Events.profileUpdated(["name", "gender", "usage_reason", "max_intensity", "show_explicit_content"]);
|
||||||
Events.onboardingComplete();
|
Events.onboardingComplete();
|
||||||
router.replace('/');
|
router.replace('/');
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -379,81 +396,22 @@ export default function OnboardingScreen() {
|
|||||||
colors={gradients.primary as [string, string]}
|
colors={gradients.primary as [string, string]}
|
||||||
style={styles.iconContainer}
|
style={styles.iconContainer}
|
||||||
>
|
>
|
||||||
<Ionicons name="flame" size={40} color={colors.text} />
|
<Ionicons name="heart-circle" size={44} color={colors.text} />
|
||||||
</LinearGradient>
|
</LinearGradient>
|
||||||
<Text style={styles.title}>Almost there!</Text>
|
<Text style={styles.title}>Set Your Comfort Zone</Text>
|
||||||
<Text style={styles.subtitle}>
|
<Text style={styles.subtitle}>
|
||||||
Shall we keep it clean or show you everything?
|
Choose the intimacy level you're comfortable with
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<GlassCard style={styles.card}>
|
<GlassCard style={styles.card}>
|
||||||
<Pressable
|
<IntensitySlider
|
||||||
style={styles.explicitOption}
|
value={maxIntensity}
|
||||||
onPress={() => setShowExplicit(true)}
|
onValueChange={setMaxIntensity}
|
||||||
>
|
/>
|
||||||
<View style={[
|
<Text style={styles.intensityNote}>
|
||||||
styles.explicitIcon,
|
Start where you're comfortable — you can always adjust later
|
||||||
showExplicit && styles.explicitIconSelected
|
</Text>
|
||||||
]}>
|
|
||||||
<Ionicons
|
|
||||||
name="flame"
|
|
||||||
size={32}
|
|
||||||
color={showExplicit ? colors.primary : colors.textSecondary}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<View style={styles.explicitText}>
|
|
||||||
<Text style={[
|
|
||||||
styles.explicitTitle,
|
|
||||||
showExplicit && styles.explicitTitleSelected
|
|
||||||
]}>
|
|
||||||
Show me everything
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.explicitDescription}>
|
|
||||||
Include spicy question packs
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View style={[
|
|
||||||
styles.radioOuter,
|
|
||||||
showExplicit && styles.radioOuterSelected
|
|
||||||
]}>
|
|
||||||
{showExplicit && <View style={styles.radioInner} />}
|
|
||||||
</View>
|
|
||||||
</Pressable>
|
|
||||||
|
|
||||||
<Pressable
|
|
||||||
style={styles.explicitOption}
|
|
||||||
onPress={() => setShowExplicit(false)}
|
|
||||||
>
|
|
||||||
<View style={[
|
|
||||||
styles.explicitIcon,
|
|
||||||
!showExplicit && styles.explicitIconSelected
|
|
||||||
]}>
|
|
||||||
<Ionicons
|
|
||||||
name="heart"
|
|
||||||
size={32}
|
|
||||||
color={!showExplicit ? colors.primary : colors.textSecondary}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<View style={styles.explicitText}>
|
|
||||||
<Text style={[
|
|
||||||
styles.explicitTitle,
|
|
||||||
!showExplicit && styles.explicitTitleSelected
|
|
||||||
]}>
|
|
||||||
Keep it clean
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.explicitDescription}>
|
|
||||||
Only show SFW content
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View style={[
|
|
||||||
styles.radioOuter,
|
|
||||||
!showExplicit && styles.radioOuterSelected
|
|
||||||
]}>
|
|
||||||
{!showExplicit && <View style={styles.radioInner} />}
|
|
||||||
</View>
|
|
||||||
</Pressable>
|
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<View style={styles.errorContainer}>
|
<View style={styles.errorContainer}>
|
||||||
<Ionicons name="alert-circle" size={16} color={colors.error} />
|
<Ionicons name="alert-circle" size={16} color={colors.error} />
|
||||||
@@ -464,7 +422,7 @@ export default function OnboardingScreen() {
|
|||||||
|
|
||||||
<View style={styles.footer}>
|
<View style={styles.footer}>
|
||||||
<GlassButton
|
<GlassButton
|
||||||
onPress={handleExplicitContinue}
|
onPress={handleIntensityContinue}
|
||||||
fullWidth
|
fullWidth
|
||||||
size="lg"
|
size="lg"
|
||||||
>
|
>
|
||||||
@@ -762,42 +720,11 @@ const styles = StyleSheet.create({
|
|||||||
color: colors.text,
|
color: colors.text,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
explicitOption: {
|
intensityNote: {
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
backgroundColor: colors.glass.background,
|
|
||||||
borderRadius: radius.lg,
|
|
||||||
borderWidth: 2,
|
|
||||||
borderColor: colors.glass.border,
|
|
||||||
padding: spacing.md,
|
|
||||||
marginBottom: spacing.sm,
|
|
||||||
},
|
|
||||||
explicitIcon: {
|
|
||||||
width: 56,
|
|
||||||
height: 56,
|
|
||||||
borderRadius: 28,
|
|
||||||
backgroundColor: colors.glass.background,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
marginRight: spacing.md,
|
|
||||||
},
|
|
||||||
explicitIconSelected: {
|
|
||||||
backgroundColor: colors.primaryLight,
|
|
||||||
},
|
|
||||||
explicitText: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
explicitTitle: {
|
|
||||||
...typography.headline,
|
|
||||||
color: colors.textSecondary,
|
|
||||||
marginBottom: spacing.xs,
|
|
||||||
},
|
|
||||||
explicitTitleSelected: {
|
|
||||||
color: colors.text,
|
|
||||||
},
|
|
||||||
explicitDescription: {
|
|
||||||
...typography.caption1,
|
...typography.caption1,
|
||||||
color: colors.textTertiary,
|
color: colors.textSecondary,
|
||||||
|
textAlign: 'center',
|
||||||
|
marginTop: spacing.md,
|
||||||
},
|
},
|
||||||
radioOuter: {
|
radioOuter: {
|
||||||
width: 24,
|
width: 24,
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -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,
|
||||||
|
|||||||
+26
-30
@@ -2,36 +2,32 @@ import { Redirect } from "expo-router";
|
|||||||
import { useAuthStore } from "../src/store";
|
import { useAuthStore } from "../src/store";
|
||||||
import { View, ActivityIndicator, StyleSheet } from "react-native";
|
import { View, ActivityIndicator, StyleSheet } from "react-native";
|
||||||
|
|
||||||
export default function Index() {
|
export default function Index() {
|
||||||
const { isAuthenticated, isLoading, user } = useAuthStore();
|
const { isAuthenticated, isLoading, user } = useAuthStore();
|
||||||
|
|
||||||
// Show loading state
|
// Show loading state while auth is being determined
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<ActivityIndicator size="large" color="#e94560" />
|
<ActivityIndicator size="large" color="#e94560" />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Redirect based on auth state
|
// Redirect based on auth state
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
return <Redirect href="/(auth)/login" />;
|
return <Redirect href="/(auth)/login" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If user hasn't completed onboarding, go to onboarding first
|
// If user hasn't completed onboarding, go to onboarding first
|
||||||
if (!user?.onboarding_completed) {
|
if (!user?.onboarding_completed) {
|
||||||
return <Redirect href="/(app)/onboarding" />;
|
return <Redirect href="/(app)/onboarding" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If user has no couple, go to pairing
|
// Go to home/dashboard - users can pair from within the app
|
||||||
if (!user?.couple_id) {
|
// No forced redirect to pairing for users without a couple
|
||||||
return <Redirect href="/(app)/pairing" />;
|
return <Redirect href="/(app)" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise go to home
|
|
||||||
return <Redirect href="/(app)" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import Animated, {
|
|||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import { GradientBackground, GlassCard } from "../../src/components/ui";
|
import { GradientBackground, GlassCard } from "../../src/components/ui";
|
||||||
import { colors, gradients, spacing, radius, typography } from "../../src/theme";
|
import { colors, gradients, spacing, radius, typography } from "../../src/theme";
|
||||||
|
import { getPackIconName } from "../../src/lib/packIcons";
|
||||||
import { Question } from "../../src/types";
|
import { Question } from "../../src/types";
|
||||||
|
|
||||||
type TooltipType = 'initiator' | 'coupleType' | null;
|
type TooltipType = 'initiator' | 'coupleType' | null;
|
||||||
@@ -259,7 +260,11 @@ export default function PackDetailsScreen() {
|
|||||||
style={StyleSheet.absoluteFill}
|
style={StyleSheet.absoluteFill}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Text style={styles.emoji}>{pack?.icon || "📦"}</Text>
|
<Ionicons
|
||||||
|
name={getPackIconName(pack?.icon)}
|
||||||
|
size={32}
|
||||||
|
color={pack?.is_premium ? colors.premium.gold : colors.textSecondary}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
{/* Crown for premium */}
|
{/* Crown for premium */}
|
||||||
{pack?.is_premium && (
|
{pack?.is_premium && (
|
||||||
@@ -575,9 +580,6 @@ const styles = StyleSheet.create({
|
|||||||
shadowRadius: 4,
|
shadowRadius: 4,
|
||||||
elevation: 4,
|
elevation: 4,
|
||||||
},
|
},
|
||||||
emoji: {
|
|
||||||
fontSize: 36,
|
|
||||||
},
|
|
||||||
title: {
|
title: {
|
||||||
...typography.largeTitle,
|
...typography.largeTitle,
|
||||||
color: colors.text,
|
color: colors.text,
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -6,6 +6,7 @@ function createThenableQuery(result: any) {
|
|||||||
const query: any = {
|
const query: any = {
|
||||||
select: jest.fn(() => query),
|
select: jest.fn(() => query),
|
||||||
eq: jest.fn(() => query),
|
eq: jest.fn(() => query),
|
||||||
|
or: jest.fn(() => query),
|
||||||
order: jest.fn(() => query),
|
order: jest.fn(() => query),
|
||||||
upsert: jest.fn(() => query),
|
upsert: jest.fn(() => query),
|
||||||
then: (resolve: any, reject: any) => Promise.resolve(result).then(resolve, reject),
|
then: (resolve: any, reject: any) => Promise.resolve(result).then(resolve, reject),
|
||||||
@@ -17,10 +18,10 @@ describe('packsStore', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
usePacksStore.setState({ packs: [], categories: [], enabledPackIds: [], isLoading: false } as any);
|
usePacksStore.setState({ packs: [], categories: [], enabledPackIds: [], isLoading: false } as any);
|
||||||
useAuthStore.setState({ user: { id: 'me', couple_id: 'c1', show_explicit_content: false } } as any);
|
useAuthStore.setState({ user: { id: 'me', couple_id: 'c1', max_intensity: 2, show_explicit_content: false } } as any);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('fetches categories/packs and filters explicit when preference disabled', async () => {
|
it('fetches categories/packs and filters by max intensity', async () => {
|
||||||
const categoriesQuery = createThenableQuery({ data: [{ id: 'cat1' }] });
|
const categoriesQuery = createThenableQuery({ data: [{ id: 'cat1' }] });
|
||||||
const packsQuery = createThenableQuery({ data: [{ id: 'pack1', is_explicit: false }] });
|
const packsQuery = createThenableQuery({ data: [{ id: 'pack1', is_explicit: false }] });
|
||||||
const enabledQuery = createThenableQuery({ data: [{ pack_id: 'pack1' }] });
|
const enabledQuery = createThenableQuery({ data: [{ pack_id: 'pack1' }] });
|
||||||
@@ -32,8 +33,7 @@ describe('packsStore', () => {
|
|||||||
|
|
||||||
await usePacksStore.getState().fetchPacks();
|
await usePacksStore.getState().fetchPacks();
|
||||||
|
|
||||||
// Ensure explicit filter applied
|
expect(packsQuery.or).toHaveBeenCalledWith('max_intensity.is.null,max_intensity.lte.2');
|
||||||
expect(packsQuery.eq).toHaveBeenCalledWith('is_explicit', false);
|
|
||||||
|
|
||||||
const state = usePacksStore.getState();
|
const state = usePacksStore.getState();
|
||||||
expect(state.categories).toEqual([{ id: 'cat1' }]);
|
expect(state.categories).toEqual([{ id: 'cat1' }]);
|
||||||
|
|||||||
@@ -1,196 +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 } 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');
|
|
||||||
|
|
||||||
// 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}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -19,6 +19,7 @@ import Animated, {
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { LinearGradient } from "expo-linear-gradient";
|
import { LinearGradient } from "expo-linear-gradient";
|
||||||
import { supabase } from "../lib/supabase";
|
import { supabase } from "../lib/supabase";
|
||||||
|
import { getPackIconName } from "../lib/packIcons";
|
||||||
import { colors, gradients, spacing, radius, typography } from "../theme";
|
import { colors, gradients, spacing, radius, typography } from "../theme";
|
||||||
|
|
||||||
interface PackTeaserProps {
|
interface PackTeaserProps {
|
||||||
@@ -159,7 +160,11 @@ export function PackTeaser({
|
|||||||
colors={['rgba(212, 175, 55, 0.15)', 'rgba(184, 134, 11, 0.1)']}
|
colors={['rgba(212, 175, 55, 0.15)', 'rgba(184, 134, 11, 0.1)']}
|
||||||
style={StyleSheet.absoluteFill}
|
style={StyleSheet.absoluteFill}
|
||||||
/>
|
/>
|
||||||
<Text style={styles.packEmoji}>{packIcon || "📦"}</Text>
|
<Ionicons
|
||||||
|
name={getPackIconName(packIcon)}
|
||||||
|
size={36}
|
||||||
|
color={colors.premium.gold}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
{/* Crown badge */}
|
{/* Crown badge */}
|
||||||
<View style={styles.crownBadge}>
|
<View style={styles.crownBadge}>
|
||||||
@@ -370,9 +375,6 @@ const styles = StyleSheet.create({
|
|||||||
shadowRadius: 4,
|
shadowRadius: 4,
|
||||||
elevation: 4,
|
elevation: 4,
|
||||||
},
|
},
|
||||||
packEmoji: {
|
|
||||||
fontSize: 40,
|
|
||||||
},
|
|
||||||
title: {
|
title: {
|
||||||
...typography.title1,
|
...typography.title1,
|
||||||
color: colors.text,
|
color: colors.text,
|
||||||
|
|||||||
@@ -0,0 +1,646 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { View, Text, StyleSheet, Pressable, Platform, Image, Modal, TouchableOpacity } from 'react-native';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import { BlurView } from 'expo-blur';
|
||||||
|
import Animated, {
|
||||||
|
useAnimatedStyle,
|
||||||
|
useSharedValue,
|
||||||
|
withSpring,
|
||||||
|
FadeIn,
|
||||||
|
FadeOut,
|
||||||
|
} from 'react-native-reanimated';
|
||||||
|
import { colors, spacing, radius, typography } from '../../theme';
|
||||||
|
import type { IntensityLevel } from '@/types';
|
||||||
|
|
||||||
|
export interface IntensitySliderProps {
|
||||||
|
value: IntensityLevel;
|
||||||
|
onValueChange: (value: IntensityLevel) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
/** Partner's intensity level (shows as a ghost indicator) */
|
||||||
|
partnerValue?: IntensityLevel | null;
|
||||||
|
/** Partner's name for the mismatch message */
|
||||||
|
partnerName?: string;
|
||||||
|
/** Partner's avatar URL */
|
||||||
|
partnerAvatar?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const INTENSITY_LEVELS: Array<{ level: IntensityLevel; label: string; emoji: string; description: string }> = [
|
||||||
|
{ level: 1, label: 'Gentle', emoji: '💭', description: 'Conversations & emotional connection' },
|
||||||
|
{ level: 2, label: 'Warm', emoji: '💕', description: 'Cuddling, kisses & flirty moments' },
|
||||||
|
{ level: 3, label: 'Playful', emoji: '😏', description: 'Making out & suggestive content' },
|
||||||
|
{ level: 4, label: 'Steamy', emoji: '🔥', description: 'Intimate activities & explicit content' },
|
||||||
|
{ level: 5, label: 'Intense', emoji: '🌶️', description: 'Advanced intimacy & fantasies' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const HEAT_COLORS = [
|
||||||
|
'#9b59b6', // Purple - gentle
|
||||||
|
'#e94560', // Rose - warm
|
||||||
|
'#ff6b6b', // Coral - playful
|
||||||
|
'#ff4757', // Red - steamy
|
||||||
|
'#ff3333', // Bright red - intense
|
||||||
|
];
|
||||||
|
|
||||||
|
const triggerHaptic = async () => {
|
||||||
|
if (Platform.OS === 'web') return;
|
||||||
|
const Haptics = await import('expo-haptics');
|
||||||
|
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function IntensitySlider({
|
||||||
|
value,
|
||||||
|
onValueChange,
|
||||||
|
disabled = false,
|
||||||
|
partnerValue,
|
||||||
|
partnerName,
|
||||||
|
partnerAvatar,
|
||||||
|
}: IntensitySliderProps) {
|
||||||
|
const current = INTENSITY_LEVELS[value - 1] ?? INTENSITY_LEVELS[1];
|
||||||
|
const progress = useSharedValue((value - 1) / 4);
|
||||||
|
const [showInfoModal, setShowInfoModal] = useState(false);
|
||||||
|
|
||||||
|
const hasPartner = partnerValue != null;
|
||||||
|
const partnerLevel = hasPartner ? INTENSITY_LEVELS[partnerValue - 1] : null;
|
||||||
|
const levelsDiffer = hasPartner && value !== partnerValue;
|
||||||
|
const userIsHigher = hasPartner && value > partnerValue;
|
||||||
|
const displayPartnerName = partnerName || 'Your partner';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
progress.value = withSpring((value - 1) / 4, { damping: 15, stiffness: 150 });
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
const handleSelect = async (nextValue: IntensityLevel) => {
|
||||||
|
if (disabled || nextValue === value) return;
|
||||||
|
await triggerHaptic();
|
||||||
|
onValueChange(nextValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
const progressStyle = useAnimatedStyle(() => ({
|
||||||
|
width: `${progress.value * 100}%`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const getMismatchMessage = () => {
|
||||||
|
if (!levelsDiffer || !partnerLevel) return null;
|
||||||
|
if (userIsHigher) {
|
||||||
|
return `${displayPartnerName} is at ${partnerLevel.label} — tap for details`;
|
||||||
|
}
|
||||||
|
return `${displayPartnerName} is at ${partnerLevel.label}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.container, disabled && styles.containerDisabled]}>
|
||||||
|
{/* Progress track */}
|
||||||
|
<View style={styles.trackContainer}>
|
||||||
|
<View style={styles.track}>
|
||||||
|
<Animated.View style={[styles.progressFill, progressStyle]}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={['#9b59b6', HEAT_COLORS[value - 1]]}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 0 }}
|
||||||
|
style={StyleSheet.absoluteFill}
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Level indicators */}
|
||||||
|
<View style={styles.levelsRow}>
|
||||||
|
{INTENSITY_LEVELS.map((level) => {
|
||||||
|
const isSelected = value === level.level;
|
||||||
|
const isPassed = value > level.level;
|
||||||
|
const isActive = isSelected || isPassed;
|
||||||
|
const isPartnerLevel = hasPartner && partnerValue === level.level;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={level.level}
|
||||||
|
style={styles.levelButton}
|
||||||
|
onPress={() => handleSelect(level.level)}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<View style={styles.indicatorWrapper}>
|
||||||
|
{/* Partner indicator ring */}
|
||||||
|
{isPartnerLevel && !isSelected && (
|
||||||
|
<View style={styles.partnerRing}>
|
||||||
|
{partnerAvatar ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: partnerAvatar }}
|
||||||
|
style={styles.partnerAvatarSmall}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Ionicons name="heart" size={8} color={colors.secondary} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<View style={[
|
||||||
|
styles.levelIndicator,
|
||||||
|
isActive && styles.levelIndicatorActive,
|
||||||
|
isSelected && {
|
||||||
|
backgroundColor: HEAT_COLORS[level.level - 1],
|
||||||
|
borderColor: HEAT_COLORS[level.level - 1],
|
||||||
|
},
|
||||||
|
isPartnerLevel && !isSelected && styles.levelIndicatorPartner,
|
||||||
|
]}>
|
||||||
|
{isSelected ? (
|
||||||
|
<Text style={styles.levelEmoji}>{level.emoji}</Text>
|
||||||
|
) : (
|
||||||
|
<Ionicons
|
||||||
|
name={isActive ? 'flame' : 'flame-outline'}
|
||||||
|
size={16}
|
||||||
|
color={isActive ? colors.text : colors.textTertiary}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<Text style={[
|
||||||
|
styles.levelLabel,
|
||||||
|
isSelected && styles.levelLabelActive,
|
||||||
|
isPartnerLevel && !isSelected && styles.levelLabelPartner,
|
||||||
|
]}>
|
||||||
|
{level.label}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Current level card */}
|
||||||
|
<View style={[styles.currentCard, levelsDiffer && userIsHigher && styles.currentCardWarning]}>
|
||||||
|
<View style={styles.currentHeader}>
|
||||||
|
<Text style={styles.currentEmoji}>{current.emoji}</Text>
|
||||||
|
<Text style={styles.currentLabel}>{current.label}</Text>
|
||||||
|
{hasPartner && !levelsDiffer && (
|
||||||
|
<Pressable
|
||||||
|
style={styles.matchBadge}
|
||||||
|
onPress={() => setShowInfoModal(true)}
|
||||||
|
>
|
||||||
|
<Ionicons name="checkmark-circle" size={14} color={colors.success} />
|
||||||
|
<Text style={styles.matchBadgeText}>Matched</Text>
|
||||||
|
<Ionicons name="information-circle-outline" size={12} color={colors.success} style={{ marginLeft: 2 }} />
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Text style={styles.currentDescription}>{current.description}</Text>
|
||||||
|
|
||||||
|
{/* Mismatch message */}
|
||||||
|
{levelsDiffer && (
|
||||||
|
<Pressable
|
||||||
|
style={styles.mismatchRow}
|
||||||
|
onPress={() => setShowInfoModal(true)}
|
||||||
|
>
|
||||||
|
{partnerAvatar ? (
|
||||||
|
<Image source={{ uri: partnerAvatar }} style={styles.partnerAvatarTiny} />
|
||||||
|
) : (
|
||||||
|
<View style={styles.partnerIconTiny}>
|
||||||
|
<Ionicons name="person" size={10} color={colors.secondary} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<Text style={[
|
||||||
|
styles.mismatchText,
|
||||||
|
userIsHigher && styles.mismatchTextWarning,
|
||||||
|
]}>
|
||||||
|
{getMismatchMessage()}
|
||||||
|
</Text>
|
||||||
|
<Ionicons
|
||||||
|
name="chevron-forward"
|
||||||
|
size={14}
|
||||||
|
color={userIsHigher ? colors.warning : colors.textSecondary}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Info Modal */}
|
||||||
|
<Modal
|
||||||
|
visible={showInfoModal}
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={() => setShowInfoModal(false)}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
style={styles.modalOverlay}
|
||||||
|
onPress={() => setShowInfoModal(false)}
|
||||||
|
>
|
||||||
|
<Pressable style={styles.modalContent} onPress={e => e.stopPropagation()}>
|
||||||
|
{Platform.OS === 'ios' ? (
|
||||||
|
<BlurView intensity={40} tint="dark" style={StyleSheet.absoluteFill} />
|
||||||
|
) : null}
|
||||||
|
<View style={styles.modalInner}>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={styles.modalHeader}>
|
||||||
|
{levelsDiffer ? (
|
||||||
|
<LinearGradient
|
||||||
|
colors={userIsHigher ? ['#F39C12', '#E67E22'] : [colors.secondary, colors.primary]}
|
||||||
|
style={styles.modalIconGradient}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={userIsHigher ? "alert-circle" : "information-circle"}
|
||||||
|
size={28}
|
||||||
|
color={colors.text}
|
||||||
|
/>
|
||||||
|
</LinearGradient>
|
||||||
|
) : (
|
||||||
|
<LinearGradient
|
||||||
|
colors={[colors.success, '#27AE60']}
|
||||||
|
style={styles.modalIconGradient}
|
||||||
|
>
|
||||||
|
<Ionicons name="heart" size={28} color={colors.text} />
|
||||||
|
</LinearGradient>
|
||||||
|
)}
|
||||||
|
<Text style={styles.modalTitle}>
|
||||||
|
{levelsDiffer
|
||||||
|
? (userIsHigher ? "Different Comfort Zones" : "You're in Sync")
|
||||||
|
: "You're Perfectly Matched"
|
||||||
|
}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Comparison visual */}
|
||||||
|
{hasPartner && (
|
||||||
|
<View style={styles.comparisonContainer}>
|
||||||
|
<View style={styles.comparisonPerson}>
|
||||||
|
<View style={[styles.comparisonAvatar, { backgroundColor: HEAT_COLORS[value - 1] }]}>
|
||||||
|
<Text style={styles.comparisonEmoji}>{current.emoji}</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.comparisonName}>You</Text>
|
||||||
|
<Text style={styles.comparisonLevel}>{current.label}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.comparisonDivider}>
|
||||||
|
<Ionicons
|
||||||
|
name={levelsDiffer ? "swap-horizontal" : "heart"}
|
||||||
|
size={20}
|
||||||
|
color={levelsDiffer ? colors.textTertiary : colors.success}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.comparisonPerson}>
|
||||||
|
{partnerAvatar ? (
|
||||||
|
<Image source={{ uri: partnerAvatar }} style={styles.comparisonAvatarImage} />
|
||||||
|
) : (
|
||||||
|
<View style={[styles.comparisonAvatar, { backgroundColor: HEAT_COLORS[(partnerValue ?? 1) - 1] }]}>
|
||||||
|
<Text style={styles.comparisonEmoji}>{partnerLevel?.emoji}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<Text style={styles.comparisonName}>{displayPartnerName}</Text>
|
||||||
|
<Text style={styles.comparisonLevel}>{partnerLevel?.label}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Explanation */}
|
||||||
|
<View style={styles.modalExplanation}>
|
||||||
|
{!levelsDiffer ? (
|
||||||
|
<>
|
||||||
|
<Text style={styles.modalText}>
|
||||||
|
You and {displayPartnerName} have the same comfort zone setting.
|
||||||
|
You'll both see the same question packs and can match on everything!
|
||||||
|
</Text>
|
||||||
|
<View style={styles.modalTip}>
|
||||||
|
<Ionicons name="sparkles" size={16} color={colors.success} />
|
||||||
|
<Text style={styles.modalTipText}>
|
||||||
|
Perfect alignment means more matches
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
) : userIsHigher ? (
|
||||||
|
<>
|
||||||
|
<Text style={styles.modalText}>
|
||||||
|
Your comfort zone is set higher than {displayPartnerName}'s.
|
||||||
|
This means some questions you see won't appear for them.
|
||||||
|
</Text>
|
||||||
|
<View style={[styles.modalTip, styles.modalTipWarning]}>
|
||||||
|
<Ionicons name="information-circle" size={16} color={colors.warning} />
|
||||||
|
<Text style={[styles.modalTipText, styles.modalTipTextWarning]}>
|
||||||
|
Swipes on {current.label} content won't match since {displayPartnerName} is at {partnerLevel?.label}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.modalSubtext}>
|
||||||
|
Consider aligning your levels, or enjoy different paces — it's your choice!
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Text style={styles.modalText}>
|
||||||
|
{displayPartnerName}'s comfort zone is higher than yours.
|
||||||
|
They might swipe on questions you don't see.
|
||||||
|
</Text>
|
||||||
|
<View style={styles.modalTip}>
|
||||||
|
<Ionicons name="shield-checkmark" size={16} color={colors.secondary} />
|
||||||
|
<Text style={styles.modalTipText}>
|
||||||
|
You'll only see content within your comfort zone
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Close button */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.modalCloseButton}
|
||||||
|
onPress={() => setShowInfoModal(false)}
|
||||||
|
>
|
||||||
|
<Text style={styles.modalCloseText}>Got it</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
</Pressable>
|
||||||
|
</Modal>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
gap: spacing.md,
|
||||||
|
},
|
||||||
|
containerDisabled: {
|
||||||
|
opacity: 0.6,
|
||||||
|
},
|
||||||
|
trackContainer: {
|
||||||
|
paddingHorizontal: spacing.lg,
|
||||||
|
},
|
||||||
|
track: {
|
||||||
|
height: 4,
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||||
|
borderRadius: 2,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
progressFill: {
|
||||||
|
height: '100%',
|
||||||
|
borderRadius: 2,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
levelsRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingHorizontal: spacing.xs,
|
||||||
|
},
|
||||||
|
levelButton: {
|
||||||
|
alignItems: 'center',
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
indicatorWrapper: {
|
||||||
|
position: 'relative',
|
||||||
|
marginBottom: spacing.xs,
|
||||||
|
},
|
||||||
|
levelIndicator: {
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: 18,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.05)',
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||||
|
},
|
||||||
|
levelIndicatorActive: {
|
||||||
|
backgroundColor: 'rgba(233, 69, 96, 0.15)',
|
||||||
|
borderColor: 'rgba(233, 69, 96, 0.3)',
|
||||||
|
},
|
||||||
|
levelIndicatorPartner: {
|
||||||
|
borderColor: colors.secondary,
|
||||||
|
borderStyle: 'dashed',
|
||||||
|
},
|
||||||
|
partnerRing: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: -6,
|
||||||
|
right: -6,
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
borderRadius: 9,
|
||||||
|
backgroundColor: colors.secondary,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
zIndex: 1,
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: colors.backgroundLight,
|
||||||
|
},
|
||||||
|
partnerAvatarSmall: {
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
borderRadius: 7,
|
||||||
|
},
|
||||||
|
levelEmoji: {
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
levelLabel: {
|
||||||
|
...typography.caption2,
|
||||||
|
color: colors.textTertiary,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
levelLabelActive: {
|
||||||
|
color: colors.text,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
levelLabelPartner: {
|
||||||
|
color: colors.secondary,
|
||||||
|
},
|
||||||
|
currentCard: {
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.05)',
|
||||||
|
borderRadius: radius.md,
|
||||||
|
padding: spacing.md,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(255, 255, 255, 0.08)',
|
||||||
|
},
|
||||||
|
currentCardWarning: {
|
||||||
|
borderColor: 'rgba(243, 156, 18, 0.3)',
|
||||||
|
backgroundColor: 'rgba(243, 156, 18, 0.05)',
|
||||||
|
},
|
||||||
|
currentHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: spacing.sm,
|
||||||
|
marginBottom: spacing.xs,
|
||||||
|
},
|
||||||
|
currentEmoji: {
|
||||||
|
fontSize: 20,
|
||||||
|
},
|
||||||
|
currentLabel: {
|
||||||
|
...typography.subhead,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: colors.text,
|
||||||
|
},
|
||||||
|
matchBadge: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 4,
|
||||||
|
marginLeft: 'auto',
|
||||||
|
backgroundColor: 'rgba(46, 204, 113, 0.15)',
|
||||||
|
paddingHorizontal: spacing.sm,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: radius.full,
|
||||||
|
},
|
||||||
|
matchBadgeText: {
|
||||||
|
...typography.caption2,
|
||||||
|
color: colors.success,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
currentDescription: {
|
||||||
|
...typography.caption1,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
lineHeight: 18,
|
||||||
|
},
|
||||||
|
mismatchRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: spacing.sm,
|
||||||
|
marginTop: spacing.sm,
|
||||||
|
paddingTop: spacing.sm,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: 'rgba(255, 255, 255, 0.08)',
|
||||||
|
},
|
||||||
|
partnerAvatarTiny: {
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
borderRadius: 10,
|
||||||
|
},
|
||||||
|
partnerIconTiny: {
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
borderRadius: 10,
|
||||||
|
backgroundColor: 'rgba(155, 89, 182, 0.2)',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
mismatchText: {
|
||||||
|
...typography.caption1,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
mismatchTextWarning: {
|
||||||
|
color: colors.warning,
|
||||||
|
},
|
||||||
|
// Modal styles
|
||||||
|
modalOverlay: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: spacing.lg,
|
||||||
|
},
|
||||||
|
modalContent: {
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: 360,
|
||||||
|
borderRadius: radius.xl,
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: Platform.OS === 'ios' ? 'transparent' : colors.backgroundLight,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||||
|
},
|
||||||
|
modalInner: {
|
||||||
|
padding: spacing.lg,
|
||||||
|
backgroundColor: Platform.OS === 'ios' ? 'rgba(26, 26, 46, 0.85)' : 'transparent',
|
||||||
|
},
|
||||||
|
modalHeader: {
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: spacing.lg,
|
||||||
|
},
|
||||||
|
modalIconGradient: {
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
borderRadius: 28,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
},
|
||||||
|
modalTitle: {
|
||||||
|
...typography.title3,
|
||||||
|
color: colors.text,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
comparisonContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.05)',
|
||||||
|
borderRadius: radius.lg,
|
||||||
|
padding: spacing.md,
|
||||||
|
marginBottom: spacing.lg,
|
||||||
|
},
|
||||||
|
comparisonPerson: {
|
||||||
|
alignItems: 'center',
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
comparisonAvatar: {
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
borderRadius: 24,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginBottom: spacing.xs,
|
||||||
|
},
|
||||||
|
comparisonAvatarImage: {
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
borderRadius: 24,
|
||||||
|
marginBottom: spacing.xs,
|
||||||
|
},
|
||||||
|
comparisonEmoji: {
|
||||||
|
fontSize: 24,
|
||||||
|
},
|
||||||
|
comparisonName: {
|
||||||
|
...typography.caption1,
|
||||||
|
color: colors.text,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
comparisonLevel: {
|
||||||
|
...typography.caption2,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
},
|
||||||
|
comparisonDivider: {
|
||||||
|
width: 40,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
modalExplanation: {
|
||||||
|
marginBottom: spacing.lg,
|
||||||
|
},
|
||||||
|
modalText: {
|
||||||
|
...typography.subhead,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
textAlign: 'center',
|
||||||
|
lineHeight: 22,
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
},
|
||||||
|
modalSubtext: {
|
||||||
|
...typography.caption1,
|
||||||
|
color: colors.textTertiary,
|
||||||
|
textAlign: 'center',
|
||||||
|
marginTop: spacing.sm,
|
||||||
|
},
|
||||||
|
modalTip: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: 'rgba(46, 204, 113, 0.1)',
|
||||||
|
borderRadius: radius.md,
|
||||||
|
padding: spacing.sm,
|
||||||
|
gap: spacing.xs,
|
||||||
|
},
|
||||||
|
modalTipWarning: {
|
||||||
|
backgroundColor: 'rgba(243, 156, 18, 0.1)',
|
||||||
|
},
|
||||||
|
modalTipText: {
|
||||||
|
...typography.caption1,
|
||||||
|
color: colors.success,
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
modalTipTextWarning: {
|
||||||
|
color: colors.warning,
|
||||||
|
},
|
||||||
|
modalCloseButton: {
|
||||||
|
backgroundColor: colors.glass.background,
|
||||||
|
borderRadius: radius.md,
|
||||||
|
paddingVertical: spacing.md,
|
||||||
|
alignItems: 'center',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.glass.border,
|
||||||
|
},
|
||||||
|
modalCloseText: {
|
||||||
|
...typography.subhead,
|
||||||
|
color: colors.text,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default IntensitySlider;
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
export { GradientBackground } from './GradientBackground';
|
export { GradientBackground } from './GradientBackground';
|
||||||
export { GlassCard } from './GlassCard';
|
export { GlassCard } from './GlassCard';
|
||||||
export { GlassButton } from './GlassButton';
|
export { GlassButton } from './GlassButton';
|
||||||
export { GlassInput } from './GlassInput';
|
export { GlassInput } from './GlassInput';
|
||||||
export { ShimmerEffect } from './ShimmerEffect';
|
export { IntensitySlider } from './IntensitySlider';
|
||||||
|
export { ShimmerEffect } from './ShimmerEffect';
|
||||||
export { DecorativeSeparator } from './DecorativeSeparator';
|
export { DecorativeSeparator } from './DecorativeSeparator';
|
||||||
export type { DecorativeSeparatorProps, SeparatorVariant } from './DecorativeSeparator';
|
export type { DecorativeSeparatorProps, SeparatorVariant } from './DecorativeSeparator';
|
||||||
export { LoadingOverlay } from './LoadingOverlay';
|
export { LoadingOverlay } from './LoadingOverlay';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import { View, StyleSheet, TouchableOpacity, KeyboardAvoidingView, Platform, ActivityIndicator, Modal, Dimensions, Alert } from "react-native";
|
import { View, Text, StyleSheet, TouchableOpacity, KeyboardAvoidingView, Platform, ActivityIndicator, Modal, Dimensions, Alert } from "react-native";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { Video, ResizeMode } from "expo-av";
|
import { Video, ResizeMode } from "expo-av";
|
||||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||||
@@ -16,10 +16,10 @@ 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, spacing } from "../../theme";
|
import { colors, gradients, radius, spacing, typography } from "../../theme";
|
||||||
import { Events } from "../../lib/analytics";
|
import { Events } from "../../lib/analytics";
|
||||||
|
|
||||||
type Message = Database["public"]["Tables"]["messages"]["Row"];
|
type Message = Database["public"]["Tables"]["messages"]["Row"];
|
||||||
@@ -74,8 +74,6 @@ export const ChatScreen: React.FC = () => {
|
|||||||
onNewMessage: clearTypingIndicator,
|
onNewMessage: clearTypingIndicator,
|
||||||
});
|
});
|
||||||
|
|
||||||
// E2EE encryption hook for sending messages
|
|
||||||
const { encryptMessage, isE2EEAvailable } = useEncryptedSend();
|
|
||||||
|
|
||||||
// Message actions hook for deletion and reporting
|
// Message actions hook for deletion and reporting
|
||||||
const { showDeleteOptions, deleteForSelf } = useMessageActions({
|
const { showDeleteOptions, deleteForSelf } = useMessageActions({
|
||||||
@@ -186,67 +184,23 @@ export const ChatScreen: React.FC = () => {
|
|||||||
setInputText("");
|
setInputText("");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Try to encrypt the message if E2EE is available
|
// 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 = isE2EEAvailable ? await encryptMessage(content) : null;
|
match_id: matchId,
|
||||||
console.log(`[E2EE Send] encryptedPayload=`, encryptedPayload ? {
|
user_id: user.id,
|
||||||
version: encryptedPayload.version,
|
content: content,
|
||||||
encrypted_content_length: encryptedPayload.encrypted_content.length,
|
version: 1,
|
||||||
encryption_iv_length: encryptedPayload.encryption_iv.length,
|
});
|
||||||
keys_metadata: encryptedPayload.keys_metadata,
|
|
||||||
} : null);
|
|
||||||
|
|
||||||
if (encryptedPayload) {
|
if (error) {
|
||||||
// Send encrypted message (v2)
|
Alert.alert("Error", "Failed to send message");
|
||||||
const { error, data } = await supabase.from("messages").insert({
|
setInputText(content);
|
||||||
match_id: matchId,
|
|
||||||
user_id: user.id,
|
|
||||||
content: null,
|
|
||||||
version: encryptedPayload.version,
|
|
||||||
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) {
|
|
||||||
Alert.alert("Error", "Failed to send message");
|
|
||||||
setInputText(content);
|
|
||||||
} else {
|
|
||||||
Events.messageSent();
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Fallback to plaintext (v1) if E2EE not available
|
Events.messageSent();
|
||||||
const { error } = await supabase.from("messages").insert({
|
|
||||||
match_id: matchId,
|
|
||||||
user_id: user.id,
|
|
||||||
content: content,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
Alert.alert("Error", "Failed to send message");
|
|
||||||
setInputText(content);
|
|
||||||
} else {
|
|
||||||
Events.messageSent();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} 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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -331,6 +285,7 @@ export const ChatScreen: React.FC = () => {
|
|||||||
onBack={() => router.push('/(app)/matches')}
|
onBack={() => router.push('/(app)/matches')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
||||||
<ChatMessages
|
<ChatMessages
|
||||||
messages={messages}
|
messages={messages}
|
||||||
userId={user?.id}
|
userId={user?.id}
|
||||||
|
|||||||
@@ -63,12 +63,15 @@ export function InputBar({
|
|||||||
transform: [{ rotate: `${buttonRotation.value}deg` }],
|
transform: [{ rotate: `${buttonRotation.value}deg` }],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const sendDisabled = !inputText.trim();
|
||||||
|
const mediaDisabled = uploading;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
{/* Plus button to toggle media menu */}
|
{/* Plus button to toggle media menu */}
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={toggleMenu}
|
onPress={toggleMenu}
|
||||||
disabled={uploading}
|
disabled={mediaDisabled}
|
||||||
style={styles.attachButton}
|
style={styles.attachButton}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel="Toggle media menu"
|
accessibilityLabel="Toggle media menu"
|
||||||
@@ -89,6 +92,7 @@ export function InputBar({
|
|||||||
onPress={() => handleMediaAction(onTakePhoto)}
|
onPress={() => handleMediaAction(onTakePhoto)}
|
||||||
style={styles.mediaMenuItem}
|
style={styles.mediaMenuItem}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
|
disabled={mediaDisabled}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel="Take photo"
|
accessibilityLabel="Take photo"
|
||||||
testID="chat-input-take-photo"
|
testID="chat-input-take-photo"
|
||||||
@@ -102,6 +106,7 @@ export function InputBar({
|
|||||||
onPress={() => handleMediaAction(onRecordVideo)}
|
onPress={() => handleMediaAction(onRecordVideo)}
|
||||||
style={styles.mediaMenuItem}
|
style={styles.mediaMenuItem}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
|
disabled={mediaDisabled}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel="Record video"
|
accessibilityLabel="Record video"
|
||||||
testID="chat-input-record-video"
|
testID="chat-input-record-video"
|
||||||
@@ -115,6 +120,7 @@ export function InputBar({
|
|||||||
onPress={() => handleMediaAction(onPickMedia)}
|
onPress={() => handleMediaAction(onPickMedia)}
|
||||||
style={styles.mediaMenuItem}
|
style={styles.mediaMenuItem}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
|
disabled={mediaDisabled}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel="Pick media"
|
accessibilityLabel="Pick media"
|
||||||
testID="chat-input-pick-media"
|
testID="chat-input-pick-media"
|
||||||
@@ -140,20 +146,20 @@ export function InputBar({
|
|||||||
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={onSend}
|
onPress={onSend}
|
||||||
disabled={!inputText.trim()}
|
disabled={sendDisabled}
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel="Send message"
|
accessibilityLabel="Send message"
|
||||||
testID="chat-input-send"
|
testID="chat-input-send"
|
||||||
>
|
>
|
||||||
<LinearGradient
|
<LinearGradient
|
||||||
colors={inputText.trim() ? gradients.primary as [string, string] : ['rgba(22, 33, 62, 0.6)', 'rgba(22, 33, 62, 0.6)']}
|
colors={sendDisabled ? ['rgba(22, 33, 62, 0.6)', 'rgba(22, 33, 62, 0.6)'] : gradients.primary as [string, string]}
|
||||||
style={styles.sendButton}
|
style={styles.sendButton}
|
||||||
start={{ x: 0, y: 0 }}
|
start={{ x: 0, y: 0 }}
|
||||||
end={{ x: 1, y: 1 }}
|
end={{ x: 1, y: 1 }}
|
||||||
>
|
>
|
||||||
{/* Silk highlight */}
|
{/* Silk highlight */}
|
||||||
{inputText.trim() && (
|
{!sendDisabled && (
|
||||||
<LinearGradient
|
<LinearGradient
|
||||||
colors={['rgba(255, 255, 255, 0.2)', 'transparent']}
|
colors={['rgba(255, 255, 255, 0.2)', 'transparent']}
|
||||||
style={styles.sendButtonHighlight}
|
style={styles.sendButtonHighlight}
|
||||||
@@ -164,7 +170,7 @@ export function InputBar({
|
|||||||
<Ionicons
|
<Ionicons
|
||||||
name="send"
|
name="send"
|
||||||
size={18}
|
size={18}
|
||||||
color={inputText.trim() ? colors.text : colors.textTertiary}
|
color={sendDisabled ? colors.textTertiary : colors.text}
|
||||||
/>
|
/>
|
||||||
</LinearGradient>
|
</LinearGradient>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -7,27 +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,
|
|
||||||
} 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 refreshPartner = useAuthStore((s) => s.refreshPartner);
|
|
||||||
const { publicKeyJwk, hasKeys, isLoading: keysLoading } = useEncryptionKeys();
|
|
||||||
|
|
||||||
// Check if E2EE is available for media
|
|
||||||
const isE2EEAvailable = Platform.OS !== 'web' && hasKeys && !keysLoading && !!publicKeyJwk;
|
|
||||||
|
|
||||||
const uploadMedia = async (uri: string, mediaType: 'image' | 'video') => {
|
const uploadMedia = async (uri: string, mediaType: 'image' | 'video') => {
|
||||||
if (!userId) return;
|
if (!userId) return;
|
||||||
@@ -38,7 +21,6 @@ export function useMediaUpload(matchId: string, userId: string | undefined) {
|
|||||||
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 | null = null;
|
|
||||||
|
|
||||||
// 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') {
|
||||||
@@ -61,60 +43,6 @@ export function useMediaUpload(matchId: string, userId: string | undefined) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to encrypt the media if E2EE is available
|
|
||||||
if (isE2EEAvailable && Platform.OS !== 'web') {
|
|
||||||
try {
|
|
||||||
// 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) {
|
|
||||||
console.warn('[E2EE Media Upload] No keys available in global store at encryption time');
|
|
||||||
throw new Error('No encryption keys available');
|
|
||||||
}
|
|
||||||
|
|
||||||
const senderPublicKey = currentKeys.publicKeyJwk;
|
|
||||||
console.log('[E2EE Media Upload] Using sender public key from global store, version:', currentKeys.keysVersion);
|
|
||||||
|
|
||||||
// Refresh partner's profile to get their latest public key
|
|
||||||
console.log('[E2EE Media Upload] Refreshing partner profile...');
|
|
||||||
const freshPartner = await refreshPartner();
|
|
||||||
|
|
||||||
const adminPublicKey = await getAdminPublicKey();
|
|
||||||
const adminKeyId = await getAdminKeyId();
|
|
||||||
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');
|
|
||||||
} catch (encryptError) {
|
|
||||||
console.warn('Media encryption failed, uploading unencrypted:', encryptError);
|
|
||||||
encryptedPayload = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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();
|
||||||
@@ -134,22 +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);
|
||||||
|
|
||||||
if (mediaType !== 'video' && !encryptedPayload) {
|
|
||||||
// Only use URI extension for non-videos and non-encrypted (videos are always mp4 after compression)
|
|
||||||
const uriExt = fileUri.split('.').pop();
|
|
||||||
if (uriExt && uriExt !== fileUri) {
|
|
||||||
ext = uriExt.toLowerCase();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileName = `${matchId}/${Date.now()}_${Math.random().toString(36).substring(7)}.${ext}`;
|
const fileName = `${matchId}/${Date.now()}_${Math.random().toString(36).substring(7)}.${ext}`;
|
||||||
const contentType = encryptedPayload
|
const contentType = mediaType === 'video' ? 'video/mp4' : 'image/jpeg';
|
||||||
? 'application/octet-stream' // Encrypted files are binary
|
|
||||||
: mediaType === 'video'
|
|
||||||
? `video/${ext === 'mov' ? 'quicktime' : ext}`
|
|
||||||
: `image/${ext === 'jpg' ? 'jpeg' : ext}`;
|
|
||||||
|
|
||||||
const { error: uploadError } = await supabase.storage
|
const { error: uploadError } = await supabase.storage
|
||||||
.from("chat-media")
|
.from("chat-media")
|
||||||
@@ -160,39 +76,16 @@ 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) {
|
await supabase.from("messages").insert({
|
||||||
try {
|
match_id: matchId,
|
||||||
await FileSystem.deleteAsync(encryptedPayload.encryptedFileUri, { idempotent: true });
|
user_id: userId,
|
||||||
} catch (cleanupError) {
|
content: mediaType === 'video' ? 'Sent a video' : 'Sent an image',
|
||||||
console.warn('Failed to clean up encrypted temp file:', cleanupError);
|
media_path: fileName,
|
||||||
}
|
media_type: mediaType,
|
||||||
}
|
media_expired: false,
|
||||||
|
version: 1,
|
||||||
// Store the message with encryption metadata if encrypted
|
});
|
||||||
if (encryptedPayload) {
|
|
||||||
await supabase.from("messages").insert({
|
|
||||||
match_id: matchId,
|
|
||||||
user_id: userId,
|
|
||||||
content: mediaType === 'video' ? 'Sent a video' : 'Sent an image',
|
|
||||||
media_path: fileName,
|
|
||||||
media_type: mediaType,
|
|
||||||
media_expired: false,
|
|
||||||
version: MESSAGE_VERSION_E2EE,
|
|
||||||
encryption_iv: encryptedPayload.encryption_iv,
|
|
||||||
keys_metadata: encryptedPayload.keys_metadata as unknown as Record<string, unknown>,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Unencrypted upload (v1)
|
|
||||||
await supabase.from("messages").insert({
|
|
||||||
match_id: matchId,
|
|
||||||
user_id: userId,
|
|
||||||
content: mediaType === 'video' ? 'Sent a video' : 'Sent an image',
|
|
||||||
media_path: fileName,
|
|
||||||
media_type: mediaType,
|
|
||||||
media_expired: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Events.mediaUploaded();
|
Events.mediaUploaded();
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { View, Text, StyleSheet, ScrollView, TouchableOpacity, useWindowDimensions, Platform, Alert } from 'react-native';
|
import { View, Text, StyleSheet, ScrollView, TouchableOpacity, useWindowDimensions, Platform, Alert, Linking } from 'react-native';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import Constants from 'expo-constants';
|
import Constants from 'expo-constants';
|
||||||
import Animated, {
|
import Animated, {
|
||||||
@@ -10,27 +10,24 @@ import Animated, {
|
|||||||
interpolate,
|
interpolate,
|
||||||
Extrapolation
|
Extrapolation
|
||||||
} from 'react-native-reanimated';
|
} from 'react-native-reanimated';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
|
||||||
import { BlurView } from 'expo-blur';
|
import { BlurView } from 'expo-blur';
|
||||||
|
|
||||||
import { GradientBackground, GlassCard } from '../../components/ui';
|
import { GradientBackground } from '../../components/ui';
|
||||||
import { FeedbackModal } from '../../components/feedback';
|
import { FeedbackModal } from '../../components/feedback';
|
||||||
import { Paywall } from '../../components/paywall';
|
import { Paywall } from '../../components/paywall';
|
||||||
import { colors, featureColors, spacing, typography, radius, shadows } from '../../theme';
|
import { colors, featureColors, spacing, typography, radius } from '../../theme';
|
||||||
import { useAuthStore, useSubscriptionStore } from '../../store';
|
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';
|
||||||
|
|
||||||
// Components
|
// Components
|
||||||
import { AppearanceSettings, CoupleStatus, NotificationSettings, PrivacySettings, DangerZone, ResetProgress, SettingsSection, MenuItem } from './components';
|
import { AppearanceSettings, CoupleStatus, NotificationSettings, PrivacySettings, DangerZone, SettingsSection, MenuItem, SubscriptionCard } from './components';
|
||||||
|
|
||||||
const MAX_CONTENT_WIDTH = 500;
|
const MAX_CONTENT_WIDTH = 500;
|
||||||
const ACCENT_GRADIENT = featureColors.profile.gradient as [string, string];
|
|
||||||
const NAV_BAR_HEIGHT = 44;
|
const NAV_BAR_HEIGHT = 44;
|
||||||
const STATUS_BAR_HEIGHT = 60;
|
const STATUS_BAR_HEIGHT = 60;
|
||||||
const HEADER_SCROLL_DISTANCE = 100;
|
const HEADER_SCROLL_DISTANCE = 100;
|
||||||
@@ -188,77 +185,16 @@ export function ProfileScreen() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Subscription Section */}
|
{/* Subscription Section */}
|
||||||
<SettingsSection title="Subscription" delay={350}>
|
<SubscriptionCard
|
||||||
{hasPremiumAccess ? (
|
hasPremiumAccess={hasPremiumAccess}
|
||||||
<View style={styles.rowContainer}>
|
isOwnSubscription={!!isOwnSubscription}
|
||||||
<View style={styles.rowLeft}>
|
expirationDate={formatExpirationDate(subscription.expirationDate)}
|
||||||
<LinearGradient
|
onUpgradePress={() => settings.setShowPaywall(true)}
|
||||||
colors={ACCENT_GRADIENT}
|
onManagePress={settings.handleManageSubscription}
|
||||||
style={styles.partnerIconGradient}
|
onRestorePress={settings.handleRestorePurchases}
|
||||||
start={{ x: 0, y: 0 }}
|
isRestoring={settings.isPurchasing}
|
||||||
end={{ x: 1, y: 1 }}
|
delay={350}
|
||||||
>
|
/>
|
||||||
<Ionicons name="star" size={20} color={colors.text} />
|
|
||||||
</LinearGradient>
|
|
||||||
<View style={styles.rowTextContainer}>
|
|
||||||
<Text style={styles.rowValue}>Pro Member</Text>
|
|
||||||
<Text style={styles.rowLabel}>
|
|
||||||
{isOwnSubscription
|
|
||||||
? `Renews ${formatExpirationDate(subscription.expirationDate)}`
|
|
||||||
: "Via partner's subscription"
|
|
||||||
}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
{isOwnSubscription && (
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.manageButton}
|
|
||||||
onPress={settings.handleManageSubscription}
|
|
||||||
activeOpacity={0.7}
|
|
||||||
>
|
|
||||||
<Text style={styles.manageButtonText}>Manage</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.rowContainer}
|
|
||||||
onPress={() => settings.setShowPaywall(true)}
|
|
||||||
activeOpacity={0.7}
|
|
||||||
>
|
|
||||||
<View style={styles.rowLeft}>
|
|
||||||
<View style={styles.emptyPartnerIcon}>
|
|
||||||
<Ionicons name="star-outline" size={20} color={colors.textTertiary} />
|
|
||||||
</View>
|
|
||||||
<View style={styles.rowTextContainer}>
|
|
||||||
<Text style={styles.rowValueMuted}>Free Plan</Text>
|
|
||||||
<Text style={styles.rowLabel}>Upgrade to unlock all packs</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<LinearGradient
|
|
||||||
colors={ACCENT_GRADIENT}
|
|
||||||
style={styles.upgradeButton}
|
|
||||||
start={{ x: 0, y: 0 }}
|
|
||||||
end={{ x: 1, y: 0 }}
|
|
||||||
>
|
|
||||||
<Text style={styles.upgradeButtonText}>Upgrade</Text>
|
|
||||||
</LinearGradient>
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Restore Purchases Link */}
|
|
||||||
{!hasPremiumAccess && (
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.restoreLink}
|
|
||||||
onPress={settings.handleRestorePurchases}
|
|
||||||
disabled={settings.isPurchasing}
|
|
||||||
>
|
|
||||||
<Text style={styles.restoreLinkText}>
|
|
||||||
{settings.isPurchasing ? "Restoring..." : "Restore Purchases"}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
</SettingsSection>
|
|
||||||
|
|
||||||
{/* Notifications */}
|
{/* Notifications */}
|
||||||
<NotificationSettings
|
<NotificationSettings
|
||||||
@@ -269,14 +205,17 @@ export function ProfileScreen() {
|
|||||||
|
|
||||||
{/* Preferences */}
|
{/* Preferences */}
|
||||||
<PrivacySettings
|
<PrivacySettings
|
||||||
showExplicit={settings.showExplicit}
|
maxIntensity={settings.maxIntensity}
|
||||||
isUpdatingExplicit={settings.isUpdatingExplicit}
|
isUpdatingIntensity={settings.isUpdatingIntensity}
|
||||||
onExplicitToggle={settings.handleExplicitToggle}
|
onIntensityChange={settings.handleIntensityChange}
|
||||||
biometricAvailable={settings.biometricAvailable}
|
biometricAvailable={settings.biometricAvailable}
|
||||||
biometricEnabled={settings.biometricEnabled}
|
biometricEnabled={settings.biometricEnabled}
|
||||||
biometricType={settings.biometricType}
|
biometricType={settings.biometricType}
|
||||||
isUpdatingBiometric={settings.isUpdatingBiometric}
|
isUpdatingBiometric={settings.isUpdatingBiometric}
|
||||||
onBiometricToggle={settings.handleBiometricToggle}
|
onBiometricToggle={settings.handleBiometricToggle}
|
||||||
|
partnerIntensity={settings.partnerIntensity}
|
||||||
|
partnerName={settings.partnerName}
|
||||||
|
partnerAvatar={settings.partnerAvatar}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Account */}
|
{/* Account */}
|
||||||
@@ -298,13 +237,20 @@ export function ProfileScreen() {
|
|||||||
description="Report bugs or request features"
|
description="Report bugs or request features"
|
||||||
onPress={() => setShowFeedbackModal(true)}
|
onPress={() => setShowFeedbackModal(true)}
|
||||||
/>
|
/>
|
||||||
|
<View style={styles.preferencesDivider} />
|
||||||
|
<MenuItem
|
||||||
|
icon="shield-checkmark-outline"
|
||||||
|
label="Privacy Policy"
|
||||||
|
onPress={() => Linking.openURL('https://sauci.app/privacy')}
|
||||||
|
/>
|
||||||
|
<View style={styles.preferencesDivider} />
|
||||||
|
<MenuItem
|
||||||
|
icon="document-text-outline"
|
||||||
|
label="Terms of Service"
|
||||||
|
onPress={() => Linking.openURL('https://sauci.app/terms')}
|
||||||
|
/>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
{/* Reset Progress */}
|
|
||||||
{couple && (
|
|
||||||
<ResetProgress onResetProgress={handleResetProgress} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Debug Section */}
|
{/* Debug Section */}
|
||||||
{__DEV__ && (
|
{__DEV__ && (
|
||||||
<SettingsSection title="Debug" delay={550}>
|
<SettingsSection title="Debug" delay={550}>
|
||||||
@@ -347,34 +293,12 @@ 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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Danger Zone */}
|
{/* Danger Zone */}
|
||||||
<DangerZone
|
<DangerZone
|
||||||
|
onResetProgress={couple ? handleResetProgress : undefined}
|
||||||
onDeleteRelationship={couple ? handleDeleteRelationship : undefined}
|
onDeleteRelationship={couple ? handleDeleteRelationship : undefined}
|
||||||
onDeleteAccount={handleDeleteAccount}
|
onDeleteAccount={handleDeleteAccount}
|
||||||
hasRelationship={!!couple}
|
hasRelationship={!!couple}
|
||||||
@@ -483,90 +407,11 @@ const styles = StyleSheet.create({
|
|||||||
marginHorizontal: spacing.sm,
|
marginHorizontal: spacing.sm,
|
||||||
opacity: 0.6,
|
opacity: 0.6,
|
||||||
},
|
},
|
||||||
// Shared row styles
|
|
||||||
rowContainer: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
paddingVertical: spacing.sm,
|
|
||||||
},
|
|
||||||
rowLeft: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
partnerIconGradient: {
|
|
||||||
width: 44,
|
|
||||||
height: 44,
|
|
||||||
borderRadius: 22,
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
emptyPartnerIcon: {
|
|
||||||
width: 44,
|
|
||||||
height: 44,
|
|
||||||
borderRadius: 22,
|
|
||||||
backgroundColor: colors.glass.background,
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
rowTextContainer: {
|
|
||||||
marginLeft: spacing.md,
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
rowValue: {
|
|
||||||
...typography.body,
|
|
||||||
fontWeight: '600',
|
|
||||||
color: colors.text,
|
|
||||||
},
|
|
||||||
rowValueMuted: {
|
|
||||||
...typography.body,
|
|
||||||
fontWeight: '600',
|
|
||||||
color: colors.textSecondary,
|
|
||||||
},
|
|
||||||
rowLabel: {
|
|
||||||
...typography.caption1,
|
|
||||||
color: colors.textTertiary,
|
|
||||||
marginTop: 2,
|
|
||||||
},
|
|
||||||
preferencesDivider: {
|
preferencesDivider: {
|
||||||
height: 1,
|
height: 1,
|
||||||
backgroundColor: colors.glass.border,
|
backgroundColor: colors.glass.border,
|
||||||
marginVertical: spacing.md,
|
marginVertical: spacing.md,
|
||||||
},
|
},
|
||||||
// Subscription styles
|
|
||||||
manageButton: {
|
|
||||||
paddingHorizontal: spacing.md,
|
|
||||||
paddingVertical: spacing.sm,
|
|
||||||
borderRadius: radius.md,
|
|
||||||
backgroundColor: colors.glass.background,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: colors.glass.border,
|
|
||||||
},
|
|
||||||
manageButtonText: {
|
|
||||||
...typography.subhead,
|
|
||||||
color: colors.secondary,
|
|
||||||
fontWeight: "600",
|
|
||||||
},
|
|
||||||
upgradeButton: {
|
|
||||||
paddingHorizontal: spacing.md,
|
|
||||||
paddingVertical: spacing.sm,
|
|
||||||
borderRadius: radius.md,
|
|
||||||
},
|
|
||||||
upgradeButtonText: {
|
|
||||||
...typography.subhead,
|
|
||||||
color: colors.text,
|
|
||||||
fontWeight: "600",
|
|
||||||
},
|
|
||||||
restoreLink: {
|
|
||||||
alignItems: "center",
|
|
||||||
marginTop: spacing.sm,
|
|
||||||
padding: spacing.sm,
|
|
||||||
},
|
|
||||||
restoreLinkText: {
|
|
||||||
...typography.caption1,
|
|
||||||
color: colors.textTertiary,
|
|
||||||
},
|
|
||||||
// Version Badge
|
// Version Badge
|
||||||
versionContainer: {
|
versionContainer: {
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
|
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
|
||||||
|
import { Image } from 'expo-image';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import { SettingsSection } from './SettingsSection';
|
import { SettingsSection } from './SettingsSection';
|
||||||
import { colors, featureColors, spacing, typography } from '../../../theme';
|
import { colors, featureColors, spacing, typography, radius } from '../../../theme';
|
||||||
import { Profile, Couple } from '../../../types';
|
import { Profile, Couple } from '../../../types';
|
||||||
|
|
||||||
const ACCENT_GRADIENT = featureColors.profile.gradient as [string, string];
|
const ACCENT_GRADIENT = featureColors.profile.gradient as [string, string];
|
||||||
@@ -15,30 +16,79 @@ interface CoupleStatusProps {
|
|||||||
onPairingPress: () => void;
|
onPairingPress: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formatPairedDate = (date: string | null | undefined): string | null => {
|
||||||
|
if (!date) return null;
|
||||||
|
const paired = new Date(date);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now.getTime() - paired.getTime();
|
||||||
|
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
if (diffDays === 0) return 'Paired today';
|
||||||
|
if (diffDays === 1) return 'Paired yesterday';
|
||||||
|
if (diffDays < 7) return `Paired ${diffDays} days ago`;
|
||||||
|
if (diffDays < 30) {
|
||||||
|
const weeks = Math.floor(diffDays / 7);
|
||||||
|
return `Paired ${weeks} ${weeks === 1 ? 'week' : 'weeks'} ago`;
|
||||||
|
}
|
||||||
|
if (diffDays < 365) {
|
||||||
|
const months = Math.floor(diffDays / 30);
|
||||||
|
return `Paired ${months} ${months === 1 ? 'month' : 'months'} ago`;
|
||||||
|
}
|
||||||
|
const years = Math.floor(diffDays / 365);
|
||||||
|
return `Paired ${years} ${years === 1 ? 'year' : 'years'} ago`;
|
||||||
|
};
|
||||||
|
|
||||||
export const CoupleStatus: React.FC<CoupleStatusProps> = ({
|
export const CoupleStatus: React.FC<CoupleStatusProps> = ({
|
||||||
partner,
|
partner,
|
||||||
couple,
|
couple,
|
||||||
onUnpair,
|
onUnpair,
|
||||||
onPairingPress,
|
onPairingPress,
|
||||||
}) => {
|
}) => {
|
||||||
|
const pairedLabel = formatPairedDate(couple?.created_at);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsSection title="Partner" delay={300}>
|
<SettingsSection title="Partner" delay={300}>
|
||||||
{partner ? (
|
{partner ? (
|
||||||
<View style={styles.rowContainer}>
|
<View style={styles.rowContainer}>
|
||||||
<View style={styles.rowLeft}>
|
<View style={styles.rowLeft}>
|
||||||
<LinearGradient
|
{partner.avatar_url ? (
|
||||||
colors={ACCENT_GRADIENT}
|
<View style={styles.partnerAvatarContainer}>
|
||||||
style={styles.partnerIconGradient}
|
<LinearGradient
|
||||||
start={{ x: 0, y: 0 }}
|
colors={ACCENT_GRADIENT}
|
||||||
end={{ x: 1, y: 1 }}
|
style={styles.partnerAvatarGradient}
|
||||||
>
|
start={{ x: 0, y: 0 }}
|
||||||
<Ionicons name="heart" size={20} color={colors.text} />
|
end={{ x: 1, y: 1 }}
|
||||||
</LinearGradient>
|
>
|
||||||
|
<Image
|
||||||
|
source={{ uri: partner.avatar_url }}
|
||||||
|
style={styles.partnerAvatar}
|
||||||
|
cachePolicy="disk"
|
||||||
|
transition={200}
|
||||||
|
/>
|
||||||
|
</LinearGradient>
|
||||||
|
<View style={styles.connectedBadge}>
|
||||||
|
<Ionicons name="heart" size={10} color={colors.text} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<LinearGradient
|
||||||
|
colors={ACCENT_GRADIENT}
|
||||||
|
style={styles.partnerIconGradient}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
>
|
||||||
|
<Text style={styles.partnerInitial}>
|
||||||
|
{(partner.name?.[0] || partner.email?.[0] || 'P').toUpperCase()}
|
||||||
|
</Text>
|
||||||
|
</LinearGradient>
|
||||||
|
)}
|
||||||
<View style={styles.rowTextContainer}>
|
<View style={styles.rowTextContainer}>
|
||||||
<Text style={styles.rowValue}>
|
<Text style={styles.rowValue}>
|
||||||
{partner.name || partner.email || 'Your partner'}
|
{partner.name || partner.email || 'Your partner'}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.rowLabel}>Connected</Text>
|
<Text style={styles.rowLabel}>
|
||||||
|
{pairedLabel || 'Connected'}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
@@ -108,20 +158,58 @@ const styles = StyleSheet.create({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
partnerIconGradient: {
|
partnerAvatarContainer: {
|
||||||
width: 44,
|
position: 'relative',
|
||||||
height: 44,
|
},
|
||||||
borderRadius: 22,
|
partnerAvatarGradient: {
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
borderRadius: 24,
|
||||||
|
padding: 2,
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
emptyPartnerIcon: {
|
partnerAvatar: {
|
||||||
width: 44,
|
width: 44,
|
||||||
height: 44,
|
height: 44,
|
||||||
borderRadius: 22,
|
borderRadius: 22,
|
||||||
|
backgroundColor: colors.background,
|
||||||
|
},
|
||||||
|
connectedBadge: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: -2,
|
||||||
|
right: -2,
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
borderRadius: 10,
|
||||||
|
backgroundColor: colors.success,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: colors.backgroundLight,
|
||||||
|
},
|
||||||
|
partnerIconGradient: {
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
borderRadius: 24,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
partnerInitial: {
|
||||||
|
...typography.title3,
|
||||||
|
color: colors.text,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
emptyPartnerIcon: {
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
borderRadius: 24,
|
||||||
backgroundColor: colors.glass.background,
|
backgroundColor: colors.glass.background,
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: colors.glass.border,
|
||||||
|
borderStyle: 'dashed',
|
||||||
},
|
},
|
||||||
rowTextContainer: {
|
rowTextContainer: {
|
||||||
marginLeft: spacing.md,
|
marginLeft: spacing.md,
|
||||||
|
|||||||
@@ -7,29 +7,54 @@ import { GlassCard, GlassButton } from '../../../components/ui';
|
|||||||
import { colors, spacing, radius, typography } from '../../../theme';
|
import { colors, spacing, radius, typography } from '../../../theme';
|
||||||
|
|
||||||
interface DangerZoneProps {
|
interface DangerZoneProps {
|
||||||
|
onResetProgress?: () => Promise<void>;
|
||||||
onDeleteRelationship?: () => Promise<void>;
|
onDeleteRelationship?: () => Promise<void>;
|
||||||
onDeleteAccount: () => Promise<void>;
|
onDeleteAccount: () => Promise<void>;
|
||||||
hasRelationship?: boolean;
|
hasRelationship?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DangerZone: React.FC<DangerZoneProps> = ({
|
export const DangerZone: React.FC<DangerZoneProps> = ({
|
||||||
|
onResetProgress,
|
||||||
onDeleteRelationship,
|
onDeleteRelationship,
|
||||||
onDeleteAccount,
|
onDeleteAccount,
|
||||||
hasRelationship = false
|
hasRelationship = false
|
||||||
}) => {
|
}) => {
|
||||||
|
const [showResetModal, setShowResetModal] = useState(false);
|
||||||
const [showDeleteRelationshipModal, setShowDeleteRelationshipModal] = useState(false);
|
const [showDeleteRelationshipModal, setShowDeleteRelationshipModal] = useState(false);
|
||||||
const [showDeleteAccountModal, setShowDeleteAccountModal] = useState(false);
|
const [showDeleteAccountModal, setShowDeleteAccountModal] = useState(false);
|
||||||
const [deleteConfirmText, setDeleteConfirmText] = useState("");
|
const [confirmText, setConfirmText] = useState("");
|
||||||
|
const [isResetting, setIsResetting] = useState(false);
|
||||||
const [isDeletingRelationship, setIsDeletingRelationship] = useState(false);
|
const [isDeletingRelationship, setIsDeletingRelationship] = useState(false);
|
||||||
const [isDeletingAccount, setIsDeletingAccount] = useState(false);
|
const [isDeletingAccount, setIsDeletingAccount] = useState(false);
|
||||||
|
|
||||||
|
const handleResetProgress = async () => {
|
||||||
|
if (confirmText !== "RESET" || !onResetProgress) return;
|
||||||
|
setIsResetting(true);
|
||||||
|
try {
|
||||||
|
await onResetProgress();
|
||||||
|
setShowResetModal(false);
|
||||||
|
setConfirmText("");
|
||||||
|
} catch (error) {
|
||||||
|
// Error handling relies on parent
|
||||||
|
} finally {
|
||||||
|
setIsResetting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeResetModal = () => {
|
||||||
|
if (!isResetting) {
|
||||||
|
setShowResetModal(false);
|
||||||
|
setConfirmText("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleDeleteRelationship = async () => {
|
const handleDeleteRelationship = async () => {
|
||||||
if (deleteConfirmText !== "DELETE" || !onDeleteRelationship) return;
|
if (confirmText !== "DELETE" || !onDeleteRelationship) return;
|
||||||
setIsDeletingRelationship(true);
|
setIsDeletingRelationship(true);
|
||||||
try {
|
try {
|
||||||
await onDeleteRelationship();
|
await onDeleteRelationship();
|
||||||
setShowDeleteRelationshipModal(false);
|
setShowDeleteRelationshipModal(false);
|
||||||
setDeleteConfirmText("");
|
setConfirmText("");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Error handling relies on parent throwing or managing its own alerts
|
// Error handling relies on parent throwing or managing its own alerts
|
||||||
} finally {
|
} finally {
|
||||||
@@ -38,7 +63,7 @@ export const DangerZone: React.FC<DangerZoneProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteAccount = async () => {
|
const handleDeleteAccount = async () => {
|
||||||
if (deleteConfirmText !== "DELETE") return;
|
if (confirmText !== "DELETE") return;
|
||||||
setIsDeletingAccount(true);
|
setIsDeletingAccount(true);
|
||||||
try {
|
try {
|
||||||
await onDeleteAccount();
|
await onDeleteAccount();
|
||||||
@@ -52,14 +77,14 @@ export const DangerZone: React.FC<DangerZoneProps> = ({
|
|||||||
const closeRelationshipModal = () => {
|
const closeRelationshipModal = () => {
|
||||||
if (!isDeletingRelationship) {
|
if (!isDeletingRelationship) {
|
||||||
setShowDeleteRelationshipModal(false);
|
setShowDeleteRelationshipModal(false);
|
||||||
setDeleteConfirmText("");
|
setConfirmText("");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeAccountModal = () => {
|
const closeAccountModal = () => {
|
||||||
if (!isDeletingAccount) {
|
if (!isDeletingAccount) {
|
||||||
setShowDeleteAccountModal(false);
|
setShowDeleteAccountModal(false);
|
||||||
setDeleteConfirmText("");
|
setConfirmText("");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -71,18 +96,45 @@ export const DangerZone: React.FC<DangerZoneProps> = ({
|
|||||||
>
|
>
|
||||||
<Text style={styles.dangerSectionTitle}>Danger Zone</Text>
|
<Text style={styles.dangerSectionTitle}>Danger Zone</Text>
|
||||||
|
|
||||||
|
{/* Reset Progress - only show if in a relationship */}
|
||||||
|
{hasRelationship && onResetProgress && (
|
||||||
|
<GlassCard style={styles.warningCard}>
|
||||||
|
<View style={styles.dangerContent}>
|
||||||
|
<View style={styles.dangerInfo}>
|
||||||
|
<View style={styles.warningIconContainer}>
|
||||||
|
<Ionicons name="refresh" size={20} color={colors.warning} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.dangerTextContainer}>
|
||||||
|
<Text style={styles.dangerTitle}>Reset Progress</Text>
|
||||||
|
<Text style={styles.dangerDescription}>
|
||||||
|
Start fresh by clearing all swipes, matches, and chats. Your partner connection stays.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.warningButton}
|
||||||
|
onPress={() => setShowResetModal(true)}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Ionicons name="refresh-outline" size={16} color={colors.warning} />
|
||||||
|
<Text style={styles.warningButtonText}>Reset</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</GlassCard>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Delete Relationship - only show if in a relationship */}
|
{/* Delete Relationship - only show if in a relationship */}
|
||||||
{hasRelationship && onDeleteRelationship && (
|
{hasRelationship && onDeleteRelationship && (
|
||||||
<GlassCard style={styles.dangerCard}>
|
<GlassCard style={StyleSheet.flatten([styles.dangerCard, styles.dangerCardSpacing]) as ViewStyle}>
|
||||||
<View style={styles.dangerContent}>
|
<View style={styles.dangerContent}>
|
||||||
<View style={styles.dangerInfo}>
|
<View style={styles.dangerInfo}>
|
||||||
<View style={styles.dangerIconContainer}>
|
<View style={styles.dangerIconContainer}>
|
||||||
<Ionicons name="heart-dislike" size={20} color={colors.error} />
|
<Ionicons name="heart-dislike" size={20} color={colors.error} />
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.dangerTextContainer}>
|
<View style={styles.dangerTextContainer}>
|
||||||
<Text style={styles.dangerTitle}>Delete Relationship Data</Text>
|
<Text style={styles.dangerTitle}>Delete Relationship</Text>
|
||||||
<Text style={styles.dangerDescription}>
|
<Text style={styles.dangerDescription}>
|
||||||
Delete your relationship including all matches, chats, and shared photos. Your account will remain.
|
End your connection and delete all shared data. Your account will remain.
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -100,7 +152,7 @@ export const DangerZone: React.FC<DangerZoneProps> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Delete Account - always show */}
|
{/* Delete Account - always show */}
|
||||||
<GlassCard style={hasRelationship ? StyleSheet.flatten([styles.dangerCard, styles.dangerCardSpacing]) as ViewStyle : styles.dangerCard}>
|
<GlassCard style={StyleSheet.flatten([styles.dangerCard, (hasRelationship ? styles.dangerCardSpacing : {})]) as ViewStyle}>
|
||||||
<View style={styles.dangerContent}>
|
<View style={styles.dangerContent}>
|
||||||
<View style={styles.dangerInfo}>
|
<View style={styles.dangerInfo}>
|
||||||
<View style={styles.dangerIconContainer}>
|
<View style={styles.dangerIconContainer}>
|
||||||
@@ -109,7 +161,7 @@ export const DangerZone: React.FC<DangerZoneProps> = ({
|
|||||||
<View style={styles.dangerTextContainer}>
|
<View style={styles.dangerTextContainer}>
|
||||||
<Text style={styles.dangerTitle}>Delete Account</Text>
|
<Text style={styles.dangerTitle}>Delete Account</Text>
|
||||||
<Text style={styles.dangerDescription}>
|
<Text style={styles.dangerDescription}>
|
||||||
Permanently delete your account and all associated data. This cannot be undone.
|
Permanently delete your account and all data. This cannot be undone.
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -126,6 +178,105 @@ export const DangerZone: React.FC<DangerZoneProps> = ({
|
|||||||
</GlassCard>
|
</GlassCard>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
|
{/* Reset Progress Modal */}
|
||||||
|
<Modal
|
||||||
|
visible={showResetModal}
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={closeResetModal}
|
||||||
|
>
|
||||||
|
<BlurView
|
||||||
|
intensity={Platform.OS === 'ios' ? 20 : 0}
|
||||||
|
tint="dark"
|
||||||
|
style={StyleSheet.absoluteFill}
|
||||||
|
>
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||||
|
style={styles.modalOverlay}
|
||||||
|
>
|
||||||
|
<View style={styles.modalContent}>
|
||||||
|
<View style={styles.modalHeader}>
|
||||||
|
<View style={styles.warningModalIconContainer}>
|
||||||
|
<Ionicons name="refresh" size={32} color={colors.warning} />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.modalTitle}>Reset Progress?</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.modalDescription}>
|
||||||
|
This will permanently delete:
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={styles.warningList}>
|
||||||
|
<View style={styles.deleteListItem}>
|
||||||
|
<Ionicons name="swap-horizontal" size={16} color={colors.warning} />
|
||||||
|
<Text style={styles.deleteListText}>All your swipes</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.deleteListItem}>
|
||||||
|
<Ionicons name="heart" size={16} color={colors.warning} />
|
||||||
|
<Text style={styles.deleteListText}>All matches</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.deleteListItem}>
|
||||||
|
<Ionicons name="chatbubbles" size={16} color={colors.warning} />
|
||||||
|
<Text style={styles.deleteListText}>All chat messages</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.keepList}>
|
||||||
|
<View style={styles.deleteListItem}>
|
||||||
|
<Ionicons name="checkmark-circle" size={16} color={colors.success} />
|
||||||
|
<Text style={styles.deleteListText}>Your partner connection stays</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.confirmLabel}>
|
||||||
|
Type <Text style={styles.warningKeyword}>RESET</Text> to confirm:
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
style={styles.warningInput}
|
||||||
|
value={confirmText}
|
||||||
|
onChangeText={setConfirmText}
|
||||||
|
placeholder="Type RESET"
|
||||||
|
placeholderTextColor={colors.textTertiary}
|
||||||
|
autoCapitalize="characters"
|
||||||
|
autoCorrect={false}
|
||||||
|
editable={!isResetting}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={styles.modalButtons}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.cancelButton}
|
||||||
|
onPress={closeResetModal}
|
||||||
|
disabled={isResetting}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Text style={styles.cancelButtonText}>Cancel</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.warningActionButton,
|
||||||
|
(confirmText !== "RESET" || isResetting) && styles.deleteButtonDisabled,
|
||||||
|
]}
|
||||||
|
onPress={handleResetProgress}
|
||||||
|
disabled={confirmText !== "RESET" || isResetting}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
{isResetting ? (
|
||||||
|
<ActivityIndicator size="small" color={colors.text} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Ionicons name="refresh" size={16} color={colors.text} />
|
||||||
|
<Text style={styles.deleteButtonText}>Reset</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
</BlurView>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
{/* Delete Relationship Modal */}
|
{/* Delete Relationship Modal */}
|
||||||
<Modal
|
<Modal
|
||||||
visible={showDeleteRelationshipModal}
|
visible={showDeleteRelationshipModal}
|
||||||
@@ -179,8 +330,8 @@ export const DangerZone: React.FC<DangerZoneProps> = ({
|
|||||||
|
|
||||||
<TextInput
|
<TextInput
|
||||||
style={styles.confirmInput}
|
style={styles.confirmInput}
|
||||||
value={deleteConfirmText}
|
value={confirmText}
|
||||||
onChangeText={setDeleteConfirmText}
|
onChangeText={setConfirmText}
|
||||||
placeholder="Type DELETE"
|
placeholder="Type DELETE"
|
||||||
placeholderTextColor={colors.textTertiary}
|
placeholderTextColor={colors.textTertiary}
|
||||||
autoCapitalize="characters"
|
autoCapitalize="characters"
|
||||||
@@ -201,10 +352,10 @@ export const DangerZone: React.FC<DangerZoneProps> = ({
|
|||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[
|
style={[
|
||||||
styles.deleteButton,
|
styles.deleteButton,
|
||||||
(deleteConfirmText !== "DELETE" || isDeletingRelationship) && styles.deleteButtonDisabled,
|
(confirmText !== "DELETE" || isDeletingRelationship) && styles.deleteButtonDisabled,
|
||||||
]}
|
]}
|
||||||
onPress={handleDeleteRelationship}
|
onPress={handleDeleteRelationship}
|
||||||
disabled={deleteConfirmText !== "DELETE" || isDeletingRelationship}
|
disabled={confirmText !== "DELETE" || isDeletingRelationship}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
{isDeletingRelationship ? (
|
{isDeletingRelationship ? (
|
||||||
@@ -283,8 +434,8 @@ export const DangerZone: React.FC<DangerZoneProps> = ({
|
|||||||
|
|
||||||
<TextInput
|
<TextInput
|
||||||
style={styles.confirmInput}
|
style={styles.confirmInput}
|
||||||
value={deleteConfirmText}
|
value={confirmText}
|
||||||
onChangeText={setDeleteConfirmText}
|
onChangeText={setConfirmText}
|
||||||
placeholder="Type DELETE"
|
placeholder="Type DELETE"
|
||||||
placeholderTextColor={colors.textTertiary}
|
placeholderTextColor={colors.textTertiary}
|
||||||
autoCapitalize="characters"
|
autoCapitalize="characters"
|
||||||
@@ -305,10 +456,10 @@ export const DangerZone: React.FC<DangerZoneProps> = ({
|
|||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[
|
style={[
|
||||||
styles.deleteButton,
|
styles.deleteButton,
|
||||||
(deleteConfirmText !== "DELETE" || isDeletingAccount) && styles.deleteButtonDisabled,
|
(confirmText !== "DELETE" || isDeletingAccount) && styles.deleteButtonDisabled,
|
||||||
]}
|
]}
|
||||||
onPress={handleDeleteAccount}
|
onPress={handleDeleteAccount}
|
||||||
disabled={deleteConfirmText !== "DELETE" || isDeletingAccount}
|
disabled={confirmText !== "DELETE" || isDeletingAccount}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
{isDeletingAccount ? (
|
{isDeletingAccount ? (
|
||||||
@@ -342,6 +493,36 @@ const styles = StyleSheet.create({
|
|||||||
marginBottom: spacing.sm,
|
marginBottom: spacing.sm,
|
||||||
marginLeft: spacing.xs,
|
marginLeft: spacing.xs,
|
||||||
},
|
},
|
||||||
|
warningCard: {
|
||||||
|
borderColor: 'rgba(243, 156, 18, 0.3)',
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
warningIconContainer: {
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: 22,
|
||||||
|
backgroundColor: 'rgba(243, 156, 18, 0.15)',
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
warningButton: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
backgroundColor: 'rgba(243, 156, 18, 0.15)',
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
borderRadius: radius.md,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(243, 156, 18, 0.3)',
|
||||||
|
gap: spacing.xs,
|
||||||
|
alignSelf: "flex-end",
|
||||||
|
},
|
||||||
|
warningButtonText: {
|
||||||
|
...typography.subhead,
|
||||||
|
color: colors.warning,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
dangerCard: {
|
dangerCard: {
|
||||||
borderColor: 'rgba(231, 76, 60, 0.3)',
|
borderColor: 'rgba(231, 76, 60, 0.3)',
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
@@ -431,6 +612,58 @@ const styles = StyleSheet.create({
|
|||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: 'rgba(231, 76, 60, 0.2)',
|
borderColor: 'rgba(231, 76, 60, 0.2)',
|
||||||
},
|
},
|
||||||
|
warningList: {
|
||||||
|
backgroundColor: "rgba(243, 156, 18, 0.1)",
|
||||||
|
borderRadius: radius.lg,
|
||||||
|
padding: spacing.md,
|
||||||
|
gap: spacing.sm,
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(243, 156, 18, 0.2)',
|
||||||
|
},
|
||||||
|
keepList: {
|
||||||
|
backgroundColor: "rgba(46, 204, 113, 0.1)",
|
||||||
|
borderRadius: radius.lg,
|
||||||
|
padding: spacing.md,
|
||||||
|
gap: spacing.sm,
|
||||||
|
marginBottom: spacing.xl,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(46, 204, 113, 0.2)',
|
||||||
|
},
|
||||||
|
warningModalIconContainer: {
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
borderRadius: 32,
|
||||||
|
backgroundColor: 'rgba(243, 156, 18, 0.15)',
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
},
|
||||||
|
warningKeyword: {
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: colors.warning,
|
||||||
|
},
|
||||||
|
warningInput: {
|
||||||
|
backgroundColor: colors.glass.background,
|
||||||
|
borderRadius: radius.md,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.warning,
|
||||||
|
padding: spacing.md,
|
||||||
|
...typography.body,
|
||||||
|
color: colors.text,
|
||||||
|
textAlign: 'center',
|
||||||
|
marginBottom: spacing.xl,
|
||||||
|
},
|
||||||
|
warningActionButton: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: colors.warning,
|
||||||
|
paddingVertical: spacing.md,
|
||||||
|
borderRadius: radius.md,
|
||||||
|
alignItems: "center",
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: spacing.xs,
|
||||||
|
},
|
||||||
deleteListItem: {
|
deleteListItem: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
|||||||
@@ -1,40 +1,76 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { View, StyleSheet } from 'react-native';
|
import { View, Text, StyleSheet } from 'react-native';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { SettingsSection } from './SettingsSection';
|
import { SettingsSection } from './SettingsSection';
|
||||||
import { SwitchItem } from './SwitchItem';
|
import { SwitchItem } from './SwitchItem';
|
||||||
import { colors, spacing } from '../../../theme';
|
import { IntensitySlider } from '../../../components/ui/IntensitySlider';
|
||||||
|
import { colors, spacing, radius, typography, featureColors } from '../../../theme';
|
||||||
|
import type { IntensityLevel } from '../../../types';
|
||||||
|
|
||||||
interface PrivacySettingsProps {
|
interface PrivacySettingsProps {
|
||||||
showExplicit: boolean;
|
maxIntensity: IntensityLevel;
|
||||||
isUpdatingExplicit: boolean;
|
isUpdatingIntensity: boolean;
|
||||||
onExplicitToggle: (value: boolean) => void;
|
onIntensityChange: (value: IntensityLevel) => void;
|
||||||
biometricAvailable: boolean;
|
biometricAvailable: boolean;
|
||||||
biometricEnabled: boolean;
|
biometricEnabled: boolean;
|
||||||
biometricType: string;
|
biometricType: string;
|
||||||
isUpdatingBiometric: boolean;
|
isUpdatingBiometric: boolean;
|
||||||
onBiometricToggle: (value: boolean) => void;
|
onBiometricToggle: (value: boolean) => void;
|
||||||
|
// Partner info for slider comparison
|
||||||
|
partnerIntensity?: IntensityLevel | null;
|
||||||
|
partnerName?: string;
|
||||||
|
partnerAvatar?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ACCENT_GRADIENT = featureColors.profile.gradient as [string, string];
|
||||||
|
|
||||||
export const PrivacySettings: React.FC<PrivacySettingsProps> = ({
|
export const PrivacySettings: React.FC<PrivacySettingsProps> = ({
|
||||||
showExplicit,
|
maxIntensity,
|
||||||
isUpdatingExplicit,
|
isUpdatingIntensity,
|
||||||
onExplicitToggle,
|
onIntensityChange,
|
||||||
biometricAvailable,
|
biometricAvailable,
|
||||||
biometricEnabled,
|
biometricEnabled,
|
||||||
biometricType,
|
biometricType,
|
||||||
isUpdatingBiometric,
|
isUpdatingBiometric,
|
||||||
onBiometricToggle,
|
onBiometricToggle,
|
||||||
|
partnerIntensity,
|
||||||
|
partnerName,
|
||||||
|
partnerAvatar,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<SettingsSection title="Preferences" delay={375}>
|
<SettingsSection title="Preferences" delay={375}>
|
||||||
<SwitchItem
|
{/* Comfort Zone Header */}
|
||||||
icon="flame"
|
<View style={styles.comfortHeader}>
|
||||||
label="Spicy Content"
|
<LinearGradient
|
||||||
description="Show spicy question packs"
|
colors={ACCENT_GRADIENT}
|
||||||
value={showExplicit}
|
style={styles.comfortIcon}
|
||||||
onValueChange={onExplicitToggle}
|
start={{ x: 0, y: 0 }}
|
||||||
disabled={isUpdatingExplicit}
|
end={{ x: 1, y: 1 }}
|
||||||
/>
|
>
|
||||||
|
<Ionicons name="heart-circle" size={22} color={colors.text} />
|
||||||
|
</LinearGradient>
|
||||||
|
<View style={styles.comfortText}>
|
||||||
|
<Text style={styles.comfortLabel}>Comfort Zone</Text>
|
||||||
|
<Text style={styles.comfortDescription}>
|
||||||
|
Set the intimacy level you're comfortable with
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Intensity Slider */}
|
||||||
|
<View style={styles.sliderContainer}>
|
||||||
|
<IntensitySlider
|
||||||
|
value={maxIntensity}
|
||||||
|
onValueChange={onIntensityChange}
|
||||||
|
disabled={isUpdatingIntensity}
|
||||||
|
partnerValue={partnerIntensity}
|
||||||
|
partnerName={partnerName}
|
||||||
|
partnerAvatar={partnerAvatar}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Biometric Toggle */}
|
||||||
{biometricAvailable && (
|
{biometricAvailable && (
|
||||||
<>
|
<>
|
||||||
<View style={styles.divider} />
|
<View style={styles.divider} />
|
||||||
@@ -53,9 +89,37 @@ export const PrivacySettings: React.FC<PrivacySettingsProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
|
comfortHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: spacing.lg,
|
||||||
|
},
|
||||||
|
comfortIcon: {
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: 22,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginRight: spacing.md,
|
||||||
|
},
|
||||||
|
comfortText: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
comfortLabel: {
|
||||||
|
...typography.headline,
|
||||||
|
color: colors.text,
|
||||||
|
},
|
||||||
|
comfortDescription: {
|
||||||
|
...typography.caption1,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
sliderContainer: {
|
||||||
|
marginBottom: spacing.sm,
|
||||||
|
},
|
||||||
divider: {
|
divider: {
|
||||||
height: 1,
|
height: 1,
|
||||||
backgroundColor: colors.glass.border,
|
backgroundColor: colors.glass.border,
|
||||||
marginVertical: spacing.md,
|
marginVertical: spacing.lg,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,389 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import Animated, { FadeInDown } from 'react-native-reanimated';
|
||||||
|
|
||||||
|
import { ShimmerEffect } from '../../../components/ui';
|
||||||
|
import { colors, gradients, spacing, typography, radius, shadows } from '../../../theme';
|
||||||
|
|
||||||
|
export interface SubscriptionCardProps {
|
||||||
|
/** Whether user has premium access (own or partner's) */
|
||||||
|
hasPremiumAccess: boolean;
|
||||||
|
/** Whether this is the user's own subscription (vs partner's) */
|
||||||
|
isOwnSubscription: boolean;
|
||||||
|
/** Formatted expiration/renewal date */
|
||||||
|
expirationDate: string;
|
||||||
|
/** Callback when upgrade button is pressed */
|
||||||
|
onUpgradePress: () => void;
|
||||||
|
/** Callback when manage subscription is pressed */
|
||||||
|
onManagePress: () => void;
|
||||||
|
/** Callback when restore purchases is pressed */
|
||||||
|
onRestorePress: () => void;
|
||||||
|
/** Whether restore is in progress */
|
||||||
|
isRestoring?: boolean;
|
||||||
|
/** Animation delay */
|
||||||
|
delay?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PREMIUM_FEATURES = [
|
||||||
|
{ icon: 'layers' as const, text: '50+ Premium Packs' },
|
||||||
|
{ icon: 'flame' as const, text: 'All Intensity Levels' },
|
||||||
|
{ icon: 'sparkles' as const, text: 'New Packs Weekly' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function SubscriptionCard({
|
||||||
|
hasPremiumAccess,
|
||||||
|
isOwnSubscription,
|
||||||
|
expirationDate,
|
||||||
|
onUpgradePress,
|
||||||
|
onManagePress,
|
||||||
|
onRestorePress,
|
||||||
|
isRestoring = false,
|
||||||
|
delay = 350,
|
||||||
|
}: SubscriptionCardProps) {
|
||||||
|
if (hasPremiumAccess) {
|
||||||
|
return (
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeInDown.delay(delay).duration(500)}
|
||||||
|
style={styles.container}
|
||||||
|
>
|
||||||
|
<ProMemberCard
|
||||||
|
isOwnSubscription={isOwnSubscription}
|
||||||
|
expirationDate={expirationDate}
|
||||||
|
onManagePress={onManagePress}
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeInDown.delay(delay).duration(500)}
|
||||||
|
style={styles.container}
|
||||||
|
>
|
||||||
|
<FreeUserCard
|
||||||
|
onUpgradePress={onUpgradePress}
|
||||||
|
onRestorePress={onRestorePress}
|
||||||
|
isRestoring={isRestoring}
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Premium member card - celebratory gold treatment */
|
||||||
|
function ProMemberCard({
|
||||||
|
isOwnSubscription,
|
||||||
|
expirationDate,
|
||||||
|
onManagePress,
|
||||||
|
}: {
|
||||||
|
isOwnSubscription: boolean;
|
||||||
|
expirationDate: string;
|
||||||
|
onManagePress: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View style={styles.proCard}>
|
||||||
|
{/* Gold gradient border effect */}
|
||||||
|
<LinearGradient
|
||||||
|
colors={[colors.premium.gold, colors.premium.goldDark]}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
style={styles.proBorderGradient}
|
||||||
|
/>
|
||||||
|
<View style={styles.proCardInner}>
|
||||||
|
{/* Header with badge */}
|
||||||
|
<View style={styles.proHeader}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={gradients.premiumGold as [string, string]}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
style={styles.proBadge}
|
||||||
|
>
|
||||||
|
<Ionicons name="star" size={16} color={colors.background} />
|
||||||
|
</LinearGradient>
|
||||||
|
<View style={styles.proHeaderText}>
|
||||||
|
<Text style={styles.proTitle}>Pro Member</Text>
|
||||||
|
<Text style={styles.proSubtitle}>
|
||||||
|
{isOwnSubscription
|
||||||
|
? `Renews ${expirationDate}`
|
||||||
|
: "Via partner's subscription"
|
||||||
|
}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{isOwnSubscription && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.manageButton}
|
||||||
|
onPress={onManagePress}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Text style={styles.manageButtonText}>Manage</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Status row */}
|
||||||
|
<View style={styles.proStatusRow}>
|
||||||
|
<View style={styles.proStatusItem}>
|
||||||
|
<Ionicons name="checkmark-circle" size={16} color={colors.premium.gold} />
|
||||||
|
<Text style={styles.proStatusText}>All Packs Unlocked</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.proStatusItem}>
|
||||||
|
<Ionicons name="infinite" size={16} color={colors.premium.gold} />
|
||||||
|
<Text style={styles.proStatusText}>Unlimited Access</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Free user card - premium upsell with shimmer */
|
||||||
|
function FreeUserCard({
|
||||||
|
onUpgradePress,
|
||||||
|
onRestorePress,
|
||||||
|
isRestoring,
|
||||||
|
}: {
|
||||||
|
onUpgradePress: () => void;
|
||||||
|
onRestorePress: () => void;
|
||||||
|
isRestoring: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ShimmerEffect
|
||||||
|
shimmerColor={colors.premium.gold}
|
||||||
|
duration={5000}
|
||||||
|
style={styles.shimmerContainer}
|
||||||
|
>
|
||||||
|
<View style={styles.freeCard}>
|
||||||
|
{/* Gold gradient border */}
|
||||||
|
<LinearGradient
|
||||||
|
colors={[`${colors.premium.gold}60`, `${colors.premium.goldDark}40`]}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
style={styles.freeBorderGradient}
|
||||||
|
/>
|
||||||
|
<View style={styles.freeCardInner}>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={styles.freeHeader}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={gradients.premiumGold as [string, string]}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 0 }}
|
||||||
|
style={styles.freeHeaderBadge}
|
||||||
|
>
|
||||||
|
<Ionicons name="sparkles" size={14} color={colors.background} />
|
||||||
|
<Text style={styles.freeHeaderBadgeText}>SAUCI PRO</Text>
|
||||||
|
</LinearGradient>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Features list */}
|
||||||
|
<View style={styles.featuresList}>
|
||||||
|
{PREMIUM_FEATURES.map((feature, index) => (
|
||||||
|
<View key={index} style={styles.featureItem}>
|
||||||
|
<View style={styles.featureIconContainer}>
|
||||||
|
<Ionicons
|
||||||
|
name={feature.icon}
|
||||||
|
size={16}
|
||||||
|
color={colors.premium.gold}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.featureText}>{feature.text}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* CTA Button */}
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onUpgradePress}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
style={styles.ctaButtonContainer}
|
||||||
|
>
|
||||||
|
<LinearGradient
|
||||||
|
colors={gradients.premiumGold as [string, string]}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 0 }}
|
||||||
|
style={styles.ctaButton}
|
||||||
|
>
|
||||||
|
<Ionicons name="star" size={18} color={colors.background} />
|
||||||
|
<Text style={styles.ctaButtonText}>Upgrade to Pro</Text>
|
||||||
|
</LinearGradient>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{/* Restore link */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.restoreLink}
|
||||||
|
onPress={onRestorePress}
|
||||||
|
disabled={isRestoring}
|
||||||
|
>
|
||||||
|
<Text style={styles.restoreLinkText}>
|
||||||
|
{isRestoring ? "Restoring..." : "Restore Purchases"}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ShimmerEffect>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
marginBottom: spacing.lg,
|
||||||
|
paddingHorizontal: spacing.lg,
|
||||||
|
},
|
||||||
|
shimmerContainer: {
|
||||||
|
borderRadius: radius.lg,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Pro Member Card Styles
|
||||||
|
proCard: {
|
||||||
|
borderRadius: radius.lg,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
proBorderGradient: {
|
||||||
|
...StyleSheet.absoluteFillObject,
|
||||||
|
opacity: 0.3,
|
||||||
|
},
|
||||||
|
proCardInner: {
|
||||||
|
backgroundColor: 'rgba(22, 33, 62, 0.8)',
|
||||||
|
margin: 1,
|
||||||
|
borderRadius: radius.lg - 1,
|
||||||
|
padding: spacing.md,
|
||||||
|
},
|
||||||
|
proHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
proBadge: {
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: 18,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
...shadows.glow(colors.premium.gold),
|
||||||
|
},
|
||||||
|
proHeaderText: {
|
||||||
|
flex: 1,
|
||||||
|
marginLeft: spacing.md,
|
||||||
|
},
|
||||||
|
proTitle: {
|
||||||
|
...typography.headline,
|
||||||
|
color: colors.premium.champagne,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
proSubtitle: {
|
||||||
|
...typography.caption1,
|
||||||
|
color: colors.textTertiary,
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
manageButton: {
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
borderRadius: radius.md,
|
||||||
|
backgroundColor: colors.glass.background,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: `${colors.premium.gold}40`,
|
||||||
|
},
|
||||||
|
manageButtonText: {
|
||||||
|
...typography.subhead,
|
||||||
|
color: colors.premium.gold,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
proStatusRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
marginTop: spacing.md,
|
||||||
|
paddingTop: spacing.md,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: colors.glass.border,
|
||||||
|
gap: spacing.lg,
|
||||||
|
},
|
||||||
|
proStatusItem: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: spacing.xs,
|
||||||
|
},
|
||||||
|
proStatusText: {
|
||||||
|
...typography.caption1,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Free User Card Styles
|
||||||
|
freeCard: {
|
||||||
|
borderRadius: radius.lg,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
freeBorderGradient: {
|
||||||
|
...StyleSheet.absoluteFillObject,
|
||||||
|
},
|
||||||
|
freeCardInner: {
|
||||||
|
backgroundColor: 'rgba(22, 33, 62, 0.85)',
|
||||||
|
margin: 1,
|
||||||
|
borderRadius: radius.lg - 1,
|
||||||
|
padding: spacing.lg,
|
||||||
|
},
|
||||||
|
freeHeader: {
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: spacing.lg,
|
||||||
|
},
|
||||||
|
freeHeaderBadge: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
borderRadius: radius.full,
|
||||||
|
gap: spacing.xs,
|
||||||
|
},
|
||||||
|
freeHeaderBadgeText: {
|
||||||
|
...typography.caption1,
|
||||||
|
fontWeight: '800',
|
||||||
|
letterSpacing: 2,
|
||||||
|
color: colors.background,
|
||||||
|
},
|
||||||
|
featuresList: {
|
||||||
|
marginBottom: spacing.lg,
|
||||||
|
},
|
||||||
|
featureItem: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: spacing.sm,
|
||||||
|
},
|
||||||
|
featureIconContainer: {
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: 14,
|
||||||
|
backgroundColor: colors.premium.goldLight,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginRight: spacing.md,
|
||||||
|
},
|
||||||
|
featureText: {
|
||||||
|
...typography.body,
|
||||||
|
color: colors.text,
|
||||||
|
},
|
||||||
|
ctaButtonContainer: {
|
||||||
|
borderRadius: radius.md,
|
||||||
|
overflow: 'hidden',
|
||||||
|
...shadows.glow(colors.premium.gold),
|
||||||
|
},
|
||||||
|
ctaButton: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingVertical: spacing.md,
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
ctaButtonText: {
|
||||||
|
...typography.headline,
|
||||||
|
color: colors.background,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
restoreLink: {
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: spacing.md,
|
||||||
|
padding: spacing.sm,
|
||||||
|
},
|
||||||
|
restoreLinkText: {
|
||||||
|
...typography.caption1,
|
||||||
|
color: colors.textTertiary,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default SubscriptionCard;
|
||||||
@@ -16,3 +16,5 @@ export { SwitchItem } from './SwitchItem';
|
|||||||
export type { SwitchItemProps } from './SwitchItem';
|
export type { SwitchItemProps } from './SwitchItem';
|
||||||
export { MenuItem } from './MenuItem';
|
export { MenuItem } from './MenuItem';
|
||||||
export type { MenuItemProps } from './MenuItem';
|
export type { MenuItemProps } from './MenuItem';
|
||||||
|
export { SubscriptionCard } from './SubscriptionCard';
|
||||||
|
export type { SubscriptionCardProps } from './SubscriptionCard';
|
||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { decode } from 'base64-arraybuffer';
|
|||||||
import { useAuthStore, useSubscriptionStore, usePacksStore } from '../../../store';
|
import { useAuthStore, useSubscriptionStore, usePacksStore } from '../../../store';
|
||||||
import { supabase } from '../../../lib/supabase';
|
import { supabase } from '../../../lib/supabase';
|
||||||
import { Events } from '../../../lib/analytics';
|
import { Events } from '../../../lib/analytics';
|
||||||
|
import type { IntensityLevel } from '../../../types';
|
||||||
import {
|
import {
|
||||||
registerForPushNotificationsAsync,
|
registerForPushNotificationsAsync,
|
||||||
savePushToken,
|
savePushToken,
|
||||||
@@ -18,11 +19,27 @@ import {
|
|||||||
getBiometricType,
|
getBiometricType,
|
||||||
} from '../../../lib/biometricAuth';
|
} from '../../../lib/biometricAuth';
|
||||||
|
|
||||||
|
const DEFAULT_MAX_INTENSITY: IntensityLevel = 2;
|
||||||
|
|
||||||
|
const normalizeIntensity = (value: number | null | undefined): IntensityLevel | null => {
|
||||||
|
if (value === 1 || value === 2 || value === 3 || value === 4 || value === 5) return value;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getProfileMaxIntensity = (profile?: { max_intensity?: number | null; show_explicit_content?: boolean | null } | null): IntensityLevel => {
|
||||||
|
const normalized = normalizeIntensity(profile?.max_intensity);
|
||||||
|
if (normalized) return normalized;
|
||||||
|
return profile?.show_explicit_content ? 5 : DEFAULT_MAX_INTENSITY;
|
||||||
|
};
|
||||||
|
|
||||||
export function useProfileSettings() {
|
export function useProfileSettings() {
|
||||||
const { user, fetchUser } = useAuthStore();
|
const { user, partner, fetchUser } = useAuthStore();
|
||||||
const { fetchPacks } = usePacksStore();
|
const { fetchPacks } = usePacksStore();
|
||||||
const { restorePurchases, isPurchasing } = useSubscriptionStore();
|
const { restorePurchases, isPurchasing } = useSubscriptionStore();
|
||||||
|
|
||||||
|
// Partner's intensity level (for showing on slider)
|
||||||
|
const partnerIntensity = partner ? getProfileMaxIntensity(partner) : null;
|
||||||
|
|
||||||
// Name Editing
|
// Name Editing
|
||||||
const [isEditingName, setIsEditingName] = useState(false);
|
const [isEditingName, setIsEditingName] = useState(false);
|
||||||
const [newName, setNewName] = useState(user?.name || "");
|
const [newName, setNewName] = useState(user?.name || "");
|
||||||
@@ -32,8 +49,8 @@ export function useProfileSettings() {
|
|||||||
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false);
|
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false);
|
||||||
|
|
||||||
// Preferences
|
// Preferences
|
||||||
const [showExplicit, setShowExplicit] = useState(user?.show_explicit_content ?? true);
|
const [maxIntensity, setMaxIntensity] = useState<IntensityLevel>(() => getProfileMaxIntensity(user));
|
||||||
const [isUpdatingExplicit, setIsUpdatingExplicit] = useState(false);
|
const [isUpdatingIntensity, setIsUpdatingIntensity] = useState(false);
|
||||||
|
|
||||||
// Notifications
|
// Notifications
|
||||||
const [pushEnabled, setPushEnabled] = useState(!!user?.push_token);
|
const [pushEnabled, setPushEnabled] = useState(!!user?.push_token);
|
||||||
@@ -53,9 +70,7 @@ export function useProfileSettings() {
|
|||||||
if (user?.name) {
|
if (user?.name) {
|
||||||
setNewName(user.name);
|
setNewName(user.name);
|
||||||
}
|
}
|
||||||
if (user?.show_explicit_content !== undefined) {
|
setMaxIntensity(getProfileMaxIntensity(user));
|
||||||
setShowExplicit(user.show_explicit_content);
|
|
||||||
}
|
|
||||||
setPushEnabled(!!user?.push_token);
|
setPushEnabled(!!user?.push_token);
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
@@ -256,17 +271,21 @@ export function useProfileSettings() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Preference Actions
|
// Preference Actions
|
||||||
const handleExplicitToggle = async (value: boolean) => {
|
const handleIntensityChange = async (value: IntensityLevel) => {
|
||||||
if (!user?.id) return;
|
if (!user?.id) return;
|
||||||
|
|
||||||
setShowExplicit(value);
|
const previous = maxIntensity;
|
||||||
setIsUpdatingExplicit(true);
|
setMaxIntensity(value);
|
||||||
|
setIsUpdatingIntensity(true);
|
||||||
|
|
||||||
|
const showExplicitContent = value >= 3;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { error } = await supabase
|
const { error } = await supabase
|
||||||
.from('profiles')
|
.from('profiles')
|
||||||
.update({
|
.update({
|
||||||
show_explicit_content: value,
|
max_intensity: value,
|
||||||
|
show_explicit_content: showExplicitContent,
|
||||||
updated_at: new Date().toISOString(),
|
updated_at: new Date().toISOString(),
|
||||||
})
|
})
|
||||||
.eq('id', user.id);
|
.eq('id', user.id);
|
||||||
@@ -276,13 +295,13 @@ export function useProfileSettings() {
|
|||||||
// Refresh user data and packs to reflect the change
|
// Refresh user data and packs to reflect the change
|
||||||
await fetchUser();
|
await fetchUser();
|
||||||
await fetchPacks();
|
await fetchPacks();
|
||||||
Events.profileUpdated(["show_explicit_content"]);
|
Events.profileUpdated(["max_intensity", "show_explicit_content"]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Revert on error
|
// Revert on error
|
||||||
setShowExplicit(!value);
|
setMaxIntensity(previous);
|
||||||
Alert.alert("Error", "Failed to update preference. Please try again.");
|
Alert.alert("Error", "Failed to update preference. Please try again.");
|
||||||
} finally {
|
} finally {
|
||||||
setIsUpdatingExplicit(false);
|
setIsUpdatingIntensity(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -380,8 +399,8 @@ export function useProfileSettings() {
|
|||||||
setNewName,
|
setNewName,
|
||||||
isUpdatingName,
|
isUpdatingName,
|
||||||
isUploadingAvatar,
|
isUploadingAvatar,
|
||||||
showExplicit,
|
maxIntensity,
|
||||||
isUpdatingExplicit,
|
isUpdatingIntensity,
|
||||||
pushEnabled,
|
pushEnabled,
|
||||||
isUpdatingPush,
|
isUpdatingPush,
|
||||||
biometricAvailable,
|
biometricAvailable,
|
||||||
@@ -392,11 +411,16 @@ export function useProfileSettings() {
|
|||||||
setShowPaywall,
|
setShowPaywall,
|
||||||
isPurchasing,
|
isPurchasing,
|
||||||
|
|
||||||
|
// Partner info for comfort zone comparison
|
||||||
|
partnerIntensity,
|
||||||
|
partnerName: partner?.name ?? undefined,
|
||||||
|
partnerAvatar: partner?.avatar_url ?? undefined,
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
handleUpdateName,
|
handleUpdateName,
|
||||||
handleCancelEditName,
|
handleCancelEditName,
|
||||||
handleAvatarPress,
|
handleAvatarPress,
|
||||||
handleExplicitToggle,
|
handleIntensityChange,
|
||||||
handlePushToggle,
|
handlePushToggle,
|
||||||
handleBiometricToggle,
|
handleBiometricToggle,
|
||||||
handleRestorePurchases,
|
handleRestorePurchases,
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -1,400 +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 } 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 shared cache from mediaCache to avoid circular dependency
|
|
||||||
import { decryptedMediaCache, clearDecryptedMediaCache } from '../lib/mediaCache';
|
|
||||||
|
|
||||||
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 isMounted = useRef(true);
|
|
||||||
|
|
||||||
const repairAttemptedByMessageId = useRef(new Set<string>());
|
|
||||||
const [retryNonce, setRetryNonce] = useState(0);
|
|
||||||
|
|
||||||
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) {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
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)) {
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
isMounted.current = true;
|
|
||||||
decryptMedia();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
isMounted.current = false;
|
|
||||||
};
|
|
||||||
}, [decryptMedia, retryNonce]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
isEncrypted,
|
|
||||||
retry,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,364 +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,
|
|
||||||
} 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';
|
|
||||||
|
|
||||||
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 } = useEncryptionKeys();
|
|
||||||
|
|
||||||
// Track per-message repair attempts to avoid tight retry loops.
|
|
||||||
const repairAttemptedByMessageId = useRef(new Set<string>());
|
|
||||||
const [retryNonce, setRetryNonce] = useState(0);
|
|
||||||
|
|
||||||
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) {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shouldAttemptStaleKeyRepair) {
|
|
||||||
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);
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.error(`[E2EE Decrypt] Message ${message.id}: Stale key repair failed:`, repairResult.error);
|
|
||||||
}
|
|
||||||
} catch (repairErr) {
|
|
||||||
console.error(`[E2EE Decrypt] Message ${message.id}: Stale key repair threw:`, repairErr);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Repair failed or retry failed
|
|
||||||
setState({
|
|
||||||
content: null,
|
|
||||||
mediaUri: null,
|
|
||||||
isDecrypting: false,
|
|
||||||
error: new Error('Waiting for message...'),
|
|
||||||
errorCode: 'E2EE_DECRYPT_FAILED',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
decrypt();
|
|
||||||
}, [decrypt, retryNonce]);
|
|
||||||
|
|
||||||
return { ...state, retry };
|
|
||||||
}
|
|
||||||
@@ -1,200 +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, useState, useRef } from 'react';
|
|
||||||
import { Platform } from 'react-native';
|
|
||||||
import {
|
|
||||||
encryptTextMessage,
|
|
||||||
getAdminPublicKey,
|
|
||||||
getAdminKeyId,
|
|
||||||
isValidPublicKeyJwk,
|
|
||||||
sanitizePublicKeyJwk,
|
|
||||||
verifyKeyPair,
|
|
||||||
} from '../lib/encryption';
|
|
||||||
import type { RSAPublicKeyJWK, EncryptedMessagePayload } from '../lib/encryption';
|
|
||||||
import { useEncryptionKeys } from './useEncryptionKeys';
|
|
||||||
import { useAuthStore } from '../store';
|
|
||||||
|
|
||||||
interface UseEncryptedSendReturn {
|
|
||||||
/** Encrypt a text message for sending */
|
|
||||||
encryptMessage: (plaintext: string) => Promise<EncryptedMessagePayload | null>;
|
|
||||||
/** Whether E2EE is available (keys loaded, not on web) */
|
|
||||||
isE2EEAvailable: boolean;
|
|
||||||
/** Whether encryption is currently in progress */
|
|
||||||
isEncrypting: boolean;
|
|
||||||
/** Last encryption error, if any */
|
|
||||||
error: Error | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook for encrypting messages before sending
|
|
||||||
*
|
|
||||||
* Returns null if E2EE is not available (web platform, no keys).
|
|
||||||
* In that case, the caller should send plaintext (v1) messages.
|
|
||||||
*/
|
|
||||||
export function useEncryptedSend(): UseEncryptedSendReturn {
|
|
||||||
const [isEncrypting, setIsEncrypting] = useState(false);
|
|
||||||
const [error, setError] = useState<Error | null>(null);
|
|
||||||
|
|
||||||
// Track the version of keys we have verified to handle key rotation/regeneration
|
|
||||||
const verifiedKeysVersionRef = useRef<number | null>(null);
|
|
||||||
|
|
||||||
const { publicKeyJwk, hasKeys, isLoading } = useEncryptionKeys();
|
|
||||||
const refreshPartner = useAuthStore((s) => s.refreshPartner);
|
|
||||||
|
|
||||||
// E2EE is available if:
|
|
||||||
// - Not on web platform
|
|
||||||
// - Keys are loaded and available
|
|
||||||
const isE2EEAvailable =
|
|
||||||
Platform.OS !== 'web' && hasKeys && !isLoading && !!publicKeyJwk;
|
|
||||||
|
|
||||||
const encryptMessage = useCallback(
|
|
||||||
async (plaintext: string): Promise<EncryptedMessagePayload | null> => {
|
|
||||||
// Return null if E2EE not available - caller will send plaintext
|
|
||||||
if (!isE2EEAvailable) {
|
|
||||||
console.log('[E2EE Send] E2EE not available, will send plaintext');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 {
|
|
||||||
// Always refresh partner's profile to get their latest public key
|
|
||||||
// This ensures we don't miss their key if they just generated it
|
|
||||||
console.log('[E2EE Send] Refreshing partner profile to get latest public key...');
|
|
||||||
const freshPartner = await refreshPartner();
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
|
|
||||||
// Encrypt the message using the key from global store
|
|
||||||
const encryptedPayload = await encryptTextMessage(
|
|
||||||
plaintext,
|
|
||||||
senderPublicKey as RSAPublicKeyJWK,
|
|
||||||
partnerPublicKey ?? null,
|
|
||||||
adminPublicKey,
|
|
||||||
adminKeyId
|
|
||||||
);
|
|
||||||
|
|
||||||
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,
|
|
||||||
isEncrypting,
|
|
||||||
error,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,349 +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>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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) || (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]);
|
|
||||||
|
|
||||||
// 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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -8,6 +8,12 @@ export async function initAnalytics() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Skip initialization in non-RN environments (e.g., during tsc/Node.js)
|
||||||
|
// AsyncStorage requires window to be defined
|
||||||
|
if (typeof window === "undefined" && typeof global !== "undefined" && !("__BUNDLE_START_TIME__" in global)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const apiKey = process.env.EXPO_PUBLIC_POSTHOG_API_KEY;
|
const apiKey = process.env.EXPO_PUBLIC_POSTHOG_API_KEY;
|
||||||
const host = process.env.EXPO_PUBLIC_POSTHOG_HOST || "https://us.i.posthog.com";
|
const host = process.env.EXPO_PUBLIC_POSTHOG_HOST || "https://us.i.posthog.com";
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 } 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,72 +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';
|
|
||||||
|
|
||||||
export interface KeyRotationResult {
|
|
||||||
success: boolean;
|
|
||||||
updated: number;
|
|
||||||
reason?: string;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
let inFlight: Promise<KeyRotationResult | null> | null = null;
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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('');
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import type { Ionicons } from "@expo/vector-icons";
|
||||||
|
|
||||||
|
type IoniconsName = keyof typeof Ionicons.glyphMap;
|
||||||
|
|
||||||
|
// Legacy emoji mappings (for backwards compatibility with existing packs)
|
||||||
|
const EMOJI_TO_IONICON: Record<string, IoniconsName> = {
|
||||||
|
// Career & Professional
|
||||||
|
"💼": "briefcase-outline",
|
||||||
|
|
||||||
|
// Relationships & Connection
|
||||||
|
"🔗": "heart-half-outline",
|
||||||
|
"❤️": "heart-outline",
|
||||||
|
"💫": "sparkles-outline",
|
||||||
|
|
||||||
|
// Secrets & Mystery
|
||||||
|
"🤫": "eye-off-outline",
|
||||||
|
"🗝️": "key-outline",
|
||||||
|
|
||||||
|
// Romance & Dates
|
||||||
|
"🌹": "flower-outline",
|
||||||
|
"🍷": "wine-outline",
|
||||||
|
"✨": "sparkles-outline",
|
||||||
|
|
||||||
|
// Adventure & Travel
|
||||||
|
"✈️": "airplane-outline",
|
||||||
|
"🚗": "car-outline",
|
||||||
|
"🏡": "home-outline",
|
||||||
|
|
||||||
|
// Playful & Games
|
||||||
|
"🎭": "color-wand-outline",
|
||||||
|
"🎲": "dice-outline",
|
||||||
|
"🎁": "gift-outline",
|
||||||
|
|
||||||
|
// Intimacy & Physical
|
||||||
|
"🔄": "sync-outline",
|
||||||
|
"😈": "flash-outline",
|
||||||
|
"🔥": "flame-outline",
|
||||||
|
|
||||||
|
// Goals & Planning
|
||||||
|
"🎯": "flag-outline",
|
||||||
|
"☀️": "sunny-outline",
|
||||||
|
|
||||||
|
// Default fallbacks
|
||||||
|
"📦": "cube-outline",
|
||||||
|
"📁": "folder-outline",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PACK_ICON_MAP = EMOJI_TO_IONICON;
|
||||||
|
|
||||||
|
export const DEFAULT_PACK_ICON: IoniconsName = "layers-outline";
|
||||||
|
|
||||||
|
// List of valid Ionicon names (subset we support)
|
||||||
|
const VALID_IONICONS = new Set([
|
||||||
|
"heart-outline",
|
||||||
|
"heart-half-outline",
|
||||||
|
"heart-circle-outline",
|
||||||
|
"chatbubbles-outline",
|
||||||
|
"chatbox-outline",
|
||||||
|
"mail-outline",
|
||||||
|
"flower-outline",
|
||||||
|
"wine-outline",
|
||||||
|
"restaurant-outline",
|
||||||
|
"cafe-outline",
|
||||||
|
"airplane-outline",
|
||||||
|
"car-outline",
|
||||||
|
"compass-outline",
|
||||||
|
"map-outline",
|
||||||
|
"home-outline",
|
||||||
|
"people-outline",
|
||||||
|
"person-outline",
|
||||||
|
"eye-off-outline",
|
||||||
|
"key-outline",
|
||||||
|
"lock-closed-outline",
|
||||||
|
"dice-outline",
|
||||||
|
"gift-outline",
|
||||||
|
"sparkles-outline",
|
||||||
|
"star-outline",
|
||||||
|
"flame-outline",
|
||||||
|
"flash-outline",
|
||||||
|
"moon-outline",
|
||||||
|
"sunny-outline",
|
||||||
|
"flag-outline",
|
||||||
|
"calendar-outline",
|
||||||
|
"checkbox-outline",
|
||||||
|
"trophy-outline",
|
||||||
|
"layers-outline",
|
||||||
|
"cube-outline",
|
||||||
|
"folder-outline",
|
||||||
|
"bookmark-outline",
|
||||||
|
"bulb-outline",
|
||||||
|
"color-wand-outline",
|
||||||
|
"sync-outline",
|
||||||
|
"refresh-outline",
|
||||||
|
"briefcase-outline",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts an icon value to an Ionicons name.
|
||||||
|
* Handles both:
|
||||||
|
* - Ionicon names directly (new packs): "briefcase-outline" -> "briefcase-outline"
|
||||||
|
* - Legacy emojis (existing packs): "💼" -> "briefcase-outline"
|
||||||
|
*/
|
||||||
|
export function getPackIconName(icon: string | null | undefined): IoniconsName {
|
||||||
|
if (!icon) return DEFAULT_PACK_ICON;
|
||||||
|
|
||||||
|
if (VALID_IONICONS.has(icon)) {
|
||||||
|
return icon as IoniconsName;
|
||||||
|
}
|
||||||
|
|
||||||
|
return EMOJI_TO_IONICON[icon] || DEFAULT_PACK_ICON;
|
||||||
|
}
|
||||||
@@ -1,24 +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.
|
|
||||||
*/
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
user: Profile | null;
|
user: Profile | null;
|
||||||
@@ -27,19 +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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Import other stores lazily to avoid circular dependency issues
|
// Import other stores lazily to avoid circular dependency issues
|
||||||
@@ -51,14 +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,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useAuthStore = create<AuthState>((set, get) => ({
|
export const useAuthStore = create<AuthState>((set, get) => ({
|
||||||
user: null,
|
user: null,
|
||||||
@@ -66,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 {
|
||||||
@@ -84,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 });
|
||||||
@@ -175,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,
|
||||||
@@ -196,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();
|
||||||
@@ -230,28 +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,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear encryption keys (called on sign out or when leaving couple)
|
|
||||||
*/
|
|
||||||
clearEncryptionKeys: () => {
|
|
||||||
set({ encryptionKeys: initialEncryptionKeyState });
|
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -2,9 +2,25 @@ 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 type { QuestionPack, Category } from "@/types";
|
import type { QuestionPack, Category } from "@/types";
|
||||||
import { useAuthStore } from "./authStore";
|
import { useAuthStore } from "./authStore";
|
||||||
|
|
||||||
interface PacksState {
|
const DEFAULT_MAX_INTENSITY = 2;
|
||||||
|
|
||||||
|
const normalizeIntensity = (value?: number | null) => {
|
||||||
|
if (typeof value !== "number" || Number.isNaN(value)) return null;
|
||||||
|
const rounded = Math.round(value);
|
||||||
|
if (rounded < 1 || rounded > 5) return null;
|
||||||
|
return rounded;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUserMaxIntensity = () => {
|
||||||
|
const user = useAuthStore.getState().user;
|
||||||
|
const normalized = normalizeIntensity(user?.max_intensity ?? null);
|
||||||
|
if (normalized) return normalized;
|
||||||
|
return user?.show_explicit_content ? 5 : DEFAULT_MAX_INTENSITY;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface PacksState {
|
||||||
packs: QuestionPack[];
|
packs: QuestionPack[];
|
||||||
categories: Category[];
|
categories: Category[];
|
||||||
enabledPackIds: string[];
|
enabledPackIds: string[];
|
||||||
@@ -25,8 +41,9 @@ export const usePacksStore = create<PacksState>((set, get) => ({
|
|||||||
fetchPacks: async () => {
|
fetchPacks: async () => {
|
||||||
set({ isLoading: true });
|
set({ isLoading: true });
|
||||||
|
|
||||||
// Get user's explicit content preference
|
// Get user's max intensity preference
|
||||||
const showExplicit = useAuthStore.getState().user?.show_explicit_content ?? true;
|
const maxIntensity = getUserMaxIntensity();
|
||||||
|
|
||||||
|
|
||||||
// Fetch categories
|
// Fetch categories
|
||||||
const { data: categories } = await supabase
|
const { data: categories } = await supabase
|
||||||
@@ -41,10 +58,11 @@ export const usePacksStore = create<PacksState>((set, get) => ({
|
|||||||
.eq("is_public", true)
|
.eq("is_public", true)
|
||||||
.order("sort_order");
|
.order("sort_order");
|
||||||
|
|
||||||
// Filter out explicit packs if user doesn't want to see them
|
// Filter out packs above user's intensity preference
|
||||||
if (!showExplicit) {
|
if (maxIntensity < 5) {
|
||||||
query = query.eq("is_explicit", false);
|
query = query.or(`max_intensity.is.null,max_intensity.lte.${maxIntensity}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const { data: packs } = await query;
|
const { data: packs } = await query;
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
// User profile (extends Supabase auth.users)
|
// User profile (extends Supabase auth.users)
|
||||||
export type Gender = 'male' | 'female' | 'non-binary' | 'prefer-not-to-say';
|
export type Gender = 'male' | 'female' | 'non-binary' | 'prefer-not-to-say';
|
||||||
|
export type IntensityLevel = 1 | 2 | 3 | 4 | 5;
|
||||||
export interface Profile {
|
|
||||||
|
export interface Profile {
|
||||||
id: string;
|
id: string;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
@@ -9,9 +10,10 @@ export interface Profile {
|
|||||||
push_token: string | null;
|
push_token: string | null;
|
||||||
is_premium: boolean;
|
is_premium: boolean;
|
||||||
couple_id: string | null;
|
couple_id: string | null;
|
||||||
gender: Gender | null;
|
gender: Gender | null;
|
||||||
show_explicit_content: boolean;
|
show_explicit_content: boolean;
|
||||||
onboarding_completed: boolean;
|
max_intensity: IntensityLevel;
|
||||||
|
onboarding_completed: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
/** RSA public key for E2EE (JWK format) */
|
/** RSA public key for E2EE (JWK format) */
|
||||||
@@ -42,9 +44,12 @@ export interface QuestionPack {
|
|||||||
description: string | null;
|
description: string | null;
|
||||||
icon: string | null;
|
icon: string | null;
|
||||||
is_premium: boolean;
|
is_premium: boolean;
|
||||||
is_public: boolean;
|
is_public: boolean;
|
||||||
is_explicit: boolean;
|
is_explicit: boolean;
|
||||||
sort_order: number;
|
min_intensity?: number;
|
||||||
|
max_intensity?: number;
|
||||||
|
avg_intensity?: number;
|
||||||
|
sort_order: number;
|
||||||
category_id: string | null;
|
category_id: string | null;
|
||||||
category?: Category;
|
category?: Category;
|
||||||
questions?: { count: number }[];
|
questions?: { count: number }[];
|
||||||
|
|||||||
@@ -67,14 +67,14 @@ Deno.serve(async (req) => {
|
|||||||
return json({ error: "Missing encryption metadata" }, 400);
|
return json({ error: "Missing encryption metadata" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const adminPrivateKeyJwkStr = Deno.env.get("ADMIN_PRIVATE_KEY_JWK");
|
const adminPrivateKeyJwk = getAdminPrivateKey(keysMetadata.admin_key_id);
|
||||||
if (!adminPrivateKeyJwkStr) {
|
if (!adminPrivateKeyJwk) {
|
||||||
return json({ error: "Admin decryption key not configured" }, 500);
|
return json({ error: "Admin decryption key not configured" }, 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawAesKey = await unwrapWithAdminKey(
|
const rawAesKey = await unwrapWithAdminKey(
|
||||||
keysMetadata.admin_wrapped_key,
|
keysMetadata.admin_wrapped_key,
|
||||||
JSON.parse(adminPrivateKeyJwkStr)
|
adminPrivateKeyJwk
|
||||||
);
|
);
|
||||||
|
|
||||||
const aesKey = await crypto.subtle.importKey(
|
const aesKey = await crypto.subtle.importKey(
|
||||||
@@ -166,3 +166,28 @@ async function assertSuperAdmin(
|
|||||||
throw new Error("Access denied");
|
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;
|
||||||
|
}
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -177,20 +177,42 @@ Deno.serve(async (req) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update user's profile with couple_id
|
// Update user's profile with couple_id
|
||||||
await supabase
|
await supabase
|
||||||
.from("profiles")
|
.from("profiles")
|
||||||
.update({ couple_id: couple.id })
|
.update({ couple_id: couple.id })
|
||||||
.eq("id", user.id);
|
.eq("id", user.id);
|
||||||
|
|
||||||
return new Response(
|
const { data: safePacks, error: safePacksError } = await supabase
|
||||||
JSON.stringify({
|
.from("question_packs")
|
||||||
success: true,
|
.select("id")
|
||||||
couple_id: couple.id,
|
.lte("avg_intensity", 2)
|
||||||
invite_code: couple.invite_code
|
.eq("is_public", true);
|
||||||
}),
|
|
||||||
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
if (safePacksError) {
|
||||||
);
|
console.error("Failed to fetch safe packs:", safePacksError);
|
||||||
|
} else if (safePacks?.length) {
|
||||||
|
const { error: insertError } = await supabase
|
||||||
|
.from("couple_packs")
|
||||||
|
.insert(safePacks.map((pack: { id: string }) => ({
|
||||||
|
couple_id: couple.id,
|
||||||
|
pack_id: pack.id,
|
||||||
|
enabled: true,
|
||||||
|
})));
|
||||||
|
|
||||||
|
if (insertError) {
|
||||||
|
console.error("Failed to enable safe packs:", insertError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
couple_id: couple.id,
|
||||||
|
invite_code: couple.invite_code
|
||||||
|
}),
|
||||||
|
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,88 @@
|
|||||||
|
-- Add intensity levels for content balance
|
||||||
|
|
||||||
|
ALTER TABLE profiles
|
||||||
|
ADD COLUMN IF NOT EXISTS max_intensity INTEGER DEFAULT 2
|
||||||
|
CHECK (max_intensity >= 1 AND max_intensity <= 5);
|
||||||
|
|
||||||
|
UPDATE profiles
|
||||||
|
SET max_intensity = CASE
|
||||||
|
WHEN show_explicit_content IS TRUE THEN 5
|
||||||
|
ELSE 2
|
||||||
|
END
|
||||||
|
WHERE max_intensity IS NULL;
|
||||||
|
|
||||||
|
ALTER TABLE question_packs
|
||||||
|
ADD COLUMN IF NOT EXISTS min_intensity INTEGER,
|
||||||
|
ADD COLUMN IF NOT EXISTS max_intensity INTEGER,
|
||||||
|
ADD COLUMN IF NOT EXISTS avg_intensity NUMERIC(3,2);
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION update_question_pack_intensity_stats(target_pack_id UUID)
|
||||||
|
RETURNS void
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SET search_path = 'public'
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
UPDATE question_packs qp
|
||||||
|
SET min_intensity = stats.min_intensity,
|
||||||
|
max_intensity = stats.max_intensity,
|
||||||
|
avg_intensity = stats.avg_intensity
|
||||||
|
FROM (
|
||||||
|
SELECT pack_id,
|
||||||
|
MIN(intensity) AS min_intensity,
|
||||||
|
MAX(intensity) AS max_intensity,
|
||||||
|
ROUND(AVG(intensity)::numeric, 2) AS avg_intensity
|
||||||
|
FROM questions
|
||||||
|
WHERE pack_id = target_pack_id
|
||||||
|
GROUP BY pack_id
|
||||||
|
) stats
|
||||||
|
WHERE qp.id = stats.pack_id;
|
||||||
|
|
||||||
|
IF NOT FOUND THEN
|
||||||
|
UPDATE question_packs
|
||||||
|
SET min_intensity = NULL,
|
||||||
|
max_intensity = NULL,
|
||||||
|
avg_intensity = NULL
|
||||||
|
WHERE id = target_pack_id;
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION handle_question_intensity_change()
|
||||||
|
RETURNS TRIGGER
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SET search_path = 'public'
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
IF TG_OP = 'DELETE' THEN
|
||||||
|
PERFORM update_question_pack_intensity_stats(OLD.pack_id);
|
||||||
|
RETURN OLD;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF TG_OP = 'UPDATE' AND NEW.pack_id IS DISTINCT FROM OLD.pack_id THEN
|
||||||
|
PERFORM update_question_pack_intensity_stats(OLD.pack_id);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
PERFORM update_question_pack_intensity_stats(NEW.pack_id);
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS question_pack_intensity_stats ON questions;
|
||||||
|
|
||||||
|
CREATE TRIGGER question_pack_intensity_stats
|
||||||
|
AFTER INSERT OR UPDATE OR DELETE ON questions
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION handle_question_intensity_change();
|
||||||
|
|
||||||
|
UPDATE question_packs qp
|
||||||
|
SET min_intensity = stats.min_intensity,
|
||||||
|
max_intensity = stats.max_intensity,
|
||||||
|
avg_intensity = stats.avg_intensity
|
||||||
|
FROM (
|
||||||
|
SELECT pack_id,
|
||||||
|
MIN(intensity) AS min_intensity,
|
||||||
|
MAX(intensity) AS max_intensity,
|
||||||
|
ROUND(AVG(intensity)::numeric, 2) AS avg_intensity
|
||||||
|
FROM questions
|
||||||
|
GROUP BY pack_id
|
||||||
|
) stats
|
||||||
|
WHERE qp.id = stats.pack_id;
|
||||||
Generated
+7
@@ -313,6 +313,7 @@
|
|||||||
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
|
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/prop-types": "*",
|
"@types/prop-types": "*",
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
@@ -449,6 +450,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",
|
||||||
@@ -865,6 +867,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"
|
||||||
}
|
}
|
||||||
@@ -1175,6 +1178,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",
|
||||||
@@ -1367,6 +1371,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"
|
||||||
},
|
},
|
||||||
@@ -1379,6 +1384,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"
|
||||||
@@ -1674,6 +1680,7 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,279 @@
|
|||||||
|
# Pack Icons: Admin-First Approach
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
The current pack icon system uses emojis stored in the database, which creates a "AI slop" appearance. We've added a frontend mapping from emojis to Ionicons, but this is fragile:
|
||||||
|
|
||||||
|
- New packs require manual code changes to add mappings
|
||||||
|
- The mapping is hidden from admins
|
||||||
|
- It's a maintenance burden
|
||||||
|
|
||||||
|
## Solution: Admin-First Icon Selection
|
||||||
|
|
||||||
|
Allow admins to select Ionicons directly in the admin UI, storing the Ionicon name (e.g., `"briefcase-outline"`) in the database instead of emojis.
|
||||||
|
|
||||||
|
## Backwards Compatibility
|
||||||
|
|
||||||
|
**Important**: We must maintain backwards compatibility with the app currently in App Store review.
|
||||||
|
|
||||||
|
| App Version | DB has emoji "💼" | DB has "briefcase-outline" |
|
||||||
|
|-------------|-------------------|---------------------------|
|
||||||
|
| Old (in review) | Shows emoji | Shows literal text (broken) |
|
||||||
|
| New (with mapping) | Maps to Ionicon | Uses directly |
|
||||||
|
|
||||||
|
### Strategy
|
||||||
|
|
||||||
|
1. **Don't migrate existing data** - existing packs keep their emojis
|
||||||
|
2. **New packs use Ionicon names** - admin UI stores Ionicon names
|
||||||
|
3. **Mobile handles both formats** - detect format and handle appropriately
|
||||||
|
|
||||||
|
This ensures:
|
||||||
|
- Old app continues working with existing emoji packs
|
||||||
|
- New packs show default icon on old app (acceptable degradation)
|
||||||
|
- New app handles everything correctly
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Tasks
|
||||||
|
|
||||||
|
### Task 1: Create Ionicon Picker Component (Admin)
|
||||||
|
|
||||||
|
**File**: `apps/admin/src/components/ui/icon-picker.tsx` (new file)
|
||||||
|
|
||||||
|
Create a new component that displays a grid of available Ionicons for selection.
|
||||||
|
|
||||||
|
**Requirements**:
|
||||||
|
- Display commonly used Ionicons in a grid/dropdown
|
||||||
|
- Show icon preview with name
|
||||||
|
- Search/filter functionality (optional)
|
||||||
|
- Return the Ionicon name string (e.g., `"briefcase-outline"`)
|
||||||
|
|
||||||
|
**Suggested Icons to Include**:
|
||||||
|
```typescript
|
||||||
|
const AVAILABLE_ICONS = [
|
||||||
|
// Relationships & Love
|
||||||
|
'heart-outline', 'heart-half-outline', 'heart-circle-outline',
|
||||||
|
|
||||||
|
// Communication
|
||||||
|
'chatbubbles-outline', 'chatbox-outline', 'mail-outline',
|
||||||
|
|
||||||
|
// Romance & Dates
|
||||||
|
'flower-outline', 'wine-outline', 'restaurant-outline', 'cafe-outline',
|
||||||
|
|
||||||
|
// Adventure & Travel
|
||||||
|
'airplane-outline', 'car-outline', 'compass-outline', 'map-outline',
|
||||||
|
|
||||||
|
// Home & Family
|
||||||
|
'home-outline', 'people-outline', 'person-outline',
|
||||||
|
|
||||||
|
// Mystery & Secrets
|
||||||
|
'eye-off-outline', 'key-outline', 'lock-closed-outline',
|
||||||
|
|
||||||
|
// Fun & Games
|
||||||
|
'dice-outline', 'gift-outline', 'sparkles-outline', 'star-outline',
|
||||||
|
|
||||||
|
// Intimacy
|
||||||
|
'flame-outline', 'flash-outline', 'moon-outline', 'sunny-outline',
|
||||||
|
|
||||||
|
// Goals & Planning
|
||||||
|
'flag-outline', 'calendar-outline', 'checkbox-outline', 'trophy-outline',
|
||||||
|
|
||||||
|
// General
|
||||||
|
'layers-outline', 'cube-outline', 'folder-outline', 'bookmark-outline',
|
||||||
|
'bulb-outline', 'color-wand-outline', 'sync-outline', 'refresh-outline',
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
**Component Interface**:
|
||||||
|
```typescript
|
||||||
|
interface IconPickerProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (iconName: string) => void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Update Pack Form Dialog (Admin)
|
||||||
|
|
||||||
|
**File**: `apps/admin/src/components/content/PackFormDialog.tsx`
|
||||||
|
|
||||||
|
**Changes**:
|
||||||
|
1. Import new `IconPicker` component
|
||||||
|
2. Replace `EmojiPicker` with `IconPicker` for the icon field
|
||||||
|
|
||||||
|
**Current code (line ~101-104)**:
|
||||||
|
```tsx
|
||||||
|
<EmojiPicker
|
||||||
|
value={formData.icon}
|
||||||
|
onChange={(emoji) => setField('icon', emoji)}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
**New code**:
|
||||||
|
```tsx
|
||||||
|
<IconPicker
|
||||||
|
value={formData.icon}
|
||||||
|
onChange={(iconName) => setField('icon', iconName)}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Update default icon in `initialFormData` (line ~41):
|
||||||
|
```typescript
|
||||||
|
// Change from '💕' to:
|
||||||
|
icon: 'heart-outline',
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Update Category Form (Admin)
|
||||||
|
|
||||||
|
**File**: `apps/admin/src/pages/content/CategoriesPage.tsx`
|
||||||
|
|
||||||
|
Same changes as Task 2 - replace `EmojiPicker` with `IconPicker` for category icons.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Update AI Pack Generator (Admin)
|
||||||
|
|
||||||
|
**File**: `apps/admin/src/lib/ai/generators/packs.ts`
|
||||||
|
|
||||||
|
Update the AI prompt to suggest Ionicon names instead of emojis.
|
||||||
|
|
||||||
|
**Current (line ~147)**:
|
||||||
|
```typescript
|
||||||
|
- icon: A single descriptive emoji
|
||||||
|
```
|
||||||
|
|
||||||
|
**New**:
|
||||||
|
```typescript
|
||||||
|
- 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
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Update Mobile Icon Utility
|
||||||
|
|
||||||
|
**File**: `apps/mobile/src/lib/packIcons.ts`
|
||||||
|
|
||||||
|
Update `getPackIconName()` to handle both Ionicon names and legacy emojis.
|
||||||
|
|
||||||
|
**New implementation**:
|
||||||
|
```typescript
|
||||||
|
import type { Ionicons } from "@expo/vector-icons";
|
||||||
|
|
||||||
|
type IoniconsName = keyof typeof Ionicons.glyphMap;
|
||||||
|
|
||||||
|
// Legacy emoji mappings (for backwards compatibility with existing packs)
|
||||||
|
const EMOJI_TO_IONICON: Record<string, IoniconsName> = {
|
||||||
|
"💼": "briefcase-outline",
|
||||||
|
"🔗": "heart-half-outline",
|
||||||
|
"❤️": "heart-outline",
|
||||||
|
"💫": "sparkles-outline",
|
||||||
|
"🤫": "eye-off-outline",
|
||||||
|
"🗝️": "key-outline",
|
||||||
|
"🌹": "flower-outline",
|
||||||
|
"🍷": "wine-outline",
|
||||||
|
"✨": "sparkles-outline",
|
||||||
|
"✈️": "airplane-outline",
|
||||||
|
"🚗": "car-outline",
|
||||||
|
"🏡": "home-outline",
|
||||||
|
"🎭": "color-wand-outline",
|
||||||
|
"🎲": "dice-outline",
|
||||||
|
"🎁": "gift-outline",
|
||||||
|
"🔄": "sync-outline",
|
||||||
|
"😈": "flash-outline",
|
||||||
|
"🔥": "flame-outline",
|
||||||
|
"🎯": "flag-outline",
|
||||||
|
"☀️": "sunny-outline",
|
||||||
|
"📦": "cube-outline",
|
||||||
|
"📁": "folder-outline",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_PACK_ICON: IoniconsName = "layers-outline";
|
||||||
|
|
||||||
|
// List of valid Ionicon names (subset we support)
|
||||||
|
const VALID_IONICONS = new Set([
|
||||||
|
'heart-outline', 'heart-half-outline', 'heart-circle-outline',
|
||||||
|
'chatbubbles-outline', 'chatbox-outline', 'mail-outline',
|
||||||
|
'flower-outline', 'wine-outline', 'restaurant-outline', 'cafe-outline',
|
||||||
|
'airplane-outline', 'car-outline', 'compass-outline', 'map-outline',
|
||||||
|
'home-outline', 'people-outline', 'person-outline',
|
||||||
|
'eye-off-outline', 'key-outline', 'lock-closed-outline',
|
||||||
|
'dice-outline', 'gift-outline', 'sparkles-outline', 'star-outline',
|
||||||
|
'flame-outline', 'flash-outline', 'moon-outline', 'sunny-outline',
|
||||||
|
'flag-outline', 'calendar-outline', 'checkbox-outline', 'trophy-outline',
|
||||||
|
'layers-outline', 'cube-outline', 'folder-outline', 'bookmark-outline',
|
||||||
|
'bulb-outline', 'color-wand-outline', 'sync-outline', 'refresh-outline',
|
||||||
|
'briefcase-outline',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts an icon value to an Ionicons name.
|
||||||
|
* Handles both:
|
||||||
|
* - Ionicon names directly (new packs): "briefcase-outline" -> "briefcase-outline"
|
||||||
|
* - Legacy emojis (existing packs): "💼" -> "briefcase-outline"
|
||||||
|
*/
|
||||||
|
export function getPackIconName(icon: string | null | undefined): IoniconsName {
|
||||||
|
if (!icon) return DEFAULT_PACK_ICON;
|
||||||
|
|
||||||
|
// Check if it's already a valid Ionicon name
|
||||||
|
if (VALID_IONICONS.has(icon)) {
|
||||||
|
return icon as IoniconsName;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to emoji mapping for legacy data
|
||||||
|
return EMOJI_TO_IONICON[icon] || DEFAULT_PACK_ICON;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Add Ionicons Package to Admin (if needed)
|
||||||
|
|
||||||
|
**File**: `apps/admin/package.json`
|
||||||
|
|
||||||
|
The admin app may need the Ionicons package to display icon previews.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/admin
|
||||||
|
npm install react-icons
|
||||||
|
```
|
||||||
|
|
||||||
|
Then use `IoIcons` from `react-icons/io5` which matches Ionicons naming.
|
||||||
|
|
||||||
|
Alternatively, create simple SVG components or use icon font directly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Summary
|
||||||
|
|
||||||
|
| File | Action | Priority |
|
||||||
|
|------|--------|----------|
|
||||||
|
| `apps/admin/src/components/ui/icon-picker.tsx` | Create | High |
|
||||||
|
| `apps/admin/src/components/content/PackFormDialog.tsx` | Modify | High |
|
||||||
|
| `apps/admin/src/pages/content/CategoriesPage.tsx` | Modify | Medium |
|
||||||
|
| `apps/admin/src/lib/ai/generators/packs.ts` | Modify | Low |
|
||||||
|
| `apps/mobile/src/lib/packIcons.ts` | Modify | High |
|
||||||
|
| `apps/admin/package.json` | Modify (maybe) | Medium |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
- [ ] Create a new pack in admin with Ionicon - verify it saves correctly
|
||||||
|
- [ ] Edit an existing pack (with emoji) - verify emoji still displays in picker
|
||||||
|
- [ ] Mobile app displays new Ionicon packs correctly
|
||||||
|
- [ ] Mobile app displays legacy emoji packs correctly (via mapping)
|
||||||
|
- [ ] Old app version (if testable) shows default icon for new Ionicon packs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Future Considerations
|
||||||
|
|
||||||
|
Once the new app version is widely deployed:
|
||||||
|
1. **Optional**: Run a database migration to convert remaining emojis to Ionicons
|
||||||
|
2. **Optional**: Remove emoji mapping code from mobile app
|
||||||
|
3. **Optional**: Add migration script admins can run per-pack
|
||||||
|
|
||||||
|
For now, the backwards-compatible approach means no database migrations and no breaking changes.
|
||||||
@@ -0,0 +1,427 @@
|
|||||||
|
# Plan: Remove E2EE and Switch to Server-Side Security
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This document outlines the plan to remove end-to-end encryption (E2EE) from Sauci's chat system and rely on Supabase's built-in security instead.
|
||||||
|
|
||||||
|
### Current State
|
||||||
|
|
||||||
|
- Messages are encrypted client-side using AES-256-GCM with triple-wrapped RSA keys
|
||||||
|
- Each message has sender, recipient, and admin wrapped keys
|
||||||
|
- Complex key management: generation, storage, rotation, recovery
|
||||||
|
- ~2500+ lines of encryption code across 21 files
|
||||||
|
- Edge functions handle key rotation and pending message re-encryption
|
||||||
|
|
||||||
|
### Target State
|
||||||
|
|
||||||
|
- Messages stored as plaintext in Supabase (protected by RLS policies)
|
||||||
|
- TLS encryption in transit (Supabase default)
|
||||||
|
- Encryption at rest (Supabase infrastructure)
|
||||||
|
- No client-side key management
|
||||||
|
- Simplified chat code
|
||||||
|
|
||||||
|
### Why Remove E2EE?
|
||||||
|
|
||||||
|
1. **Complexity** - Triple-wrapped keys, device recovery, key rotation adds significant maintenance burden
|
||||||
|
2. **Overkill** - For a couples intimacy app, server-side security is sufficient
|
||||||
|
3. **User Experience** - E2EE adds latency, recovery flows, and potential failure modes
|
||||||
|
4. **Development Velocity** - Simpler code = faster iteration
|
||||||
|
|
||||||
|
### Security Model After Removal
|
||||||
|
|
||||||
|
- **In Transit**: TLS 1.3 (Supabase default)
|
||||||
|
- **At Rest**: Supabase encrypts data at rest
|
||||||
|
- **Access Control**: RLS policies restrict access to couple's own messages
|
||||||
|
- **Admin Access**: Admins can read messages (same as current E2EE with admin key)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration Strategy for Existing Messages
|
||||||
|
|
||||||
|
Existing v2 (encrypted) messages need handling. Options:
|
||||||
|
|
||||||
|
### Option A: Decrypt and Convert (Recommended)
|
||||||
|
|
||||||
|
Run a one-time migration to decrypt all v2 messages using admin key and store as v1 plaintext.
|
||||||
|
|
||||||
|
- Pros: Clean slate, no legacy code needed
|
||||||
|
- Cons: Requires admin private key, one-time effort
|
||||||
|
|
||||||
|
### Option B: Leave v2 Messages As-Is
|
||||||
|
|
||||||
|
Keep encrypted messages in database but stop creating new ones. Old messages become unreadable.
|
||||||
|
|
||||||
|
- Pros: Simpler migration
|
||||||
|
- Cons: Users lose access to old messages
|
||||||
|
|
||||||
|
### Option C: Hybrid Display
|
||||||
|
|
||||||
|
Keep decryption code for reading old v2 messages, but send new messages as v1.
|
||||||
|
|
||||||
|
- Pros: No data loss
|
||||||
|
- Cons: Maintains complexity, defeats purpose of removal
|
||||||
|
|
||||||
|
**Recommendation**: Option A - decrypt existing messages during migration window.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
### Phase 1: Database Migration (Estimated: 2-3 hours)
|
||||||
|
|
||||||
|
#### Task 1.1: Create Migration to Decrypt Existing v2 Messages
|
||||||
|
|
||||||
|
Create an edge function that:
|
||||||
|
|
||||||
|
1. Fetches all messages with `version = 2`
|
||||||
|
2. Decrypts each using admin private key
|
||||||
|
3. Updates to `version = 1`, moves decrypted content to `content` column
|
||||||
|
4. Clears `encrypted_content`, `encryption_iv`, `keys_metadata`
|
||||||
|
|
||||||
|
**Files to create:**
|
||||||
|
|
||||||
|
- `apps/supabase/functions/migrate-e2ee-to-plaintext/index.ts`
|
||||||
|
|
||||||
|
#### Task 1.2: Run Migration
|
||||||
|
|
||||||
|
Execute the migration function against both non-prod and prod databases.
|
||||||
|
|
||||||
|
#### Task 1.3: Verify Migration
|
||||||
|
|
||||||
|
Query to confirm no v2 messages remain:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT COUNT(*) FROM messages WHERE version = 2;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 2: Remove Client-Side Encryption Code (Estimated: 4-6 hours)
|
||||||
|
|
||||||
|
#### Task 2.1: Remove Encryption Library
|
||||||
|
|
||||||
|
Delete the entire encryption directory:
|
||||||
|
|
||||||
|
- `apps/mobile/src/lib/encryption/` (14 files)
|
||||||
|
|
||||||
|
**Files to delete:**
|
||||||
|
|
||||||
|
- `adminKeys.ts`
|
||||||
|
- `constants.ts`
|
||||||
|
- `crypto.ts`
|
||||||
|
- `index.ts`
|
||||||
|
- `keyManager.ts`
|
||||||
|
- `mediaDecryption.ts`
|
||||||
|
- `mediaEncryption.ts`
|
||||||
|
- `messageDecryption.ts`
|
||||||
|
- `messageEncryption.ts`
|
||||||
|
- `reencryptPendingMessages.ts`
|
||||||
|
- `repairStaleKey.ts`
|
||||||
|
- `triggerKeyRotation.ts`
|
||||||
|
- `types.ts`
|
||||||
|
|
||||||
|
#### Task 2.2: Remove Encryption Hooks
|
||||||
|
|
||||||
|
Delete the E2EE-specific hooks:
|
||||||
|
|
||||||
|
**Files to delete:**
|
||||||
|
|
||||||
|
- `apps/mobile/src/hooks/useDecryptedMessage.ts`
|
||||||
|
- `apps/mobile/src/hooks/useDecryptedMedia.ts`
|
||||||
|
- `apps/mobile/src/hooks/useEncryptedSend.ts`
|
||||||
|
|
||||||
|
#### Task 2.3: Simplify useEncryptionKeys Hook
|
||||||
|
|
||||||
|
Either delete entirely or convert to a no-op stub that returns `{ hasKeys: true }` for compatibility.
|
||||||
|
|
||||||
|
**File to modify/delete:**
|
||||||
|
|
||||||
|
- `apps/mobile/src/hooks/useEncryptionKeys.ts`
|
||||||
|
|
||||||
|
#### Task 2.4: Remove EncryptionKeyInitializer Component
|
||||||
|
|
||||||
|
Delete the component that initializes E2EE keys on app startup.
|
||||||
|
|
||||||
|
**File to delete:**
|
||||||
|
|
||||||
|
- `apps/mobile/src/components/EncryptionKeyInitializer.tsx`
|
||||||
|
|
||||||
|
#### Task 2.5: Remove from App Layout
|
||||||
|
|
||||||
|
Remove `EncryptionKeyInitializer` from the app layout.
|
||||||
|
|
||||||
|
**File to modify:**
|
||||||
|
|
||||||
|
- `apps/mobile/app/(app)/_layout.tsx`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 3: Simplify Chat Components (Estimated: 3-4 hours)
|
||||||
|
|
||||||
|
#### Task 3.1: Simplify ChatScreen.tsx
|
||||||
|
|
||||||
|
Remove:
|
||||||
|
|
||||||
|
- `useEncryptedSend` hook usage
|
||||||
|
- `encryptionKeys` from store
|
||||||
|
- `secureReady` checks
|
||||||
|
- Recovery UI (`encryptionKeys.isRecovering`)
|
||||||
|
- Encryption readiness blocking
|
||||||
|
|
||||||
|
Change message sending to use plaintext directly.
|
||||||
|
|
||||||
|
**File to modify:**
|
||||||
|
|
||||||
|
- `apps/mobile/src/features/chat/ChatScreen.tsx`
|
||||||
|
|
||||||
|
#### Task 3.2: Simplify MessageContent.tsx
|
||||||
|
|
||||||
|
Remove:
|
||||||
|
|
||||||
|
- `useDecryptedMessage` hook usage
|
||||||
|
- `useDecryptedMedia` hook usage
|
||||||
|
- Decryption loading states
|
||||||
|
- Decryption error states and retry buttons
|
||||||
|
|
||||||
|
Display `message.content` directly.
|
||||||
|
|
||||||
|
**File to modify:**
|
||||||
|
|
||||||
|
- `apps/mobile/src/features/chat/MessageContent.tsx`
|
||||||
|
|
||||||
|
#### Task 3.3: Simplify Media Upload
|
||||||
|
|
||||||
|
Remove encryption from media upload flow.
|
||||||
|
|
||||||
|
**File to modify:**
|
||||||
|
|
||||||
|
- `apps/mobile/src/hooks/useMediaUpload.ts`
|
||||||
|
|
||||||
|
Changes:
|
||||||
|
|
||||||
|
- Remove `encryptMediaFile` call
|
||||||
|
- Upload files directly without `.enc` extension
|
||||||
|
- Remove E2EE readiness checks
|
||||||
|
|
||||||
|
#### Task 3.4: Simplify InputBar (if applicable)
|
||||||
|
|
||||||
|
Remove any "security not ready" blocking logic.
|
||||||
|
|
||||||
|
**File to modify:**
|
||||||
|
|
||||||
|
- `apps/mobile/src/features/chat/InputBar.tsx`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 4: Clean Up State Management (Estimated: 1-2 hours)
|
||||||
|
|
||||||
|
#### Task 4.1: Remove encryptionKeys from Auth Store
|
||||||
|
|
||||||
|
Remove the entire `encryptionKeys` state object and related actions.
|
||||||
|
|
||||||
|
**File to modify:**
|
||||||
|
|
||||||
|
- `apps/mobile/src/store/authStore.ts`
|
||||||
|
|
||||||
|
Remove:
|
||||||
|
|
||||||
|
- `EncryptionKeyState` interface
|
||||||
|
- `encryptionKeys` state
|
||||||
|
- `setEncryptionKeys` action
|
||||||
|
- `beginEncryptionRecovery` / `endEncryptionRecovery` actions
|
||||||
|
- Reset logic in logout
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 5: Remove Edge Functions (Estimated: 1 hour)
|
||||||
|
|
||||||
|
#### Task 5.1: Delete E2EE Edge Functions
|
||||||
|
|
||||||
|
Remove the edge functions that handle key rotation and re-encryption.
|
||||||
|
|
||||||
|
**Files to delete:**
|
||||||
|
|
||||||
|
- `apps/supabase/functions/handle-key-rotation/`
|
||||||
|
- `apps/supabase/functions/reencrypt-pending-messages/`
|
||||||
|
|
||||||
|
#### Task 5.2: Remove from Supabase Dashboard
|
||||||
|
|
||||||
|
If deployed, remove these functions from Supabase dashboard or let them remain (they won't be called).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 6: Database Cleanup (Optional) (Estimated: 1 hour)
|
||||||
|
|
||||||
|
#### Task 6.1: Remove E2EE Columns (Optional)
|
||||||
|
|
||||||
|
Create migration to drop unused columns. Can be deferred.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Optional cleanup migration
|
||||||
|
ALTER TABLE messages
|
||||||
|
DROP COLUMN IF EXISTS encrypted_content,
|
||||||
|
DROP COLUMN IF EXISTS encryption_iv,
|
||||||
|
DROP COLUMN IF EXISTS keys_metadata;
|
||||||
|
|
||||||
|
ALTER TABLE messages
|
||||||
|
DROP CONSTRAINT IF EXISTS messages_e2ee_fields_check;
|
||||||
|
|
||||||
|
ALTER TABLE profiles
|
||||||
|
DROP COLUMN IF EXISTS public_key_jwk;
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS master_keys;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note**: Can leave columns in place - they'll just be NULL for new messages.
|
||||||
|
|
||||||
|
#### Task 6.2: Update Message Version Default
|
||||||
|
|
||||||
|
Ensure new messages use version 1 (or remove version concept entirely).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 7: Cleanup and Testing (Estimated: 2-3 hours)
|
||||||
|
|
||||||
|
#### Task 7.1: Remove Unused Dependencies
|
||||||
|
|
||||||
|
Check if `react-native-quick-crypto` can be removed (may be needed for other crypto).
|
||||||
|
|
||||||
|
**File to modify:**
|
||||||
|
|
||||||
|
- `apps/mobile/package.json`
|
||||||
|
|
||||||
|
#### Task 7.2: Update CLAUDE.md
|
||||||
|
|
||||||
|
Remove E2EE documentation section.
|
||||||
|
|
||||||
|
**File to modify:**
|
||||||
|
|
||||||
|
- `CLAUDE.md`
|
||||||
|
|
||||||
|
#### Task 7.3: Delete E2EE Documentation
|
||||||
|
|
||||||
|
Remove the E2EE implementation docs.
|
||||||
|
|
||||||
|
**File to delete:**
|
||||||
|
|
||||||
|
- `docs/e2ee-implementation.md`
|
||||||
|
|
||||||
|
#### Task 7.4: Update iOS Permission Strings
|
||||||
|
|
||||||
|
Remove "encrypted" from iOS permission descriptions. These strings mention encryption:
|
||||||
|
|
||||||
|
**File to modify:**
|
||||||
|
|
||||||
|
- `apps/mobile/app.json`
|
||||||
|
|
||||||
|
**Current strings (lines 26-27):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
"NSCameraUsageDescription": "Sauci uses your camera to take photos and videos that you can 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 in encrypted chat messages.",
|
||||||
|
```
|
||||||
|
|
||||||
|
**Updated strings:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
"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.",
|
||||||
|
```
|
||||||
|
|
||||||
|
After updating `app.json`, run `npx expo prebuild --clean` to regenerate `ios/Sauci/Info.plist`.
|
||||||
|
|
||||||
|
#### Task 7.5: Full Testing
|
||||||
|
|
||||||
|
Test all chat functionality:
|
||||||
|
|
||||||
|
- [ ] Send text message
|
||||||
|
- [ ] Receive text message
|
||||||
|
- [ ] Send image
|
||||||
|
- [ ] Receive image
|
||||||
|
- [ ] Send video
|
||||||
|
- [ ] Receive video
|
||||||
|
- [ ] View old messages (post-migration)
|
||||||
|
- [ ] New user signup and chat
|
||||||
|
- [ ] Device change / reinstall
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Summary
|
||||||
|
|
||||||
|
### Files to Delete (19 files)
|
||||||
|
|
||||||
|
```text
|
||||||
|
apps/mobile/src/lib/encryption/adminKeys.ts
|
||||||
|
apps/mobile/src/lib/encryption/constants.ts
|
||||||
|
apps/mobile/src/lib/encryption/crypto.ts
|
||||||
|
apps/mobile/src/lib/encryption/index.ts
|
||||||
|
apps/mobile/src/lib/encryption/keyManager.ts
|
||||||
|
apps/mobile/src/lib/encryption/mediaDecryption.ts
|
||||||
|
apps/mobile/src/lib/encryption/mediaEncryption.ts
|
||||||
|
apps/mobile/src/lib/encryption/messageDecryption.ts
|
||||||
|
apps/mobile/src/lib/encryption/messageEncryption.ts
|
||||||
|
apps/mobile/src/lib/encryption/reencryptPendingMessages.ts
|
||||||
|
apps/mobile/src/lib/encryption/repairStaleKey.ts
|
||||||
|
apps/mobile/src/lib/encryption/triggerKeyRotation.ts
|
||||||
|
apps/mobile/src/lib/encryption/types.ts
|
||||||
|
apps/mobile/src/hooks/useDecryptedMessage.ts
|
||||||
|
apps/mobile/src/hooks/useDecryptedMedia.ts
|
||||||
|
apps/mobile/src/hooks/useEncryptedSend.ts
|
||||||
|
apps/mobile/src/hooks/useEncryptionKeys.ts
|
||||||
|
apps/mobile/src/components/EncryptionKeyInitializer.tsx
|
||||||
|
docs/e2ee-implementation.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### Files to Modify (8 files)
|
||||||
|
|
||||||
|
```text
|
||||||
|
apps/mobile/src/features/chat/ChatScreen.tsx
|
||||||
|
apps/mobile/src/features/chat/MessageContent.tsx
|
||||||
|
apps/mobile/src/features/chat/InputBar.tsx
|
||||||
|
apps/mobile/src/hooks/useMediaUpload.ts
|
||||||
|
apps/mobile/src/store/authStore.ts
|
||||||
|
apps/mobile/app/(app)/_layout.tsx
|
||||||
|
apps/mobile/app.json
|
||||||
|
CLAUDE.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### Edge Functions to Delete (2 functions)
|
||||||
|
|
||||||
|
```text
|
||||||
|
apps/supabase/functions/handle-key-rotation/
|
||||||
|
apps/supabase/functions/reencrypt-pending-messages/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Estimated Total Effort
|
||||||
|
|
||||||
|
| Phase | Estimated Time |
|
||||||
|
| ------------------------------- | ---------------- |
|
||||||
|
| Phase 1: Database Migration | 2-3 hours |
|
||||||
|
| Phase 2: Remove Encryption Code | 4-6 hours |
|
||||||
|
| Phase 3: Simplify Chat Components | 3-4 hours |
|
||||||
|
| Phase 4: Clean Up State | 1-2 hours |
|
||||||
|
| Phase 5: Remove Edge Functions | 1 hour |
|
||||||
|
| Phase 6: Database Cleanup | 1 hour (optional)|
|
||||||
|
| Phase 7: Testing | 2-3 hours |
|
||||||
|
| **Total** | **14-20 hours** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rollback Plan
|
||||||
|
|
||||||
|
If issues arise after deployment:
|
||||||
|
|
||||||
|
1. **Messages are preserved** - Content column still has plaintext for v1, migration preserves decrypted content
|
||||||
|
2. **Re-enable E2EE** - Git revert the client changes
|
||||||
|
3. **No data loss** - The migration only adds data (decrypts), doesn't delete
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
1. **Timeline**: When to execute the migration? During maintenance window?
|
||||||
|
2. **Notification**: Should users be notified about the security model change?
|
||||||
|
3. **Privacy Policy**: Does the privacy policy mention E2EE? May need update.
|
||||||
|
4. **App Store**: Any App Store claims about E2EE that need updating?
|
||||||
Reference in new issue
Block a user