Files
eranos/src/components/auth/SignupDialog.tsx
T
Alex Gleason da27054a9b Fix file downloads and URL opening on Capacitor iOS
The <a download> and <a target="_blank"> patterns don't work in
WKWebView. Add downloadTextFile() and openUrl() utilities in
src/lib/downloadFile.ts that use @capacitor/filesystem and
@capacitor/share on native platforms, falling back to standard
browser behavior on web.

Update all call sites: onboarding key download (InitialSyncGate,
SignupDialog), image lightbox buttons (ImageGallery, ProfilePage).

Document Capacitor compatibility constraints in AGENTS.md.
2026-03-27 16:42:36 -05:00

261 lines
9.4 KiB
TypeScript

// NOTE: This file is stable and usually should not be modified.
// It is important that all functionality in this file is preserved, and should only be modified if explicitly requested.
import React, { useState, useEffect, useRef } from 'react';
import { Download, Eye, EyeOff, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { toast } from '@/hooks/useToast';
import { useLoginActions } from '@/hooks/useLoginActions';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useUploadFile } from '@/hooks/useUploadFile';
import { generateSecretKey, getPublicKey, nip19 } from 'nostr-tools';
import { downloadTextFile } from '@/lib/downloadFile';
import { ProfileCard } from '@/components/ProfileCard';
import { ImageCropDialog } from '@/components/ImageCropDialog';
import type { NostrMetadata } from '@nostrify/nostrify';
interface SignupDialogProps {
isOpen: boolean;
onClose: () => void;
}
const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose }) => {
const [step, setStep] = useState<'generate' | 'download' | 'profile'>('generate');
const [nsec, setNsec] = useState('');
const [showKey, setShowKey] = useState(false);
const [profileData, setProfileData] = useState<Partial<NostrMetadata>>({
name: '',
about: '',
picture: '',
banner: '',
});
const [cropState, setCropState] = useState<{ imageSrc: string; aspect: number; field: 'picture' | 'banner' } | null>(null);
const pickInputRef = useRef<HTMLInputElement>(null);
const pendingField = useRef<'picture' | 'banner'>('picture');
const login = useLoginActions();
const { mutateAsync: publishEvent, isPending: isPublishing } = useNostrPublish();
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
// Generate a proper nsec key using nostr-tools
const generateKey = () => {
const sk = generateSecretKey();
setNsec(nip19.nsecEncode(sk));
setStep('download');
};
const downloadKey = async () => {
try {
const decoded = nip19.decode(nsec);
if (decoded.type !== 'nsec') {
throw new Error('Invalid nsec key');
}
const pubkey = getPublicKey(decoded.data);
const npub = nip19.npubEncode(pubkey);
const filename = `nostr-${location.hostname.replaceAll(/\./g, '-')}-${npub.slice(5, 9)}.nsec.txt`;
await downloadTextFile(filename, nsec);
// Continue to profile step
login.nsec(nsec);
setStep('profile');
} catch {
toast({
title: 'Download failed',
description: 'Could not download the key file. Please copy it manually.',
variant: 'destructive',
});
}
};
const handlePickImage = (field: 'picture' | 'banner') => {
pendingField.current = field;
pickInputRef.current?.click();
};
const handleFileChosen = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
e.target.value = '';
const field = pendingField.current;
setCropState({ imageSrc: URL.createObjectURL(file), aspect: field === 'picture' ? 1 : 3, field });
};
const handleCropConfirm = async (blob: Blob) => {
if (!cropState) return;
const { field, imageSrc } = cropState;
URL.revokeObjectURL(imageSrc);
setCropState(null);
try {
const file = new File([blob], `${field}.jpg`, { type: 'image/jpeg' });
const [[, url]] = await uploadFile(file);
setProfileData(prev => ({ ...prev, [field]: url }));
} catch {
toast({ title: 'Upload failed', description: 'Please try again.', variant: 'destructive' });
}
};
const handleCropCancel = () => {
if (cropState) URL.revokeObjectURL(cropState.imageSrc);
setCropState(null);
};
const finishSignup = async (skipProfile = false) => {
try {
if (!skipProfile && (profileData.name || profileData.about || profileData.picture)) {
await publishEvent({
kind: 0,
content: JSON.stringify(profileData),
});
}
} catch {
toast({
title: 'Profile Setup Failed',
description: 'Your account was created but profile setup failed. You can update it later.',
variant: 'destructive',
});
} finally {
onClose();
}
};
const getTitle = () => {
if (step === 'generate') return 'Sign up';
if (step === 'download') return 'Secret Key';
if (step === 'profile') return 'Create Your Profile';
};
// Reset state when dialog opens
useEffect(() => {
if (isOpen) {
setStep('generate');
setNsec('');
setShowKey(false);
setProfileData({ name: '', about: '', picture: '' });
}
}, [isOpen]);
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-[95vw] sm:max-w-sm max-h-[90dvh] p-0 gap-6 overflow-hidden rounded-2xl overflow-y-auto">
<DialogHeader className="px-6 pt-6">
<DialogTitle className="text-lg font-semibold leading-none tracking-tight text-center">
{getTitle()}
</DialogTitle>
</DialogHeader>
<div className='px-6 pb-6 space-y-4 overflow-y-auto flex-1'>
{/* Generate Step */}
{step === 'generate' && (
<div className='text-center space-y-6'>
<div className="flex size-40 text-8xl bg-primary/10 rounded-full items-center justify-center justify-self-center">
🔑
</div>
<Button className="w-full h-12 px-9" onClick={generateKey}>
Generate key
</Button>
</div>
)}
{/* Download Step */}
{step === 'download' && (
<div className='space-y-4'>
<div className="flex size-16 text-4xl bg-primary/10 rounded-full items-center justify-center justify-self-center">
🔑
</div>
<div className="relative">
<Input
type={showKey ? "text" : "password"}
value={nsec}
readOnly
className="pr-10 font-mono"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
onClick={() => setShowKey(!showKey)}
>
{showKey ? (
<EyeOff className="h-4 w-4 text-muted-foreground" />
) : (
<Eye className="h-4 w-4 text-muted-foreground" />
)}
</Button>
</div>
<Button
className="w-full h-12 px-9"
onClick={downloadKey}
>
<Download className="size-4" />
Download key
</Button>
<div className='mx-auto max-w-sm'>
<div className='p-3 bg-amber-50 dark:bg-amber-950/20 rounded-lg border border-amber-200 dark:border-amber-800'>
<div className='flex items-center gap-2 mb-1'>
<span className='text-xs font-semibold text-amber-800 dark:text-amber-200'>
Important Warning
</span>
</div>
<p className='text-xs text-amber-900 dark:text-amber-300'>
This key is your primary and only means of accessing your account. Store it safely and securely. Please download your key to continue.
</p>
</div>
</div>
</div>
)}
{/* Profile Step */}
{step === 'profile' && (
<div className='space-y-4'>
<input ref={pickInputRef} type="file" accept="image/*" className="hidden" onChange={handleFileChosen} />
{cropState && (
<ImageCropDialog
open
imageSrc={cropState.imageSrc}
aspect={cropState.aspect}
title={cropState.field === 'picture' ? 'Crop Profile Picture' : 'Crop Banner'}
onCancel={handleCropCancel}
onCrop={handleCropConfirm}
/>
)}
<div className={isPublishing ? 'opacity-50 pointer-events-none' : ''}>
<ProfileCard
metadata={profileData}
onChange={(patch) => setProfileData(prev => ({ ...prev, ...patch }))}
onPickImage={handlePickImage}
/>
</div>
{isUploading && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="size-3.5 animate-spin" /> Uploading image
</div>
)}
<div className='space-y-2'>
<Button className='w-full' onClick={() => finishSignup(false)} disabled={isPublishing || isUploading}>
{isPublishing ? <><Loader2 className="size-4 mr-2 animate-spin" /> Creating Profile</> : 'Create profile'}
</Button>
<Button variant='outline' className='w-full' onClick={() => finishSignup(true)} disabled={isPublishing || isUploading}>
Skip for now
</Button>
</div>
</div>
)}
</div>
</DialogContent>
</Dialog>
);
};
export default SignupDialog;