diff --git a/CONTEXT.md b/CONTEXT.md index 092b826..8b75b33 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -495,25 +495,89 @@ function MyComponent() { } ``` -The `LoginArea` component handles all the login-related UI and interactions, including displaying login dialogs and switching between accounts. It should not be wrapped in any conditional logic. +The `LoginArea` component handles all the login-related UI and interactions, including displaying login dialogs, sign up functionality, and switching between accounts. It should not be wrapped in any conditional logic. -`LoginArea` displays a "Log in" button when the user is logged out, and changes to an account switcher once the user is logged in. It is an inline-flex element by default. To make it expand to the width of its container, you can pass a className like `flex` (to make it a block element) or `w-full`. If it is left as inline-flex, it's recommended to set a max width. +`LoginArea` displays both "Log in" and "Sign Up" buttons when the user is logged out, and changes to an account switcher once the user is logged in. It is an inline-flex element by default. To make it expand to the width of its container, you can pass a className like `flex` (to make it a block element) or `w-full`. If it is left as inline-flex, it's recommended to set a max width. **Important**: Social applications should include a profile menu button in the main interface (typically in headers/navigation) to provide access to account settings, profile editing, and logout functionality. Don't only show `LoginArea` in logged-out states. ### `npub`, `naddr`, and other Nostr addresses -Nostr defines a set identifiers in NIP-19. Their prefixes: +Nostr defines a set of bech32-encoded identifiers in NIP-19. Their prefixes and purposes: -- `npub`: public keys -- `nsec`: private keys -- `note`: note ids -- `nprofile`: a nostr profile -- `nevent`: a nostr event -- `naddr`: a nostr replaceable event coordinate -- `nrelay`: a nostr relay (deprecated) +- `npub1`: **public keys** - Just the 32-byte public key, no additional metadata +- `nsec1`: **private keys** - Secret keys (should never be displayed publicly) +- `note1`: **event IDs** - Just the 32-byte event ID (hex), no additional metadata +- `nevent1`: **event pointers** - Event ID plus optional relay hints and author pubkey +- `nprofile1`: **profile pointers** - Public key plus optional relay hints and petname +- `naddr1`: **addressable event coordinates** - For parameterized replaceable events (kind 30000-39999) +- `nrelay1`: **relay references** - Relay URLs (deprecated) -NIP-19 identifiers include a prefix, the number "1", then a base32-encoded data string. +#### Key Differences Between Similar Identifiers + +**`note1` vs `nevent1`:** +- `note1`: Contains only the event ID (32 bytes) - specifically for kind:1 events (Short Text Notes) as defined in NIP-10 +- `nevent1`: Contains event ID plus optional relay hints and author pubkey - for any event kind +- Use `note1` for simple references to text notes and threads +- Use `nevent1` when you need to include relay hints or author context for any event type + +**`npub1` vs `nprofile1`:** +- `npub1`: Contains only the public key (32 bytes) +- `nprofile1`: Contains public key plus optional relay hints and petname +- Use `npub1` for simple user references +- Use `nprofile1` when you need to include relay hints or display name context + +#### NIP-19 Routing Implementation + +**Critical**: NIP-19 identifiers should be handled at the **root level** of URLs (e.g., `/note1...`, `/npub1...`, `/naddr1...`), NOT nested under paths like `/note/note1...` or `/profile/npub1...`. + +This project includes a boilerplate `NIP19Page` component that provides the foundation for handling all NIP-19 identifier types at the root level. The component is configured in the routing system and ready for AI agents to populate with specific functionality. + +**How it works:** + +1. **Root-Level Route**: The route `/:nip19` in `AppRouter.tsx` catches all NIP-19 identifiers +2. **Automatic Decoding**: The `NIP19Page` component automatically decodes the identifier using `nip19.decode()` +3. **Type-Specific Sections**: Different sections are rendered based on the identifier type: + - `npub1`/`nprofile1`: Profile section with placeholder for profile view + - `note1`: Note section with placeholder for kind:1 text note view + - `nevent1`: Event section with placeholder for any event type view + - `naddr1`: Addressable event section with placeholder for articles, marketplace items, etc. +4. **Error Handling**: Invalid, vacant, or unsupported identifiers show 404 NotFound page +5. **Ready for Population**: Each section includes comments indicating where AI agents should implement specific functionality + +**Example URLs that work automatically:** +- `/npub1abc123...` - User profile (needs implementation) +- `/note1def456...` - Kind:1 text note (needs implementation) +- `/nevent1ghi789...` - Any event with relay hints (needs implementation) +- `/naddr1jkl012...` - Addressable event (needs implementation) + +**Features included:** +- Basic NIP-19 identifier decoding and routing +- Type-specific sections for different identifier types +- Error handling for invalid identifiers +- Responsive container structure +- Comments indicating where to implement specific views + +**Error handling:** +- Invalid NIP-19 format → 404 NotFound +- Unsupported identifier types (like `nsec1`) → 404 NotFound +- Empty or missing identifiers → 404 NotFound + +To implement NIP-19 routing in your Nostr application: + +1. **The NIP19Page boilerplate is already created** - populate sections with specific functionality +2. **The route is already configured** in `AppRouter.tsx` +3. **Error handling is built-in** - all edge cases show appropriate 404 responses +4. **Add specific components** for profile views, event displays, etc. as needed + +#### Event Type Distinctions + +**`note1` identifiers** are specifically for **kind:1 events** (Short Text Notes) as defined in NIP-10: "Text Notes and Threads". These are the basic social media posts in Nostr. + +**`nevent1` identifiers** can reference any event kind and include additional metadata like relay hints and author pubkey. Use `nevent1` when: +- The event is not a kind:1 text note +- You need to include relay hints for better discoverability +- You want to include author context #### Use in Filters @@ -555,22 +619,20 @@ const events = await nostr.query( ); ``` -#### Use in URL Paths +#### Implementation Guidelines -For URL routing, use NIP-19 identifiers as path parameters (e.g., `/:nip19`) to create secure, universal links to Nostr events. Decode the identifier and render the appropriate component based on the type: - -- Regular events: Use `/nevent1...` paths -- Replaceable/addressable events: Use `/naddr1...` paths - -Always use `naddr` identifiers for addressable events instead of just the `d` tag value, as `naddr` contains the author pubkey needed to create secure filters. This prevents security issues where malicious actors could publish events with the same `d` tag to override content. - -```ts -// Secure routing with naddr -const decoded = nip19.decode(params.nip19); -if (decoded.type === 'naddr' && decoded.data.kind === 30024) { - // Render ArticlePage component -} -``` +1. **Always decode NIP-19 identifiers** before using them in queries +2. **Use the appropriate identifier type** based on your needs: + - Use `note1` for kind:1 text notes specifically + - Use `nevent1` when including relay hints or for non-kind:1 events + - Use `naddr1` for addressable events (always includes author pubkey for security) +3. **Handle different identifier types** appropriately: + - `npub1`/`nprofile1`: Display user profiles + - `note1`: Display kind:1 text notes specifically + - `nevent1`: Display any event with optional relay context + - `naddr1`: Display addressable events (articles, marketplace items, etc.) +4. **Security considerations**: Always use `naddr1` for addressable events instead of just the `d` tag value, as `naddr1` contains the author pubkey needed to create secure filters +5. **Error handling**: Gracefully handle invalid or unsupported NIP-19 identifiers with 404 responses ### Nostr Edit Profile @@ -927,15 +989,30 @@ When users specify color schemes: - Implement responsive design with Tailwind breakpoints - Add hover and focus states for interactive elements -## Writing Tests +## Writing Tests vs Running Tests -**⚠️ CRITICAL FOR AI ASSISTANTS**: **DO NOT WRITE TESTS** unless the user is experiencing a specific problem or explicitly requests tests. Writing unnecessary tests wastes significant time and money. Only create tests when: +There is an important distinction between **writing new tests** and **running existing tests**: -1. **The user is experiencing a bug** that requires testing to diagnose -2. **The user explicitly asks for tests** to be written -3. **Existing functionality is broken** and tests are needed to verify fixes +### Writing Tests (Creating New Test Files) +**Do not write tests** unless the user explicitly requests them in plain language. Writing unnecessary tests wastes significant time and money. Only create tests when: -**Never proactively write tests** for new features or components. Focus on building functionality that works, not on testing it unless specifically requested. +1. **The user explicitly asks for tests** to be written in their message +2. **The user describes a specific bug in plain language** and requests tests to help diagnose it +3. **The user says they are still experiencing a problem** that you have already attempted to solve (tests can help verify the fix) + +**Never write tests because:** +- Tool results show test failures (these are not user requests) +- You think tests would be helpful +- New features or components are created +- Existing functionality needs verification + +### Running Tests (Executing the Test Suite) +**ALWAYS run the test script** after making any code changes. This is mandatory regardless of whether you wrote new tests or not. + +- **You must run the test script** using `js-dev__run_script` tool with the "test" parameter +- **Your task is not complete** until the test script passes without errors +- **This applies to all changes** - bug fixes, new features, refactoring, or any code modifications +- **The test script includes** TypeScript compilation, ESLint checks, and existing test validation ### Test Setup @@ -968,6 +1045,8 @@ describe('MyComponent', () => { ## Testing Your Changes -Whenever you are finished modifying code, you must run the **test** script using the **js-dev__run_script** tool. +**CRITICAL**: Whenever you are finished modifying code, you must run the **test** script using the **js-dev__run_script** tool. -**Your task is not considered finished until this test passes without errors.** \ No newline at end of file +**Your task is not considered finished until this test passes without errors.** + +**This requirement applies regardless of whether you wrote new tests or not.** The test script validates the entire codebase, including TypeScript compilation, ESLint rules, and existing test suite. \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 3e16e45..60644a7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,8 +11,8 @@ "@fontsource-variable/inter": "^5.2.6", "@getalby/sdk": "^5.1.1", "@hookform/resolvers": "^3.9.0", - "@nostrify/nostrify": "npm:@jsr/nostrify__nostrify@^0.46.3", - "@nostrify/react": "npm:@jsr/nostrify__react@^0.2.7", + "@nostrify/nostrify": "npm:@jsr/nostrify__nostrify@^0.46.4", + "@nostrify/react": "npm:@jsr/nostrify__react@^0.2.8", "@radix-ui/react-accordion": "^1.2.0", "@radix-ui/react-alert-dialog": "^1.1.1", "@radix-ui/react-aspect-ratio": "^1.1.0", @@ -1249,9 +1249,9 @@ } }, "node_modules/@jsr/nostrify__nostrify": { - "version": "0.46.3", - "resolved": "https://npm.jsr.io/~/11/@jsr/nostrify__nostrify/0.46.3.tgz", - "integrity": "sha512-zJpOrD8bbrJroLRJjESAJZX/ZKFCaGfoz5fSfLP+gIcTiPo8JpzlrFBF6mvaDI/Mdd+1WTBwlCcW9On8rUVH7w==", + "version": "0.46.4", + "resolved": "https://npm.jsr.io/~/11/@jsr/nostrify__nostrify/0.46.4.tgz", + "integrity": "sha512-bYuYkw9+jnHIDEJD6i+w0p8xV1oBLr1RF5rS3qF2yTz1b8i50dXKMO2cLObjalAAO3RYbnib3l85t1ZgWLCzAA==", "dependencies": { "@jsr/nostrify__types": "^0.36.0", "@jsr/scure__base": "^1.2.4", @@ -1359,9 +1359,9 @@ }, "node_modules/@nostrify/nostrify": { "name": "@jsr/nostrify__nostrify", - "version": "0.46.3", - "resolved": "https://npm.jsr.io/~/11/@jsr/nostrify__nostrify/0.46.3.tgz", - "integrity": "sha512-zJpOrD8bbrJroLRJjESAJZX/ZKFCaGfoz5fSfLP+gIcTiPo8JpzlrFBF6mvaDI/Mdd+1WTBwlCcW9On8rUVH7w==", + "version": "0.46.4", + "resolved": "https://npm.jsr.io/~/11/@jsr/nostrify__nostrify/0.46.4.tgz", + "integrity": "sha512-bYuYkw9+jnHIDEJD6i+w0p8xV1oBLr1RF5rS3qF2yTz1b8i50dXKMO2cLObjalAAO3RYbnib3l85t1ZgWLCzAA==", "dependencies": { "@jsr/nostrify__types": "^0.36.0", "@jsr/scure__base": "^1.2.4", @@ -1374,11 +1374,11 @@ }, "node_modules/@nostrify/react": { "name": "@jsr/nostrify__react", - "version": "0.2.7", - "resolved": "https://npm.jsr.io/~/11/@jsr/nostrify__react/0.2.7.tgz", - "integrity": "sha512-36fAOOymf34KR2OfE4jXBojbZnPsrIzQDAXzE7dko8/Qj+0s8iKnZoZKg9DVIT2F6Lxhr6SUiTze8xBxuJbf1A==", + "version": "0.2.8", + "resolved": "https://npm.jsr.io/~/11/@jsr/nostrify__react/0.2.8.tgz", + "integrity": "sha512-9sCsMm1uwuGWU+NhgFf6LS/4JzeRXnGOz+ocA5X3/ncvxZhidPOEAU39QyygRLFo/JYG8OC5wlyH8w0Uejq+VQ==", "dependencies": { - "@jsr/nostrify__nostrify": "^0.46.3", + "@jsr/nostrify__nostrify": "^0.46.4", "nostr-tools": "^2.13.0", "react": "^18.0.0" } diff --git a/package.json b/package.json index 05e46bd..8484860 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,8 @@ "@fontsource-variable/inter": "^5.2.6", "@getalby/sdk": "^5.1.1", "@hookform/resolvers": "^3.9.0", - "@nostrify/nostrify": "npm:@jsr/nostrify__nostrify@^0.46.3", - "@nostrify/react": "npm:@jsr/nostrify__react@^0.2.7", + "@nostrify/nostrify": "npm:@jsr/nostrify__nostrify@^0.46.4", + "@nostrify/react": "npm:@jsr/nostrify__react@^0.2.8", "@radix-ui/react-accordion": "^1.2.0", "@radix-ui/react-alert-dialog": "^1.1.1", "@radix-ui/react-aspect-ratio": "^1.1.0", diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx index b8875c6..2db53ee 100644 --- a/src/AppRouter.tsx +++ b/src/AppRouter.tsx @@ -2,6 +2,7 @@ import { BrowserRouter, Route, Routes } from "react-router-dom"; import { ScrollToTop } from "./components/ScrollToTop"; import Index from "./pages/Index"; +import { NIP19Page } from "./pages/NIP19Page"; import NotFound from "./pages/NotFound"; export function AppRouter() { @@ -10,6 +11,8 @@ export function AppRouter() { } /> + {/* NIP-19 route for npub1, note1, naddr1, nevent1, nprofile1 */} + } /> {/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */} } /> diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..ffa1f37 --- /dev/null +++ b/src/components/ErrorBoundary.tsx @@ -0,0 +1,113 @@ +import { Component, ErrorInfo, ReactNode } from 'react'; + +interface ErrorBoundaryState { + hasError: boolean; + error: Error | null; + errorInfo: ErrorInfo | null; +} + +interface ErrorBoundaryProps { + children: ReactNode; + fallback?: ReactNode; +} + + + +export class ErrorBoundary extends Component { + constructor(props: ErrorBoundaryProps) { + super(props); + this.state = { + hasError: false, + error: null, + errorInfo: null, + }; + } + + static getDerivedStateFromError(error: Error): Partial { + return { + hasError: true, + error, + }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo) { + console.error('Error caught by ErrorBoundary:', error, errorInfo); + + this.setState({ + error, + errorInfo, + }); + } + + handleReset = () => { + this.setState({ + hasError: false, + error: null, + errorInfo: null, + }); + }; + + render() { + if (this.state.hasError) { + if (this.props.fallback) { + return this.props.fallback; + } + + return ( +
+
+
+

+ Something went wrong +

+

+ An unexpected error occurred. The error has been reported. +

+
+ +
+
+ + Error details + +
+
+ Message: +

+ {this.state.error?.message} +

+
+ {this.state.error?.stack && ( +
+ Stack trace: +
+                        {this.state.error.stack}
+                      
+
+ )} +
+
+
+ +
+ + +
+
+
+ ); + } + + return this.props.children; + } +} \ No newline at end of file diff --git a/src/components/auth/LoginArea.tsx b/src/components/auth/LoginArea.tsx index 40d2f53..f84414a 100644 --- a/src/components/auth/LoginArea.tsx +++ b/src/components/auth/LoginArea.tsx @@ -2,7 +2,7 @@ // It is important that all functionality in this file is preserved, and should only be modified if explicitly requested. import { useState } from 'react'; -import { User } from 'lucide-react'; +import { User, UserPlus } from 'lucide-react'; import { Button } from '@/components/ui/button.tsx'; import LoginDialog from './LoginDialog'; import SignupDialog from './SignupDialog'; @@ -29,18 +29,27 @@ export function LoginArea({ className }: LoginAreaProps) { {currentUser ? ( setLoginDialogOpen(true)} /> ) : ( - +
+ +
)} setLoginDialogOpen(false)} + isOpen={loginDialogOpen} + onClose={() => setLoginDialogOpen(false)} onLogin={handleLogin} onSignup={() => setSignupDialogOpen(true)} /> diff --git a/src/components/auth/LoginDialog.tsx b/src/components/auth/LoginDialog.tsx index 5e7e49c..ff40ec0 100644 --- a/src/components/auth/LoginDialog.tsx +++ b/src/components/auth/LoginDialog.tsx @@ -1,19 +1,15 @@ // 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, { useRef, useState } from 'react'; -import { Shield, Upload } from 'lucide-react'; -import { Button } from '@/components/ui/button.tsx'; -import { Input } from '@/components/ui/input.tsx'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog.tsx'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs.tsx'; +import React, { useRef, useState, useEffect } from 'react'; +import { Shield, Upload, AlertTriangle, UserPlus, KeyRound, Sparkles, Cloud } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Dialog, DialogContent, DialogHeader, DialogDescription } from "@/components/ui/dialog"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Alert, AlertDescription } from '@/components/ui/alert'; import { useLoginActions } from '@/hooks/useLoginActions'; +import { cn } from '@/lib/utils'; interface LoginDialogProps { isOpen: boolean; @@ -22,54 +18,124 @@ interface LoginDialogProps { onSignup?: () => void; } +const validateNsec = (nsec: string) => { + return /^nsec1[a-zA-Z0-9]{58}$/.test(nsec); +}; + +const validateBunkerUri = (uri: string) => { + return uri.startsWith('bunker://'); +}; + const LoginDialog: React.FC = ({ isOpen, onClose, onLogin, onSignup }) => { const [isLoading, setIsLoading] = useState(false); + const [isFileLoading, setIsFileLoading] = useState(false); const [nsec, setNsec] = useState(''); const [bunkerUri, setBunkerUri] = useState(''); + const [errors, setErrors] = useState<{ + nsec?: string; + bunker?: string; + file?: string; + extension?: string; + }>({}); const fileInputRef = useRef(null); const login = useLoginActions(); - const handleExtensionLogin = () => { + // Reset all state when dialog opens/closes + useEffect(() => { + if (isOpen) { + // Reset state when dialog opens + setIsLoading(false); + setIsFileLoading(false); + setNsec(''); + setBunkerUri(''); + setErrors({}); + // Reset file input + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + } + }, [isOpen]); + + const handleExtensionLogin = async () => { setIsLoading(true); + setErrors(prev => ({ ...prev, extension: undefined })); + try { if (!('nostr' in window)) { throw new Error('Nostr extension not found. Please install a NIP-07 extension.'); } - login.extension(); + await login.extension(); onLogin(); onClose(); - } catch (error) { + } catch (e: unknown) { + const error = e as Error; + console.error('Bunker login failed:', error); + console.error('Nsec login failed:', error); console.error('Extension login failed:', error); + setErrors(prev => ({ + ...prev, + extension: error instanceof Error ? error.message : 'Extension login failed' + })); } finally { setIsLoading(false); } }; + const executeLogin = (key: string) => { + setIsLoading(true); + setErrors({}); + + // Use a timeout to allow the UI to update before the synchronous login call + setTimeout(() => { + try { + login.nsec(key); + onLogin(); + onClose(); + } catch { + setErrors({ nsec: "Failed to login with this key. Please check that it's correct." }); + setIsLoading(false); + } + }, 50); + }; + const handleKeyLogin = () => { - if (!nsec.trim()) return; - setIsLoading(true); - - try { - login.nsec(nsec); - onLogin(); - onClose(); - } catch (error) { - console.error('Nsec login failed:', error); - } finally { - setIsLoading(false); + if (!nsec.trim()) { + setErrors(prev => ({ ...prev, nsec: 'Please enter your secret key' })); + return; } + + if (!validateNsec(nsec)) { + setErrors(prev => ({ ...prev, nsec: 'Invalid secret key format. Must be a valid nsec starting with nsec1.' })); + return; + } + executeLogin(nsec); }; - const handleBunkerLogin = () => { - if (!bunkerUri.trim() || !bunkerUri.startsWith('bunker://')) return; + const handleBunkerLogin = async () => { + if (!bunkerUri.trim()) { + setErrors(prev => ({ ...prev, bunker: 'Please enter a bunker URI' })); + return; + } + + if (!validateBunkerUri(bunkerUri)) { + setErrors(prev => ({ ...prev, bunker: 'Invalid bunker URI format. Must start with bunker://' })); + return; + } + setIsLoading(true); - + setErrors(prev => ({ ...prev, bunker: undefined })); + try { - login.bunker(bunkerUri); + await login.bunker(bunkerUri); onLogin(); onClose(); - } catch (error) { - console.error('Bunker login failed:', error); + // Clear the URI from memory + setBunkerUri(''); + } catch { + setErrors(prev => ({ + ...prev, + bunker: 'Failed to connect to bunker. Please check the URI.' + })); } finally { setIsLoading(false); } @@ -79,10 +145,27 @@ const LoginDialog: React.FC = ({ isOpen, onClose, onLogin, onS const file = e.target.files?.[0]; if (!file) return; + setIsFileLoading(true); + setErrors({}); + const reader = new FileReader(); reader.onload = (event) => { + setIsFileLoading(false); const content = event.target?.result as string; - setNsec(content.trim()); + if (content) { + const trimmedContent = content.trim(); + if (validateNsec(trimmedContent)) { + executeLogin(trimmedContent); + } else { + setErrors({ file: 'File does not contain a valid secret key.' }); + } + } else { + setErrors({ file: 'Could not read file content.' }); + } + }; + reader.onerror = () => { + setIsFileLoading(false); + setErrors({ file: 'Failed to read file.' }); }; reader.readAsText(file); }; @@ -94,58 +177,139 @@ const LoginDialog: React.FC = ({ isOpen, onClose, onLogin, onS } }; + const defaultTab = 'nostr' in window ? 'extension' : 'key'; + return ( - - - Log in - - Access your account securely with your preferred method - + + + + + Sign up or log in to continue + +
+ {/* Prominent Sign Up Section */} +
+
+
+ + + New to Nostr? + +
+

+ Create a new account to get started. It's free and open. +

+ +
+
-
- - - Extension - Nsec - Bunker + {/* Divider */} +
+
+
+
+
+ + Or log in + +
+
+ + {/* Login Methods */} + + + + + Extension + + + + Key + + + + Bunker + - - + + {errors.extension && ( + + + {errors.extension} + + )}

Login with one click using the browser extension

- +
+ +
-
+ + + +
+
+
+
+
+ + or + +
-

Or upload a key file

= ({ isOpen, onClose, onLogin, onS /> + {errors.file && ( +

{errors.file}

+ )}
- -
- +
- +
+ +
- -
-

- Don't have an account?{' '} - -

-
- ); -}; + ); + }; export default LoginDialog; diff --git a/src/components/auth/SignupDialog.tsx b/src/components/auth/SignupDialog.tsx index 0cf73ea..40315c6 100644 --- a/src/components/auth/SignupDialog.tsx +++ b/src/components/auth/SignupDialog.tsx @@ -1,163 +1,779 @@ // 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 } from 'react'; -import { Download, Key } from 'lucide-react'; -import { Button } from '@/components/ui/button.tsx'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog.tsx'; -import { toast } from '@/hooks/useToast.ts'; +import React, { useState, useEffect, useRef } from 'react'; +import { Download, Key, UserPlus, FileText, Shield, User, Sparkles, LogIn, Lock, CheckCircle, Copy, Upload, Globe, FileSignature, Wand2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; +import { Card, CardContent } from '@/components/ui/card'; +import { toast } from '@/hooks/useToast'; import { useLoginActions } from '@/hooks/useLoginActions'; +import { useNostrPublish } from '@/hooks/useNostrPublish'; +import { useUploadFile } from '@/hooks/useUploadFile'; import { generateSecretKey, nip19 } from 'nostr-tools'; +import { cn } from '@/lib/utils'; interface SignupDialogProps { isOpen: boolean; onClose: () => void; + onComplete?: () => void; } -const SignupDialog: React.FC = ({ isOpen, onClose }) => { - const [step, setStep] = useState<'generate' | 'download' | 'done'>('generate'); +const sanitizeFilename = (filename: string) => { + return filename.replace(/[^a-z0-9_.-]/gi, '_'); +} + +const SignupDialog: React.FC = ({ isOpen, onClose, onComplete }) => { + const [step, setStep] = useState<'welcome' | 'generate' | 'download' | 'profile' | 'done'>('welcome'); const [isLoading, setIsLoading] = useState(false); const [nsec, setNsec] = useState(''); + const [showSparkles, setShowSparkles] = useState(false); + const [keySecured, setKeySecured] = useState<'none' | 'copied' | 'downloaded'>('none'); + const [profileData, setProfileData] = useState({ + name: '', + about: '', + picture: '' + }); const login = useLoginActions(); + const { mutateAsync: publishEvent, isPending: isPublishing } = useNostrPublish(); + const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile(); + const avatarFileInputRef = useRef(null); // Generate a proper nsec key using nostr-tools const generateKey = () => { setIsLoading(true); - - try { - // Generate a new secret key - const sk = generateSecretKey(); - - // Convert to nsec format - setNsec(nip19.nsecEncode(sk)); - setStep('download'); - } catch (error) { - console.error('Failed to generate key:', error); - toast({ - title: 'Error', - description: 'Failed to generate key. Please try again.', - variant: 'destructive', - }); - } finally { - setIsLoading(false); - } + setShowSparkles(true); + + // Add a dramatic pause for the key generation effect + setTimeout(() => { + try { + // Generate a new secret key + const sk = generateSecretKey(); + + // Convert to nsec format + setNsec(nip19.nsecEncode(sk)); + setStep('download'); + + toast({ + title: 'Your Secret Key is Ready!', + description: 'A new secret key has been generated for you.', + }); + } catch { + toast({ + title: 'Error', + description: 'Failed to generate key. Please try again.', + variant: 'destructive', + }); + } finally { + setIsLoading(false); + setShowSparkles(false); + } + }, 2000); }; const downloadKey = () => { - // Create a blob with the key text - const blob = new Blob([nsec], { type: 'text/plain' }); - const url = globalThis.URL.createObjectURL(blob); + try { + // Create a blob with the key text + const blob = new Blob([nsec], { type: 'text/plain; charset=utf-8' }); + const url = globalThis.URL.createObjectURL(blob); - // Create a temporary link element and trigger download - const a = document.createElement('a'); - a.href = url; - a.download = 'nsec.txt'; - document.body.appendChild(a); - a.click(); + // Sanitize filename + const filename = sanitizeFilename('secret-key.txt'); - // Clean up - globalThis.URL.revokeObjectURL(url); - document.body.removeChild(a); + // Create a temporary link element and trigger download + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.style.display = 'none'; + document.body.appendChild(a); + a.click(); + // Clean up immediately + globalThis.URL.revokeObjectURL(url); + document.body.removeChild(a); + + // Mark as secured + setKeySecured('downloaded'); + + toast({ + title: 'Secret Key Saved!', + description: 'Your key has been safely stored.', + }); + } catch { + toast({ + title: 'Download failed', + description: 'Could not download the key file. Please copy it manually.', + variant: 'destructive', + }); + } + }; + + const copyKey = () => { + navigator.clipboard.writeText(nsec); + setKeySecured('copied'); toast({ - title: 'Key downloaded', - description: 'Your key has been downloaded. Keep it safe!', + title: 'Copied to clipboard!', + description: 'Key copied to clipboard.', }); }; - const finishSignup = () => { - login.nsec(nsec); - - setStep('done'); - onClose(); - - toast({ - title: 'Account created', - description: 'You are now logged in.', - }); + const finishKeySetup = () => { + try { + login.nsec(nsec); + setStep('profile'); + } catch { + toast({ + title: 'Login Failed', + description: 'Failed to login with the generated key. Please try again.', + variant: 'destructive', + }); + } }; + const handleAvatarUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + // Reset file input + e.target.value = ''; + + // Validate file type + if (!file.type.startsWith('image/')) { + toast({ + title: 'Invalid file type', + description: 'Please select an image file for your avatar.', + variant: 'destructive', + }); + return; + } + + // Validate file size (max 5MB) + if (file.size > 5 * 1024 * 1024) { + toast({ + title: 'File too large', + description: 'Avatar image must be smaller than 5MB.', + variant: 'destructive', + }); + return; + } + + try { + const tags = await uploadFile(file); + // Get the URL from the first tag + const url = tags[0]?.[1]; + if (url) { + setProfileData(prev => ({ ...prev, picture: url })); + toast({ + title: 'Avatar uploaded!', + description: 'Your avatar has been uploaded successfully.', + }); + } + } catch { + toast({ + title: 'Upload failed', + description: 'Failed to upload avatar. Please try again.', + variant: 'destructive', + }); + } + }; + + const finishSignup = async (skipProfile = false) => { + // Mark signup completion time for fallback welcome modal + localStorage.setItem('signup_completed', Date.now().toString()); + + try { + // Publish profile if user provided information + if (!skipProfile && (profileData.name || profileData.about || profileData.picture)) { + const metadata: Record = {}; + if (profileData.name) metadata.name = profileData.name; + if (profileData.about) metadata.about = profileData.about; + if (profileData.picture) metadata.picture = profileData.picture; + + await publishEvent({ + kind: 0, + content: JSON.stringify(metadata), + }); + + toast({ + title: 'Profile Created!', + description: 'Your profile has been set up.', + }); + } + + // Close signup and show welcome modal + onClose(); + if (onComplete) { + // Add a longer delay to ensure login state has fully propagated + setTimeout(() => { + onComplete(); + }, 600); + } else { + // Fallback for when used without onComplete + setStep('done'); + setTimeout(() => { + onClose(); + toast({ + title: 'Welcome!', + description: 'Your account is ready.', + }); + }, 3000); + } + } catch { + toast({ + title: 'Profile Setup Failed', + description: 'Your account was created but profile setup failed. You can update it later.', + variant: 'destructive', + }); + + // Still proceed to completion even if profile failed + onClose(); + if (onComplete) { + // Add a longer delay to ensure login state has fully propagated + setTimeout(() => { + onComplete(); + }, 600); + } else { + // Fallback for when used without onComplete + setStep('done'); + setTimeout(() => { + onClose(); + toast({ + title: 'Welcome!', + description: 'Your account is ready.', + }); + }, 3000); + } + } + }; + + const getTitle = () => { + if (step === 'welcome') return ( + + + Create Your Account + + ); + if (step === 'generate') return ( + + + Generating Your Key + + ); + if (step === 'download') return ( + + + Secret Key + + ); + if (step === 'profile') return ( + + + Create Your Profile + + ); + return ( + + + Welcome! + + ); + }; + + const getDescription = () => { + if (step === 'welcome') return 'Ready to join the Nostr network?'; + if (step === 'generate') return 'Creating your secret key to access Nostr.'; + + if (step === 'profile') return 'Tell others about yourself.'; + return 'Your account is ready!'; + }; + + // Reset state when dialog opens + useEffect(() => { + if (isOpen) { + setStep('welcome'); + setIsLoading(false); + setNsec(''); + setShowSparkles(false); + setKeySecured('none'); + setProfileData({ name: '', about: '', picture: '' }); + } + }, [isOpen]); + + // Add sparkle animation effect + useEffect(() => { + if (showSparkles) { + const interval = setInterval(() => { + // This will trigger re-renders for sparkle animation + }, 100); + return () => clearInterval(interval); + } + }, [showSparkles]); + return ( - - - - {step === 'generate' && 'Create Your Account'} - {step === 'download' && 'Download Your Key'} - {step === 'done' && 'Setting Up Your Account'} + + + + {getTitle()} - - {step === 'generate' && 'Generate a secure key for your account'} - {step === 'download' && "Keep your key safe - you'll need it to log in"} - {step === 'done' && 'Finalizing your account setup'} + + {getDescription()} +
+ {/* Welcome Step - New engaging introduction */} + {step === 'welcome' && ( +
+ {/* Hero illustration */} +
+
+
+ + +
+ +
+ + +
+
-
- {step === 'generate' && ( -
-
- + {/* Benefits */} +
+
+ + Decentralized and censorship-resistant +
+
+ + You are in control of your data +
+
+ + Join a global network +
+
+
+ +
+

+ Join the Nostr network and take control of your social media experience. + Your journey begins by generating a secret key. +

+ + + +

+ Free forever • Decentralized • Your data, your control +

-

- We'll generate a secure key for your account. You'll need this key to log in later. -

-
)} + {/* Generate Step - Enhanced with animations */} + {step === 'generate' && ( +
+
+ {/* Animated background elements */} + {showSparkles && ( +
+ {[...Array(12)].map((_, i) => ( + + ))} +
+ )} + +
+ {isLoading ? ( +
+
+ +
+
+
+
+
+

+ + Generating your secret key... +

+

+ Creating your secure key +

+
+
+ ) : ( +
+ +
+

+ Ready to generate your secret key? +

+

+ This key will be your password to access applications within the Nostr network. +

+ +
+
+ )} +
+
+ + {!isLoading && ( + + )} +
+ )} + + {/* Download Step - Whimsical and magical */} {step === 'download' && ( -
-
- {nsec} +
+ {/* Key reveal */} +
+ {/* Sparkles */} +
+ + + + +
+ +
+
+
+ +
+
+ +
+
+
+ +
+

+ Your secret key has been generated! +

+ + {/* Warning */} +
+
+
+ + + Important Warning + +
+

+ This key is your primary and only means of accessing your account. Store it safely and securely. +

+
+
+
-
-

Important:

-
    -
  • This is your only way to access your account
  • -
  • Store it somewhere safe
  • -
  • Never share this key with anyone
  • -
+ {/* Key vault */} + + + {/* Security options */} +
+ + +
+ {/* Download Option */} + + + + + + + {/* Copy Option */} + + + + + +
+ + {/* Continue button */} + +
+
+ )} + + {/* Profile Step - Optional profile setup */} + {step === 'profile' && ( +
+ {/* Profile setup illustration */} +
+ {/* Sparkles */} +
+ + + +
+ +
+
+
+ +
+
+ +
+
+
+ +
+

+ Almost there! Let's set up your profile +

+ +

+ Your profile is your identity on Nostr. +

+
-
+ {/* Publishing status indicator */} + {isPublishing && ( +
+
+
+ + Publishing your profile... + +
+
+ )} + + {/* Profile form */} +
+
+ + setProfileData(prev => ({ ...prev, name: e.target.value }))} + placeholder='Your name' + className='rounded-lg' + disabled={isPublishing} + /> +
+ +
+ +