da27054a9b
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.
261 lines
9.4 KiB
TypeScript
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;
|