feat: Implement comprehensive Nostr identity and social features
## Core Identity Management - Add secure key generation and import functionality - Implement AES-GCM encryption with PBKDF2 key derivation - Create password-protected identity storage - Add browser-compatible crypto utilities (no Buffer dependency) ## User Interface - Build identity management dialog with tabs for setup and profile - Add navbar integration with user dropdown and mobile support - Create password unlock dialog for encrypted identities - Integrate vue-sonner for toast notifications ## Nostr Protocol Integration - Implement event creation (notes, reactions, profiles, contacts) - Add reply thread detection and engagement metrics - Create social interaction composables for publishing - Support multi-relay publishing with failure handling - Add profile fetching and caching system ## Security Features - Web Crypto API with 100k PBKDF2 iterations - Secure random salt and IV generation - Automatic password prompts for encrypted storage - Legacy support for unencrypted identities ## Technical Improvements - Replace all Buffer usage with browser-native APIs - Add comprehensive error handling and validation - Implement reactive state management with Vue composables - Create reusable crypto utility functions 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
d3e8b23c86
commit
ee7eb461c4
12 changed files with 1777 additions and 21 deletions
47
src/App.vue
47
src/App.vue
|
|
@ -1,14 +1,47 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, onUnmounted } from 'vue'
|
import { onMounted, onUnmounted, ref } from 'vue'
|
||||||
import Navbar from '@/components/layout/Navbar.vue'
|
import Navbar from '@/components/layout/Navbar.vue'
|
||||||
import Footer from '@/components/layout/Footer.vue'
|
import Footer from '@/components/layout/Footer.vue'
|
||||||
import ConnectionStatus from '@/components/nostr/ConnectionStatus.vue'
|
import ConnectionStatus from '@/components/nostr/ConnectionStatus.vue'
|
||||||
|
import PasswordDialog from '@/components/nostr/PasswordDialog.vue'
|
||||||
|
import { Toaster } from 'vue-sonner'
|
||||||
import { useNostr } from '@/composables/useNostr'
|
import { useNostr } from '@/composables/useNostr'
|
||||||
|
import { identity } from '@/composables/useIdentity'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
|
||||||
const relays = JSON.parse(import.meta.env.VITE_NOSTR_RELAYS as string)
|
const relays = JSON.parse(import.meta.env.VITE_NOSTR_RELAYS as string)
|
||||||
const { isConnected, isConnecting, error, connect, disconnect } = useNostr({ relays })
|
const { isConnected, isConnecting, error, connect, disconnect } = useNostr({ relays })
|
||||||
|
|
||||||
|
const showPasswordDialog = ref(false)
|
||||||
|
|
||||||
|
async function handlePasswordUnlock(password: string) {
|
||||||
|
try {
|
||||||
|
await identity.loadWithPassword(password)
|
||||||
|
showPasswordDialog.value = false
|
||||||
|
toast.success('Identity unlocked successfully')
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to unlock identity. Please check your password.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePasswordCancel() {
|
||||||
|
showPasswordDialog.value = false
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
// Check if we have an encrypted identity that needs password
|
||||||
|
if (identity.hasStoredIdentity() && identity.isStoredIdentityEncrypted()) {
|
||||||
|
showPasswordDialog.value = true
|
||||||
|
} else {
|
||||||
|
// Initialize identity system for non-encrypted identities
|
||||||
|
try {
|
||||||
|
await identity.initialize()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to initialize identity:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect to Nostr relays
|
||||||
await connect()
|
await connect()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -39,5 +72,17 @@ onUnmounted(() => {
|
||||||
|
|
||||||
<Footer />
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Toast notifications -->
|
||||||
|
<Toaster />
|
||||||
|
|
||||||
|
<!-- Password unlock dialog -->
|
||||||
|
<PasswordDialog
|
||||||
|
v-model:is-open="showPasswordDialog"
|
||||||
|
title="Unlock Identity"
|
||||||
|
description="Your Nostr identity is encrypted. Enter your password to unlock it."
|
||||||
|
@password="handlePasswordUnlock"
|
||||||
|
@cancel="handlePasswordCancel"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,12 @@ import { ref, computed } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useTheme } from '@/components/theme-provider'
|
import { useTheme } from '@/components/theme-provider'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Zap, Sun, Moon, Menu, X } from 'lucide-vue-next'
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||||
|
import { Zap, Sun, Moon, Menu, X, User, Key, LogOut } from 'lucide-vue-next'
|
||||||
import LanguageSwitcher from '@/components/LanguageSwitcher.vue'
|
import LanguageSwitcher from '@/components/LanguageSwitcher.vue'
|
||||||
|
import IdentityDialog from '@/components/nostr/IdentityDialog.vue'
|
||||||
|
import { identity } from '@/composables/useIdentity'
|
||||||
|
|
||||||
interface NavigationItem {
|
interface NavigationItem {
|
||||||
name: string
|
name: string
|
||||||
|
|
@ -14,6 +18,7 @@ interface NavigationItem {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const { theme, setTheme } = useTheme()
|
const { theme, setTheme } = useTheme()
|
||||||
const isOpen = ref(false)
|
const isOpen = ref(false)
|
||||||
|
const showIdentityDialog = ref(false)
|
||||||
|
|
||||||
const navigation = computed<NavigationItem[]>(() => [
|
const navigation = computed<NavigationItem[]>(() => [
|
||||||
{ name: t('nav.home'), href: '/' },
|
{ name: t('nav.home'), href: '/' },
|
||||||
|
|
@ -28,6 +33,10 @@ const toggleMenu = () => {
|
||||||
const toggleTheme = () => {
|
const toggleTheme = () => {
|
||||||
setTheme(theme.value === 'dark' ? 'light' : 'dark')
|
setTheme(theme.value === 'dark' ? 'light' : 'dark')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openIdentityDialog = () => {
|
||||||
|
showIdentityDialog.value = true
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -51,8 +60,41 @@ const toggleTheme = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Theme Toggle and Language -->
|
<!-- Theme Toggle, Language, and Identity -->
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
|
<!-- Identity Management -->
|
||||||
|
<div class="hidden sm:block">
|
||||||
|
<DropdownMenu v-if="identity.isAuthenticated.value">
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm" class="gap-2">
|
||||||
|
<User class="h-4 w-4" />
|
||||||
|
<span class="max-w-24 truncate">{{ identity.profileDisplay.value?.name || 'Anonymous' }}</span>
|
||||||
|
<Badge variant="secondary" class="text-xs">Connected</Badge>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" class="w-56">
|
||||||
|
<DropdownMenuItem @click="openIdentityDialog" class="gap-2">
|
||||||
|
<User class="h-4 w-4" />
|
||||||
|
Profile
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem @click="openIdentityDialog" class="gap-2">
|
||||||
|
<Key class="h-4 w-4" />
|
||||||
|
Keys
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem @click="identity.signOut()" class="gap-2 text-destructive">
|
||||||
|
<LogOut class="h-4 w-4" />
|
||||||
|
Sign Out
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
<Button v-else variant="outline" size="sm" @click="openIdentityDialog" class="gap-2">
|
||||||
|
<User class="h-4 w-4" />
|
||||||
|
Connect Identity
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Hide theme toggle on mobile -->
|
<!-- Hide theme toggle on mobile -->
|
||||||
<Button variant="ghost" size="icon" @click="toggleTheme"
|
<Button variant="ghost" size="icon" @click="toggleTheme"
|
||||||
class="hidden sm:inline-flex text-foreground hover:text-foreground/90 hover:bg-accent">
|
class="hidden sm:inline-flex text-foreground hover:text-foreground/90 hover:bg-accent">
|
||||||
|
|
@ -79,8 +121,33 @@ const toggleTheme = () => {
|
||||||
<div v-show="isOpen"
|
<div v-show="isOpen"
|
||||||
class="absolute left-0 right-0 top-14 z-[100] border-b bg-background/95 backdrop-blur-sm md:hidden">
|
class="absolute left-0 right-0 top-14 z-[100] border-b bg-background/95 backdrop-blur-sm md:hidden">
|
||||||
<div class="space-y-1 p-4">
|
<div class="space-y-1 p-4">
|
||||||
|
<!-- Identity in mobile menu -->
|
||||||
|
<div class="mb-4 px-2">
|
||||||
|
<Button v-if="!identity.isAuthenticated.value" variant="outline" size="sm" @click="openIdentityDialog; isOpen = false" class="w-full gap-2">
|
||||||
|
<User class="h-4 w-4" />
|
||||||
|
Connect Identity
|
||||||
|
</Button>
|
||||||
|
<div v-else class="space-y-2">
|
||||||
|
<div class="flex items-center gap-2 px-2 py-1">
|
||||||
|
<User class="h-4 w-4" />
|
||||||
|
<span class="text-sm font-medium">{{ identity.profileDisplay.value?.name || 'Anonymous' }}</span>
|
||||||
|
<Badge variant="secondary" class="text-xs ml-auto">Connected</Badge>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Button variant="ghost" size="sm" @click="openIdentityDialog; isOpen = false" class="w-full justify-start gap-2">
|
||||||
|
<User class="h-4 w-4" />
|
||||||
|
Profile
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" @click="identity.signOut(); isOpen = false" class="w-full justify-start gap-2 text-destructive">
|
||||||
|
<LogOut class="h-4 w-4" />
|
||||||
|
Sign Out
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Add theme and language toggles to mobile menu -->
|
<!-- Add theme and language toggles to mobile menu -->
|
||||||
<div class="flex items-center justify-between mb-4 px-2">
|
<div class="flex items-center justify-between mb-4 px-2 border-t pt-4">
|
||||||
<Button variant="ghost" size="icon" @click="toggleTheme"
|
<Button variant="ghost" size="icon" @click="toggleTheme"
|
||||||
class="text-foreground hover:text-foreground/90 hover:bg-accent">
|
class="text-foreground hover:text-foreground/90 hover:bg-accent">
|
||||||
<Sun v-if="theme === 'dark'" class="h-5 w-5" />
|
<Sun v-if="theme === 'dark'" class="h-5 w-5" />
|
||||||
|
|
@ -98,5 +165,8 @@ const toggleTheme = () => {
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Identity Dialog -->
|
||||||
|
<IdentityDialog v-model:is-open="showIdentityDialog" />
|
||||||
</nav>
|
</nav>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
453
src/components/nostr/IdentityDialog.vue
Normal file
453
src/components/nostr/IdentityDialog.vue
Normal file
|
|
@ -0,0 +1,453 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Key, Download, Upload, Copy, Check } from 'lucide-vue-next'
|
||||||
|
import { identity } from '@/composables/useIdentity'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isOpen: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'update:isOpen', value: boolean): void
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
|
// Form states
|
||||||
|
const importKey = ref('')
|
||||||
|
const usePassword = ref(false)
|
||||||
|
const password = ref('')
|
||||||
|
const confirmPassword = ref('')
|
||||||
|
const showPrivateKey = ref(false)
|
||||||
|
const copied = ref(false)
|
||||||
|
|
||||||
|
// Profile form
|
||||||
|
const profileForm = ref({
|
||||||
|
name: '',
|
||||||
|
display_name: '',
|
||||||
|
about: '',
|
||||||
|
picture: '',
|
||||||
|
website: '',
|
||||||
|
lud16: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const passwordsMatch = computed(() => {
|
||||||
|
if (!usePassword.value) return true
|
||||||
|
return password.value === confirmPassword.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const canGenerate = computed(() => {
|
||||||
|
if (!usePassword.value) return true
|
||||||
|
return password.value.length >= 6 && passwordsMatch.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const canImport = computed(() => {
|
||||||
|
return importKey.value.trim().length > 0 && canGenerate.value
|
||||||
|
})
|
||||||
|
|
||||||
|
async function handleGenerate() {
|
||||||
|
try {
|
||||||
|
const pwd = usePassword.value ? password.value : undefined
|
||||||
|
await identity.generateNewIdentity(pwd)
|
||||||
|
|
||||||
|
// Initialize profile form with current data
|
||||||
|
const profile = identity.currentProfile.value
|
||||||
|
if (profile) {
|
||||||
|
profileForm.value = { ...profile }
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success('Identity generated successfully!')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to generate identity:', error)
|
||||||
|
toast.error('Failed to generate identity')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleImport() {
|
||||||
|
try {
|
||||||
|
const pwd = usePassword.value ? password.value : undefined
|
||||||
|
await identity.importIdentity(importKey.value.trim(), pwd)
|
||||||
|
|
||||||
|
// Initialize profile form with current data
|
||||||
|
const profile = identity.currentProfile.value
|
||||||
|
if (profile) {
|
||||||
|
profileForm.value = { ...profile }
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success('Identity imported successfully!')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to import identity:', error)
|
||||||
|
toast.error('Failed to import identity')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpdateProfile() {
|
||||||
|
try {
|
||||||
|
// Filter out empty values
|
||||||
|
const profileData = Object.fromEntries(
|
||||||
|
Object.entries(profileForm.value).filter(([_, value]) => value.trim() !== '')
|
||||||
|
)
|
||||||
|
|
||||||
|
await identity.updateProfile(profileData)
|
||||||
|
toast.success('Profile updated successfully!')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update profile:', error)
|
||||||
|
toast.error('Failed to update profile')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyToClipboard(text: string) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
copied.value = true
|
||||||
|
setTimeout(() => copied.value = false, 2000)
|
||||||
|
toast.success('Copied to clipboard!')
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to copy to clipboard')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
emit('update:isOpen', false)
|
||||||
|
// Reset forms
|
||||||
|
importKey.value = ''
|
||||||
|
password.value = ''
|
||||||
|
confirmPassword.value = ''
|
||||||
|
usePassword.value = false
|
||||||
|
showPrivateKey.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize profile form when dialog opens
|
||||||
|
function initializeProfileForm() {
|
||||||
|
const profile = identity.currentProfile.value
|
||||||
|
if (profile) {
|
||||||
|
profileForm.value = {
|
||||||
|
name: profile.name || '',
|
||||||
|
display_name: profile.display_name || '',
|
||||||
|
about: profile.about || '',
|
||||||
|
picture: profile.picture || '',
|
||||||
|
website: profile.website || '',
|
||||||
|
lud16: profile.lud16 || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Dialog :open="isOpen" @update:open="handleClose">
|
||||||
|
<DialogContent class="sm:max-w-[600px] max-h-[80vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle class="flex items-center gap-2">
|
||||||
|
<Key class="w-5 h-5" />
|
||||||
|
Nostr Identity
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Manage your Nostr identity and profile
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Tabs default-value="identity" class="w-full" @update:model-value="initializeProfileForm">
|
||||||
|
<TabsList class="grid w-full grid-cols-2">
|
||||||
|
<TabsTrigger value="identity">Identity</TabsTrigger>
|
||||||
|
<TabsTrigger value="profile" :disabled="!identity.isAuthenticated.value">
|
||||||
|
Profile
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="identity" class="space-y-4">
|
||||||
|
<!-- Current Identity Display -->
|
||||||
|
<div v-if="identity.isAuthenticated.value" class="space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle class="text-lg">Current Identity</CardTitle>
|
||||||
|
<CardDescription>Your active Nostr identity</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="space-y-3">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label class="text-sm font-medium">Public Key (npub)</Label>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
:model-value="identity.identityInfo.value?.npub || ''"
|
||||||
|
readonly
|
||||||
|
class="font-mono text-xs"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
@click="copyToClipboard(identity.identityInfo.value?.npub || '')"
|
||||||
|
>
|
||||||
|
<Copy class="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<Label class="text-sm font-medium">Private Key (nsec)</Label>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
@click="showPrivateKey = !showPrivateKey"
|
||||||
|
>
|
||||||
|
{{ showPrivateKey ? 'Hide' : 'Show' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
:type="showPrivateKey ? 'text' : 'password'"
|
||||||
|
:model-value="identity.currentIdentity.value?.nsec || ''"
|
||||||
|
readonly
|
||||||
|
class="font-mono text-xs"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
@click="copyToClipboard(identity.currentIdentity.value?.nsec || '')"
|
||||||
|
>
|
||||||
|
<component :is="copied ? Check : Copy" class="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
⚠️ Keep your private key safe! Anyone with access can control your identity.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button variant="destructive" size="sm" @click="identity.signOut()">
|
||||||
|
Sign Out
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Identity Setup -->
|
||||||
|
<div v-else class="space-y-4">
|
||||||
|
<Tabs default-value="generate" class="w-full">
|
||||||
|
<TabsList class="grid w-full grid-cols-2">
|
||||||
|
<TabsTrigger value="generate">
|
||||||
|
<Download class="w-4 h-4 mr-2" />
|
||||||
|
Generate New
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="import">
|
||||||
|
<Upload class="w-4 h-4 mr-2" />
|
||||||
|
Import Existing
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="generate" class="space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Generate New Identity</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Create a new Nostr identity with a fresh keypair
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="space-y-4">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
id="use-password"
|
||||||
|
type="checkbox"
|
||||||
|
v-model="usePassword"
|
||||||
|
class="rounded"
|
||||||
|
/>
|
||||||
|
<Label for="use-password">Encrypt with password</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="usePassword" class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<Label for="password">Password</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
v-model="password"
|
||||||
|
placeholder="Enter password (min 6 characters)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label for="confirm-password">Confirm Password</Label>
|
||||||
|
<Input
|
||||||
|
id="confirm-password"
|
||||||
|
type="password"
|
||||||
|
v-model="confirmPassword"
|
||||||
|
placeholder="Confirm password"
|
||||||
|
:class="{ 'border-destructive': !passwordsMatch && confirmPassword.length > 0 }"
|
||||||
|
/>
|
||||||
|
<p v-if="!passwordsMatch && confirmPassword.length > 0" class="text-xs text-destructive mt-1">
|
||||||
|
Passwords do not match
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
@click="handleGenerate"
|
||||||
|
:disabled="!canGenerate || identity.isLoading.value"
|
||||||
|
class="w-full"
|
||||||
|
>
|
||||||
|
Generate Identity
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="import" class="space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Import Existing Identity</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Import your existing Nostr identity using your private key
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label for="import-key">Private Key</Label>
|
||||||
|
<Textarea
|
||||||
|
id="import-key"
|
||||||
|
v-model="importKey"
|
||||||
|
placeholder="Enter your private key (hex format or nsec...)"
|
||||||
|
class="font-mono text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
id="use-password-import"
|
||||||
|
type="checkbox"
|
||||||
|
v-model="usePassword"
|
||||||
|
class="rounded"
|
||||||
|
/>
|
||||||
|
<Label for="use-password-import">Encrypt with password</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="usePassword" class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<Label for="password-import">Password</Label>
|
||||||
|
<Input
|
||||||
|
id="password-import"
|
||||||
|
type="password"
|
||||||
|
v-model="password"
|
||||||
|
placeholder="Enter password (min 6 characters)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label for="confirm-password-import">Confirm Password</Label>
|
||||||
|
<Input
|
||||||
|
id="confirm-password-import"
|
||||||
|
type="password"
|
||||||
|
v-model="confirmPassword"
|
||||||
|
placeholder="Confirm password"
|
||||||
|
:class="{ 'border-destructive': !passwordsMatch && confirmPassword.length > 0 }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
@click="handleImport"
|
||||||
|
:disabled="!canImport || identity.isLoading.value"
|
||||||
|
class="w-full"
|
||||||
|
>
|
||||||
|
Import Identity
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="profile" class="space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Profile Information</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Update your public Nostr profile information
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="space-y-4">
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label for="name">Name</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
v-model="profileForm.name"
|
||||||
|
placeholder="Your name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label for="display-name">Display Name</Label>
|
||||||
|
<Input
|
||||||
|
id="display-name"
|
||||||
|
v-model="profileForm.display_name"
|
||||||
|
placeholder="Display name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label for="about">About</Label>
|
||||||
|
<Textarea
|
||||||
|
id="about"
|
||||||
|
v-model="profileForm.about"
|
||||||
|
placeholder="Tell people about yourself..."
|
||||||
|
rows="3"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label for="picture">Picture URL</Label>
|
||||||
|
<Input
|
||||||
|
id="picture"
|
||||||
|
v-model="profileForm.picture"
|
||||||
|
placeholder="https://example.com/your-avatar.jpg"
|
||||||
|
type="url"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label for="website">Website</Label>
|
||||||
|
<Input
|
||||||
|
id="website"
|
||||||
|
v-model="profileForm.website"
|
||||||
|
placeholder="https://your-website.com"
|
||||||
|
type="url"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label for="lightning">Lightning Address</Label>
|
||||||
|
<Input
|
||||||
|
id="lightning"
|
||||||
|
v-model="profileForm.lud16"
|
||||||
|
placeholder="you@getalby.com"
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-muted-foreground mt-1">
|
||||||
|
Lightning address for receiving zaps
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
@click="handleUpdateProfile"
|
||||||
|
:disabled="identity.isLoading.value"
|
||||||
|
class="w-full"
|
||||||
|
>
|
||||||
|
Update Profile
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<div v-if="identity.error.value" class="text-sm text-destructive">
|
||||||
|
{{ identity.error.value }}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
105
src/components/nostr/PasswordDialog.vue
Normal file
105
src/components/nostr/PasswordDialog.vue
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Lock } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isOpen: boolean
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'update:isOpen', value: boolean): void
|
||||||
|
(e: 'password', password: string): void
|
||||||
|
(e: 'cancel'): void
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
title: 'Enter Password',
|
||||||
|
description: 'Your identity is encrypted. Please enter your password to continue.'
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
|
const password = ref('')
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
function handleSubmit() {
|
||||||
|
if (!password.value.trim()) {
|
||||||
|
error.value = 'Password is required'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
error.value = ''
|
||||||
|
emit('password', password.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
emit('cancel')
|
||||||
|
handleClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
emit('update:isOpen', false)
|
||||||
|
// Reset form
|
||||||
|
password.value = ''
|
||||||
|
error.value = ''
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
handleSubmit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Dialog :open="isOpen" @update:open="handleClose">
|
||||||
|
<DialogContent class="sm:max-w-[425px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle class="flex items-center gap-2">
|
||||||
|
<Lock class="w-5 h-5" />
|
||||||
|
{{ title }}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{{ description }}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div class="grid gap-4 py-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="password">Password</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
v-model="password"
|
||||||
|
placeholder="Enter your password"
|
||||||
|
:disabled="isLoading"
|
||||||
|
@keydown="handleKeydown"
|
||||||
|
class="w-full"
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
<p v-if="error" class="text-sm text-destructive">
|
||||||
|
{{ error }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" @click="handleCancel" :disabled="isLoading">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button @click="handleSubmit" :disabled="isLoading || !password.trim()">
|
||||||
|
<span v-if="isLoading" class="animate-spin mr-2">⚡</span>
|
||||||
|
Unlock
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
193
src/composables/useIdentity.ts
Normal file
193
src/composables/useIdentity.ts
Normal file
|
|
@ -0,0 +1,193 @@
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { IdentityManager, type NostrIdentity, type NostrProfile } from '@/lib/nostr/identity'
|
||||||
|
|
||||||
|
const currentIdentity = ref<NostrIdentity | null>(null)
|
||||||
|
const currentProfile = ref<NostrProfile | null>(null)
|
||||||
|
const isAuthenticated = computed(() => !!currentIdentity.value)
|
||||||
|
|
||||||
|
export function useIdentity() {
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize identity on app start
|
||||||
|
*/
|
||||||
|
async function initialize(password?: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
isLoading.value = true
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
const identity = await IdentityManager.loadIdentity(password)
|
||||||
|
if (identity) {
|
||||||
|
currentIdentity.value = identity
|
||||||
|
currentProfile.value = IdentityManager.loadProfile()
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : 'Failed to initialize identity'
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate new identity
|
||||||
|
*/
|
||||||
|
async function generateNewIdentity(password?: string): Promise<NostrIdentity> {
|
||||||
|
try {
|
||||||
|
isLoading.value = true
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
const identity = IdentityManager.generateIdentity()
|
||||||
|
await IdentityManager.saveIdentity(identity, password)
|
||||||
|
|
||||||
|
currentIdentity.value = identity
|
||||||
|
currentProfile.value = null
|
||||||
|
|
||||||
|
return identity
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : 'Failed to generate identity'
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import existing identity
|
||||||
|
*/
|
||||||
|
async function importIdentity(privateKey: string, password?: string): Promise<NostrIdentity> {
|
||||||
|
try {
|
||||||
|
isLoading.value = true
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
const identity = IdentityManager.importIdentity(privateKey)
|
||||||
|
await IdentityManager.saveIdentity(identity, password)
|
||||||
|
|
||||||
|
currentIdentity.value = identity
|
||||||
|
currentProfile.value = IdentityManager.loadProfile()
|
||||||
|
|
||||||
|
return identity
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : 'Failed to import identity'
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update user profile
|
||||||
|
*/
|
||||||
|
async function updateProfile(profile: NostrProfile): Promise<void> {
|
||||||
|
try {
|
||||||
|
isLoading.value = true
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
IdentityManager.saveProfile(profile)
|
||||||
|
currentProfile.value = profile
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : 'Failed to update profile'
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign out and clear identity
|
||||||
|
*/
|
||||||
|
function signOut(): void {
|
||||||
|
IdentityManager.clearIdentity()
|
||||||
|
currentIdentity.value = null
|
||||||
|
currentProfile.value = null
|
||||||
|
error.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if identity is stored
|
||||||
|
*/
|
||||||
|
function hasStoredIdentity(): boolean {
|
||||||
|
return IdentityManager.hasStoredIdentity()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if stored identity is encrypted
|
||||||
|
*/
|
||||||
|
function isStoredIdentityEncrypted(): boolean {
|
||||||
|
return IdentityManager.isStoredIdentityEncrypted()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load identity with password (for encrypted identities)
|
||||||
|
*/
|
||||||
|
async function loadWithPassword(password: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
isLoading.value = true
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
const identity = await IdentityManager.loadIdentity(password)
|
||||||
|
if (identity) {
|
||||||
|
currentIdentity.value = identity
|
||||||
|
currentProfile.value = IdentityManager.loadProfile()
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : 'Failed to load identity'
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current identity info
|
||||||
|
*/
|
||||||
|
const identityInfo = computed(() => {
|
||||||
|
if (!currentIdentity.value) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
npub: currentIdentity.value.npub,
|
||||||
|
publicKey: currentIdentity.value.publicKey,
|
||||||
|
shortPubkey: currentIdentity.value.publicKey.slice(0, 8) + '...' + currentIdentity.value.publicKey.slice(-8)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get profile display info
|
||||||
|
*/
|
||||||
|
const profileDisplay = computed(() => {
|
||||||
|
if (!currentProfile.value && !identityInfo.value) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: currentProfile.value?.name || currentProfile.value?.display_name || identityInfo.value?.shortPubkey || 'Anonymous',
|
||||||
|
displayName: currentProfile.value?.display_name || currentProfile.value?.name,
|
||||||
|
about: currentProfile.value?.about,
|
||||||
|
picture: currentProfile.value?.picture,
|
||||||
|
website: currentProfile.value?.website,
|
||||||
|
lightningAddress: currentProfile.value?.lud16
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
currentIdentity: computed(() => currentIdentity.value),
|
||||||
|
currentProfile: computed(() => currentProfile.value),
|
||||||
|
isAuthenticated,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
identityInfo,
|
||||||
|
profileDisplay,
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
initialize,
|
||||||
|
generateNewIdentity,
|
||||||
|
importIdentity,
|
||||||
|
updateProfile,
|
||||||
|
signOut,
|
||||||
|
hasStoredIdentity,
|
||||||
|
isStoredIdentityEncrypted,
|
||||||
|
loadWithPassword
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export singleton instance for global state
|
||||||
|
export const identity = useIdentity()
|
||||||
156
src/composables/useSocial.ts
Normal file
156
src/composables/useSocial.ts
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { NostrClient, type NostrNote } from '@/lib/nostr/client'
|
||||||
|
import { createTextNote, createReaction, createProfileMetadata } from '@/lib/nostr/events'
|
||||||
|
import { identity } from '@/composables/useIdentity'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
|
||||||
|
export function useSocial(relayUrls: string[]) {
|
||||||
|
const client = new NostrClient({ relays: relayUrls })
|
||||||
|
const isPublishing = ref(false)
|
||||||
|
const profiles = ref(new Map<string, any>())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publish a text note
|
||||||
|
*/
|
||||||
|
async function publishNote(content: string, replyTo?: string): Promise<void> {
|
||||||
|
if (!identity.isAuthenticated.value || !identity.currentIdentity.value) {
|
||||||
|
throw new Error('Must be logged in to publish notes')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
isPublishing.value = true
|
||||||
|
|
||||||
|
await client.connect()
|
||||||
|
const event = createTextNote(content, identity.currentIdentity.value, replyTo)
|
||||||
|
await client.publishEvent(event)
|
||||||
|
|
||||||
|
toast.success(replyTo ? 'Reply published!' : 'Note published!')
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to publish note'
|
||||||
|
toast.error(message)
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
isPublishing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publish a reaction to a note
|
||||||
|
*/
|
||||||
|
async function publishReaction(targetEventId: string, targetAuthor: string, reaction: string = '👍'): Promise<void> {
|
||||||
|
if (!identity.isAuthenticated.value || !identity.currentIdentity.value) {
|
||||||
|
throw new Error('Must be logged in to react to notes')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.connect()
|
||||||
|
const event = createReaction(targetEventId, targetAuthor, reaction, identity.currentIdentity.value)
|
||||||
|
await client.publishEvent(event)
|
||||||
|
|
||||||
|
toast.success('Reaction added!')
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to add reaction'
|
||||||
|
toast.error(message)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publish profile metadata
|
||||||
|
*/
|
||||||
|
async function publishProfile(profileData: any): Promise<void> {
|
||||||
|
if (!identity.isAuthenticated.value || !identity.currentIdentity.value) {
|
||||||
|
throw new Error('Must be logged in to update profile')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
isPublishing.value = true
|
||||||
|
|
||||||
|
await client.connect()
|
||||||
|
const event = createProfileMetadata(profileData, identity.currentIdentity.value)
|
||||||
|
await client.publishEvent(event)
|
||||||
|
|
||||||
|
toast.success('Profile updated on Nostr!')
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to update profile'
|
||||||
|
toast.error(message)
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
isPublishing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch replies to a note
|
||||||
|
*/
|
||||||
|
async function fetchReplies(noteId: string): Promise<NostrNote[]> {
|
||||||
|
try {
|
||||||
|
await client.connect()
|
||||||
|
return await client.fetchReplies(noteId)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch replies:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch and cache user profiles
|
||||||
|
*/
|
||||||
|
async function fetchProfiles(pubkeys: string[]): Promise<void> {
|
||||||
|
// Filter out already cached profiles
|
||||||
|
const uncachedPubkeys = pubkeys.filter(pubkey => !profiles.value.has(pubkey))
|
||||||
|
|
||||||
|
if (uncachedPubkeys.length === 0) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.connect()
|
||||||
|
const fetchedProfiles = await client.fetchProfiles(uncachedPubkeys)
|
||||||
|
|
||||||
|
// Update cache
|
||||||
|
fetchedProfiles.forEach((profile, pubkey) => {
|
||||||
|
profiles.value.set(pubkey, profile)
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch profiles:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cached profile or return default
|
||||||
|
*/
|
||||||
|
function getProfile(pubkey: string) {
|
||||||
|
return profiles.value.get(pubkey) || {
|
||||||
|
name: pubkey.slice(0, 8) + '...',
|
||||||
|
display_name: undefined,
|
||||||
|
about: undefined,
|
||||||
|
picture: undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get display name for a pubkey
|
||||||
|
*/
|
||||||
|
function getDisplayName(pubkey: string): string {
|
||||||
|
const profile = getProfile(pubkey)
|
||||||
|
return profile.display_name || profile.name || pubkey.slice(0, 8) + '...'
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
isPublishing,
|
||||||
|
profiles,
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
publishNote,
|
||||||
|
publishReaction,
|
||||||
|
publishProfile,
|
||||||
|
fetchReplies,
|
||||||
|
fetchProfiles,
|
||||||
|
getProfile,
|
||||||
|
getDisplayName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export singleton instance for global use
|
||||||
|
const relayUrls = JSON.parse(import.meta.env.VITE_NOSTR_RELAYS as string)
|
||||||
|
export const social = useSocial(relayUrls)
|
||||||
134
src/lib/crypto/encryption.ts
Normal file
134
src/lib/crypto/encryption.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
/**
|
||||||
|
* Secure encryption utilities using Web Crypto API
|
||||||
|
* Provides AES-GCM encryption for sensitive data storage
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface EncryptedData {
|
||||||
|
encryptedData: string
|
||||||
|
iv: string
|
||||||
|
salt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SecureStorage {
|
||||||
|
private static readonly ALGORITHM = 'AES-GCM'
|
||||||
|
private static readonly KEY_LENGTH = 256
|
||||||
|
private static readonly IV_LENGTH = 12
|
||||||
|
private static readonly SALT_LENGTH = 16
|
||||||
|
private static readonly ITERATIONS = 100000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive encryption key from password using PBKDF2
|
||||||
|
*/
|
||||||
|
private static async deriveKey(password: string, salt: Uint8Array): Promise<CryptoKey> {
|
||||||
|
const encoder = new TextEncoder()
|
||||||
|
const passwordData = encoder.encode(password)
|
||||||
|
|
||||||
|
// Import password as key material
|
||||||
|
const keyMaterial = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
passwordData,
|
||||||
|
'PBKDF2',
|
||||||
|
false,
|
||||||
|
['deriveBits', 'deriveKey']
|
||||||
|
)
|
||||||
|
|
||||||
|
// Derive AES key using PBKDF2
|
||||||
|
return crypto.subtle.deriveKey(
|
||||||
|
{
|
||||||
|
name: 'PBKDF2',
|
||||||
|
salt,
|
||||||
|
iterations: this.ITERATIONS,
|
||||||
|
hash: 'SHA-256'
|
||||||
|
},
|
||||||
|
keyMaterial,
|
||||||
|
{
|
||||||
|
name: this.ALGORITHM,
|
||||||
|
length: this.KEY_LENGTH
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
['encrypt', 'decrypt']
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypt data with password
|
||||||
|
*/
|
||||||
|
static async encrypt(data: string, password: string): Promise<EncryptedData> {
|
||||||
|
try {
|
||||||
|
const encoder = new TextEncoder()
|
||||||
|
const dataBytes = encoder.encode(data)
|
||||||
|
|
||||||
|
// Generate random salt and IV
|
||||||
|
const salt = crypto.getRandomValues(new Uint8Array(this.SALT_LENGTH))
|
||||||
|
const iv = crypto.getRandomValues(new Uint8Array(this.IV_LENGTH))
|
||||||
|
|
||||||
|
// Derive encryption key
|
||||||
|
const key = await this.deriveKey(password, salt)
|
||||||
|
|
||||||
|
// Encrypt the data
|
||||||
|
const encryptedBuffer = await crypto.subtle.encrypt(
|
||||||
|
{
|
||||||
|
name: this.ALGORITHM,
|
||||||
|
iv
|
||||||
|
},
|
||||||
|
key,
|
||||||
|
dataBytes
|
||||||
|
)
|
||||||
|
|
||||||
|
// Convert to base64 for storage
|
||||||
|
return {
|
||||||
|
encryptedData: btoa(String.fromCharCode(...new Uint8Array(encryptedBuffer))),
|
||||||
|
iv: btoa(String.fromCharCode(...iv)),
|
||||||
|
salt: btoa(String.fromCharCode(...salt))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Encryption failed: ${error instanceof Error ? error.message : 'Unknown error'}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt data with password
|
||||||
|
*/
|
||||||
|
static async decrypt(encryptedData: EncryptedData, password: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
// Convert from base64
|
||||||
|
const encrypted = new Uint8Array(
|
||||||
|
atob(encryptedData.encryptedData).split('').map(c => c.charCodeAt(0))
|
||||||
|
)
|
||||||
|
const iv = new Uint8Array(
|
||||||
|
atob(encryptedData.iv).split('').map(c => c.charCodeAt(0))
|
||||||
|
)
|
||||||
|
const salt = new Uint8Array(
|
||||||
|
atob(encryptedData.salt).split('').map(c => c.charCodeAt(0))
|
||||||
|
)
|
||||||
|
|
||||||
|
// Derive decryption key
|
||||||
|
const key = await this.deriveKey(password, salt)
|
||||||
|
|
||||||
|
// Decrypt the data
|
||||||
|
const decryptedBuffer = await crypto.subtle.decrypt(
|
||||||
|
{
|
||||||
|
name: this.ALGORITHM,
|
||||||
|
iv
|
||||||
|
},
|
||||||
|
key,
|
||||||
|
encrypted
|
||||||
|
)
|
||||||
|
|
||||||
|
// Convert back to string
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
return decoder.decode(decryptedBuffer)
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Decryption failed: ${error instanceof Error ? error.message : 'Invalid password or corrupted data'}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if Web Crypto API is available
|
||||||
|
*/
|
||||||
|
static isSupported(): boolean {
|
||||||
|
return typeof crypto !== 'undefined' &&
|
||||||
|
typeof crypto.subtle !== 'undefined' &&
|
||||||
|
typeof crypto.getRandomValues !== 'undefined'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { SimplePool, type Filter, type Event } from 'nostr-tools'
|
import { SimplePool, type Filter, type Event, type UnsignedEvent } from 'nostr-tools'
|
||||||
|
import { extractReactions, extractReplyCounts, getReplyInfo, EventKinds } from './events'
|
||||||
|
|
||||||
export interface NostrClientConfig {
|
export interface NostrClientConfig {
|
||||||
relays: string[]
|
relays: string[]
|
||||||
|
|
@ -6,8 +7,12 @@ export interface NostrClientConfig {
|
||||||
|
|
||||||
export interface NostrNote extends Event {
|
export interface NostrNote extends Event {
|
||||||
// Add any additional note-specific fields we want to track
|
// Add any additional note-specific fields we want to track
|
||||||
replyCount?: number
|
replyCount: number
|
||||||
reactionCount?: number
|
reactionCount: number
|
||||||
|
reactions: { [reaction: string]: number }
|
||||||
|
isReply: boolean
|
||||||
|
replyTo?: string
|
||||||
|
mentions: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export class NostrClient {
|
export class NostrClient {
|
||||||
|
|
@ -51,23 +56,146 @@ export class NostrClient {
|
||||||
async fetchNotes(options: {
|
async fetchNotes(options: {
|
||||||
limit?: number
|
limit?: number
|
||||||
since?: number // Unix timestamp in seconds
|
since?: number // Unix timestamp in seconds
|
||||||
|
authors?: string[]
|
||||||
|
includeReplies?: boolean
|
||||||
} = {}): Promise<NostrNote[]> {
|
} = {}): Promise<NostrNote[]> {
|
||||||
const { limit = 20, since = Math.floor((Date.now() - 24 * 60 * 60 * 1000) / 1000) } = options
|
const {
|
||||||
|
limit = 20,
|
||||||
|
since = Math.floor((Date.now() - 24 * 60 * 60 * 1000) / 1000),
|
||||||
|
authors,
|
||||||
|
includeReplies = false
|
||||||
|
} = options
|
||||||
|
|
||||||
const filter: Filter = {
|
const filters: Filter[] = [
|
||||||
kinds: [1], // Regular notes
|
{
|
||||||
|
kinds: [EventKinds.TEXT_NOTE],
|
||||||
since,
|
since,
|
||||||
|
limit,
|
||||||
|
...(authors && { authors })
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
// Also fetch reactions and replies for engagement data
|
||||||
|
const engagementFilters: Filter[] = [
|
||||||
|
{
|
||||||
|
kinds: [EventKinds.REACTION, EventKinds.TEXT_NOTE],
|
||||||
|
since,
|
||||||
|
limit: limit * 5 // Get more for engagement calculation
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get events from all relays
|
||||||
|
const [noteEvents, engagementEvents] = await Promise.all([
|
||||||
|
Promise.all(
|
||||||
|
this.relays.map(async (relay) => {
|
||||||
|
try {
|
||||||
|
return await this.pool.querySync([relay], filters)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Failed to fetch notes from relay ${relay}:`, error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
),
|
||||||
|
Promise.all(
|
||||||
|
this.relays.map(async (relay) => {
|
||||||
|
try {
|
||||||
|
return await this.pool.querySync([relay], engagementFilters)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Failed to fetch engagement from relay ${relay}:`, error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
])
|
||||||
|
|
||||||
|
// Flatten and deduplicate events by ID
|
||||||
|
const uniqueNotes = Array.from(
|
||||||
|
new Map(
|
||||||
|
noteEvents.flat().map(event => [event.id, event])
|
||||||
|
).values()
|
||||||
|
)
|
||||||
|
|
||||||
|
const allEngagementEvents = engagementEvents.flat()
|
||||||
|
|
||||||
|
// Extract engagement data
|
||||||
|
const reactions = extractReactions(allEngagementEvents)
|
||||||
|
const replyCounts = extractReplyCounts(allEngagementEvents)
|
||||||
|
|
||||||
|
// Process notes with engagement data
|
||||||
|
let processedNotes = uniqueNotes
|
||||||
|
.map((event: Event): NostrNote => {
|
||||||
|
const replyInfo = getReplyInfo(event)
|
||||||
|
const eventReactions = reactions.get(event.id) || {}
|
||||||
|
const reactionCount = Object.values(eventReactions).reduce((sum, count) => sum + count, 0)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...event,
|
||||||
|
replyCount: replyCounts.get(event.id) || 0,
|
||||||
|
reactionCount,
|
||||||
|
reactions: eventReactions,
|
||||||
|
isReply: replyInfo.isReply,
|
||||||
|
replyTo: replyInfo.replyTo,
|
||||||
|
mentions: replyInfo.mentions
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Filter out replies if not requested
|
||||||
|
if (!includeReplies) {
|
||||||
|
processedNotes = processedNotes.filter(note => !note.isReply)
|
||||||
|
}
|
||||||
|
|
||||||
|
return processedNotes
|
||||||
|
.sort((a: NostrNote, b: NostrNote) => b.created_at - a.created_at) // Sort by newest first
|
||||||
|
.slice(0, limit) // Apply limit after processing
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch notes:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publish an event to all connected relays
|
||||||
|
*/
|
||||||
|
async publishEvent(event: UnsignedEvent): Promise<void> {
|
||||||
|
if (!this._isConnected) {
|
||||||
|
throw new Error('Not connected to any relays')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
this.relays.map(relay => this.pool.publish([relay], event))
|
||||||
|
)
|
||||||
|
|
||||||
|
const failures = results.filter(result => result.status === 'rejected')
|
||||||
|
if (failures.length === results.length) {
|
||||||
|
throw new Error('Failed to publish to any relay')
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Published event ${event.id} to ${results.length - failures.length}/${results.length} relays`)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to publish event:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch replies to a specific note
|
||||||
|
*/
|
||||||
|
async fetchReplies(noteId: string, limit: number = 50): Promise<NostrNote[]> {
|
||||||
|
const filter: Filter = {
|
||||||
|
kinds: [EventKinds.TEXT_NOTE],
|
||||||
|
'#e': [noteId],
|
||||||
limit
|
limit
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get events from all relays
|
|
||||||
const events = await Promise.all(
|
const events = await Promise.all(
|
||||||
this.relays.map(async (relay) => {
|
this.relays.map(async (relay) => {
|
||||||
try {
|
try {
|
||||||
return await this.pool.querySync([relay], filter)
|
return await this.pool.querySync([relay], filter)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(`Failed to fetch from relay ${relay}:`, error)
|
console.warn(`Failed to fetch replies from relay ${relay}:`, error)
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -81,14 +209,70 @@ export class NostrClient {
|
||||||
)
|
)
|
||||||
|
|
||||||
return uniqueEvents
|
return uniqueEvents
|
||||||
.sort((a: Event, b: Event) => b.created_at - a.created_at) // Sort by newest first
|
.sort((a: Event, b: Event) => a.created_at - b.created_at) // Chronological order for replies
|
||||||
.map((event: Event): NostrNote => ({
|
.map((event: Event): NostrNote => {
|
||||||
|
const replyInfo = getReplyInfo(event)
|
||||||
|
return {
|
||||||
...event,
|
...event,
|
||||||
replyCount: 0, // We'll implement this later
|
replyCount: 0, // Individual replies don't need reply counts in this context
|
||||||
reactionCount: 0 // We'll implement this later
|
reactionCount: 0, // We could add this later
|
||||||
}))
|
reactions: {},
|
||||||
|
isReply: replyInfo.isReply,
|
||||||
|
replyTo: replyInfo.replyTo,
|
||||||
|
mentions: replyInfo.mentions
|
||||||
|
}
|
||||||
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch notes:', error)
|
console.error('Failed to fetch replies:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch user profiles
|
||||||
|
*/
|
||||||
|
async fetchProfiles(pubkeys: string[]): Promise<Map<string, any>> {
|
||||||
|
if (pubkeys.length === 0) return new Map()
|
||||||
|
|
||||||
|
const filter: Filter = {
|
||||||
|
kinds: [EventKinds.PROFILE_METADATA],
|
||||||
|
authors: pubkeys
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const events = await Promise.all(
|
||||||
|
this.relays.map(async (relay) => {
|
||||||
|
try {
|
||||||
|
return await this.pool.querySync([relay], filter)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Failed to fetch profiles from relay ${relay}:`, error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const profiles = new Map<string, any>()
|
||||||
|
const allEvents = events.flat()
|
||||||
|
|
||||||
|
// Get the latest profile for each pubkey
|
||||||
|
pubkeys.forEach(pubkey => {
|
||||||
|
const userEvents = allEvents
|
||||||
|
.filter(event => event.pubkey === pubkey)
|
||||||
|
.sort((a, b) => b.created_at - a.created_at)
|
||||||
|
|
||||||
|
if (userEvents.length > 0) {
|
||||||
|
try {
|
||||||
|
const profile = JSON.parse(userEvents[0].content)
|
||||||
|
profiles.set(pubkey, profile)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Failed to parse profile for ${pubkey}:`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return profiles
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch profiles:', error)
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -96,7 +280,7 @@ export class NostrClient {
|
||||||
// Subscribe to new notes in real-time
|
// Subscribe to new notes in real-time
|
||||||
subscribeToNotes(onNote: (note: NostrNote) => void): () => void {
|
subscribeToNotes(onNote: (note: NostrNote) => void): () => void {
|
||||||
const filters = [{
|
const filters = [{
|
||||||
kinds: [1],
|
kinds: [EventKinds.TEXT_NOTE],
|
||||||
since: Math.floor(Date.now() / 1000)
|
since: Math.floor(Date.now() / 1000)
|
||||||
}]
|
}]
|
||||||
|
|
||||||
|
|
@ -107,10 +291,15 @@ export class NostrClient {
|
||||||
filters,
|
filters,
|
||||||
{
|
{
|
||||||
onevent: (event: Event) => {
|
onevent: (event: Event) => {
|
||||||
|
const replyInfo = getReplyInfo(event)
|
||||||
onNote({
|
onNote({
|
||||||
...event,
|
...event,
|
||||||
replyCount: 0,
|
replyCount: 0,
|
||||||
reactionCount: 0
|
reactionCount: 0,
|
||||||
|
reactions: {},
|
||||||
|
isReply: replyInfo.isReply,
|
||||||
|
replyTo: replyInfo.replyTo,
|
||||||
|
mentions: replyInfo.mentions
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
156
src/lib/nostr/events.ts
Normal file
156
src/lib/nostr/events.ts
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
import { finalizeEvent, type EventTemplate, type UnsignedEvent } from 'nostr-tools'
|
||||||
|
import type { NostrIdentity } from './identity'
|
||||||
|
import { hexToBytes } from '@/lib/utils/crypto'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nostr event kinds
|
||||||
|
*/
|
||||||
|
export const EventKinds = {
|
||||||
|
TEXT_NOTE: 1,
|
||||||
|
REACTION: 7,
|
||||||
|
ZAP: 9735,
|
||||||
|
PROFILE_METADATA: 0,
|
||||||
|
CONTACT_LIST: 3,
|
||||||
|
REPOST: 6,
|
||||||
|
DELETE: 5
|
||||||
|
} as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a text note event
|
||||||
|
*/
|
||||||
|
export function createTextNote(content: string, identity: NostrIdentity, replyTo?: string): UnsignedEvent {
|
||||||
|
const eventTemplate: EventTemplate = {
|
||||||
|
kind: EventKinds.TEXT_NOTE,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags: [],
|
||||||
|
content
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add reply tags if this is a reply
|
||||||
|
if (replyTo) {
|
||||||
|
eventTemplate.tags.push(['e', replyTo, '', 'reply'])
|
||||||
|
}
|
||||||
|
|
||||||
|
return finalizeEvent(eventTemplate, hexToBytes(identity.privateKey))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a reaction event (like/dislike)
|
||||||
|
*/
|
||||||
|
export function createReaction(
|
||||||
|
targetEventId: string,
|
||||||
|
targetAuthor: string,
|
||||||
|
reaction: string,
|
||||||
|
identity: NostrIdentity
|
||||||
|
): UnsignedEvent {
|
||||||
|
const eventTemplate: EventTemplate = {
|
||||||
|
kind: EventKinds.REACTION,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags: [
|
||||||
|
['e', targetEventId],
|
||||||
|
['p', targetAuthor]
|
||||||
|
],
|
||||||
|
content: reaction
|
||||||
|
}
|
||||||
|
|
||||||
|
return finalizeEvent(eventTemplate, hexToBytes(identity.privateKey))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a profile metadata event
|
||||||
|
*/
|
||||||
|
export function createProfileMetadata(profile: Record<string, any>, identity: NostrIdentity): UnsignedEvent {
|
||||||
|
const eventTemplate: EventTemplate = {
|
||||||
|
kind: EventKinds.PROFILE_METADATA,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags: [],
|
||||||
|
content: JSON.stringify(profile)
|
||||||
|
}
|
||||||
|
|
||||||
|
return finalizeEvent(eventTemplate, hexToBytes(identity.privateKey))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a contact list event (following)
|
||||||
|
*/
|
||||||
|
export function createContactList(contacts: string[], identity: NostrIdentity): UnsignedEvent {
|
||||||
|
const eventTemplate: EventTemplate = {
|
||||||
|
kind: EventKinds.CONTACT_LIST,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags: contacts.map(pubkey => ['p', pubkey]),
|
||||||
|
content: ''
|
||||||
|
}
|
||||||
|
|
||||||
|
return finalizeEvent(eventTemplate, hexToBytes(identity.privateKey))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a delete event
|
||||||
|
*/
|
||||||
|
export function createDeleteEvent(eventIds: string[], identity: NostrIdentity, reason?: string): UnsignedEvent {
|
||||||
|
const eventTemplate: EventTemplate = {
|
||||||
|
kind: EventKinds.DELETE,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags: eventIds.map(id => ['e', id]),
|
||||||
|
content: reason || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
return finalizeEvent(eventTemplate, hexToBytes(identity.privateKey))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract reply information from event tags
|
||||||
|
*/
|
||||||
|
export function getReplyInfo(event: any): { isReply: boolean; replyTo?: string; mentions: string[] } {
|
||||||
|
const eTags = event.tags.filter((tag: string[]) => tag[0] === 'e')
|
||||||
|
const pTags = event.tags.filter((tag: string[]) => tag[0] === 'p')
|
||||||
|
|
||||||
|
// Find reply target (last e tag with "reply" marker or last e tag)
|
||||||
|
const replyTag = eTags.find((tag: string[]) => tag[3] === 'reply') || eTags[eTags.length - 1]
|
||||||
|
|
||||||
|
return {
|
||||||
|
isReply: !!replyTag,
|
||||||
|
replyTo: replyTag?.[1],
|
||||||
|
mentions: pTags.map((tag: string[]) => tag[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract reactions from events
|
||||||
|
*/
|
||||||
|
export function extractReactions(events: any[]): Map<string, { [reaction: string]: number }> {
|
||||||
|
const reactions = new Map<string, { [reaction: string]: number }>()
|
||||||
|
|
||||||
|
events
|
||||||
|
.filter(event => event.kind === EventKinds.REACTION)
|
||||||
|
.forEach(event => {
|
||||||
|
const targetEventId = event.tags.find((tag: string[]) => tag[0] === 'e')?.[1]
|
||||||
|
if (!targetEventId) return
|
||||||
|
|
||||||
|
const reaction = event.content || '👍'
|
||||||
|
const current = reactions.get(targetEventId) || {}
|
||||||
|
current[reaction] = (current[reaction] || 0) + 1
|
||||||
|
reactions.set(targetEventId, current)
|
||||||
|
})
|
||||||
|
|
||||||
|
return reactions
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract reply counts from events
|
||||||
|
*/
|
||||||
|
export function extractReplyCounts(events: any[]): Map<string, number> {
|
||||||
|
const replyCounts = new Map<string, number>()
|
||||||
|
|
||||||
|
events
|
||||||
|
.filter(event => event.kind === EventKinds.TEXT_NOTE)
|
||||||
|
.forEach(event => {
|
||||||
|
const replyInfo = getReplyInfo(event)
|
||||||
|
if (replyInfo.isReply && replyInfo.replyTo) {
|
||||||
|
const current = replyCounts.get(replyInfo.replyTo) || 0
|
||||||
|
replyCounts.set(replyInfo.replyTo, current + 1)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return replyCounts
|
||||||
|
}
|
||||||
210
src/lib/nostr/identity.ts
Normal file
210
src/lib/nostr/identity.ts
Normal file
|
|
@ -0,0 +1,210 @@
|
||||||
|
import { generateSecretKey, getPublicKey, nip19 } from 'nostr-tools'
|
||||||
|
import { SecureStorage, type EncryptedData } from '@/lib/crypto/encryption'
|
||||||
|
import { bytesToHex, hexToBytes } from '@/lib/utils/crypto'
|
||||||
|
|
||||||
|
export interface NostrIdentity {
|
||||||
|
privateKey: string
|
||||||
|
publicKey: string
|
||||||
|
npub: string
|
||||||
|
nsec: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NostrProfile {
|
||||||
|
name?: string
|
||||||
|
display_name?: string
|
||||||
|
about?: string
|
||||||
|
picture?: string
|
||||||
|
banner?: string
|
||||||
|
website?: string
|
||||||
|
nip05?: string
|
||||||
|
lud16?: string // Lightning address
|
||||||
|
}
|
||||||
|
|
||||||
|
export class IdentityManager {
|
||||||
|
private static readonly STORAGE_KEY = 'nostr_identity'
|
||||||
|
private static readonly PROFILE_KEY = 'nostr_profile'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a new Nostr identity
|
||||||
|
*/
|
||||||
|
static generateIdentity(): NostrIdentity {
|
||||||
|
const privateKey = generateSecretKey()
|
||||||
|
const publicKey = getPublicKey(privateKey)
|
||||||
|
|
||||||
|
return {
|
||||||
|
privateKey: bytesToHex(privateKey),
|
||||||
|
publicKey,
|
||||||
|
npub: nip19.npubEncode(publicKey),
|
||||||
|
nsec: nip19.nsecEncode(privateKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import identity from private key (hex or nsec format)
|
||||||
|
*/
|
||||||
|
static importIdentity(privateKeyInput: string): NostrIdentity {
|
||||||
|
let privateKeyBytes: Uint8Array
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Try to decode as nsec first
|
||||||
|
if (privateKeyInput.startsWith('nsec')) {
|
||||||
|
const decoded = nip19.decode(privateKeyInput)
|
||||||
|
if (decoded.type === 'nsec') {
|
||||||
|
privateKeyBytes = decoded.data
|
||||||
|
} else {
|
||||||
|
throw new Error('Invalid nsec format')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Try as hex string
|
||||||
|
if (privateKeyInput.length !== 64) {
|
||||||
|
throw new Error('Private key must be 64 hex characters')
|
||||||
|
}
|
||||||
|
privateKeyBytes = hexToBytes(privateKeyInput)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (privateKeyBytes.length !== 32) {
|
||||||
|
throw new Error('Private key must be 32 bytes')
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicKey = getPublicKey(privateKeyBytes)
|
||||||
|
|
||||||
|
return {
|
||||||
|
privateKey: bytesToHex(privateKeyBytes),
|
||||||
|
publicKey,
|
||||||
|
npub: nip19.npubEncode(publicKey),
|
||||||
|
nsec: nip19.nsecEncode(privateKeyBytes)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Failed to import identity: ${error instanceof Error ? error.message : 'Unknown error'}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save identity to localStorage (encrypted)
|
||||||
|
*/
|
||||||
|
static async saveIdentity(identity: NostrIdentity, password?: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
let dataToStore: any = identity
|
||||||
|
|
||||||
|
if (password) {
|
||||||
|
if (!SecureStorage.isSupported()) {
|
||||||
|
throw new Error('Secure encryption is not supported in this browser')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt sensitive data using Web Crypto API
|
||||||
|
const sensitiveData = JSON.stringify({
|
||||||
|
privateKey: identity.privateKey,
|
||||||
|
nsec: identity.nsec
|
||||||
|
})
|
||||||
|
|
||||||
|
const encryptedData = await SecureStorage.encrypt(sensitiveData, password)
|
||||||
|
|
||||||
|
dataToStore = {
|
||||||
|
publicKey: identity.publicKey,
|
||||||
|
npub: identity.npub,
|
||||||
|
encrypted: true,
|
||||||
|
encryptedData
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(dataToStore))
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Failed to save identity: ${error instanceof Error ? error.message : 'Unknown error'}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load identity from localStorage
|
||||||
|
*/
|
||||||
|
static async loadIdentity(password?: string): Promise<NostrIdentity | null> {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(this.STORAGE_KEY)
|
||||||
|
if (!stored) return null
|
||||||
|
|
||||||
|
const storedData = JSON.parse(stored)
|
||||||
|
|
||||||
|
if (storedData.encrypted && password) {
|
||||||
|
if (!SecureStorage.isSupported()) {
|
||||||
|
throw new Error('Secure encryption is not supported in this browser')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt sensitive data
|
||||||
|
const decryptedSensitiveData = await SecureStorage.decrypt(storedData.encryptedData, password)
|
||||||
|
const { privateKey, nsec } = JSON.parse(decryptedSensitiveData)
|
||||||
|
|
||||||
|
return {
|
||||||
|
privateKey,
|
||||||
|
publicKey: storedData.publicKey,
|
||||||
|
npub: storedData.npub,
|
||||||
|
nsec
|
||||||
|
}
|
||||||
|
} else if (storedData.encrypted && !password) {
|
||||||
|
throw new Error('Password required to decrypt stored identity')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-encrypted identity (legacy support)
|
||||||
|
const identity = storedData as NostrIdentity
|
||||||
|
|
||||||
|
// Validate the loaded identity
|
||||||
|
if (!identity.privateKey || !identity.publicKey) {
|
||||||
|
throw new Error('Invalid stored identity')
|
||||||
|
}
|
||||||
|
|
||||||
|
return identity
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load identity:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear stored identity
|
||||||
|
*/
|
||||||
|
static clearIdentity(): void {
|
||||||
|
localStorage.removeItem(this.STORAGE_KEY)
|
||||||
|
localStorage.removeItem(this.PROFILE_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if identity exists in storage
|
||||||
|
*/
|
||||||
|
static hasStoredIdentity(): boolean {
|
||||||
|
return !!localStorage.getItem(this.STORAGE_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if stored identity is encrypted
|
||||||
|
*/
|
||||||
|
static isStoredIdentityEncrypted(): boolean {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(this.STORAGE_KEY)
|
||||||
|
if (!stored) return false
|
||||||
|
|
||||||
|
const storedData = JSON.parse(stored)
|
||||||
|
return !!storedData.encrypted
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save user profile
|
||||||
|
*/
|
||||||
|
static saveProfile(profile: NostrProfile): void {
|
||||||
|
localStorage.setItem(this.PROFILE_KEY, JSON.stringify(profile))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load user profile
|
||||||
|
*/
|
||||||
|
static loadProfile(): NostrProfile | null {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(this.PROFILE_KEY)
|
||||||
|
return stored ? JSON.parse(stored) : null
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load profile:', error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -13,3 +13,6 @@ export function valueUpdater<T extends Updater<any>>(updaterOrValue: T, ref: Ref
|
||||||
? updaterOrValue(ref.value)
|
? updaterOrValue(ref.value)
|
||||||
: updaterOrValue
|
: updaterOrValue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Toast function using vue-sonner
|
||||||
|
export { toast } from 'vue-sonner'
|
||||||
|
|
|
||||||
42
src/lib/utils/crypto.ts
Normal file
42
src/lib/utils/crypto.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
/**
|
||||||
|
* Crypto utility functions for browser environments
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert hex string to Uint8Array
|
||||||
|
*/
|
||||||
|
export function hexToBytes(hex: string): Uint8Array {
|
||||||
|
if (hex.length % 2 !== 0) {
|
||||||
|
throw new Error('Hex string must have even length')
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = hex.match(/.{2}/g)?.map(byte => parseInt(byte, 16))
|
||||||
|
if (!bytes) {
|
||||||
|
throw new Error('Invalid hex string')
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Uint8Array(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert Uint8Array to hex string
|
||||||
|
*/
|
||||||
|
export function bytesToHex(bytes: Uint8Array): string {
|
||||||
|
return Array.from(bytes)
|
||||||
|
.map(b => b.toString(16).padStart(2, '0'))
|
||||||
|
.join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert string to Uint8Array
|
||||||
|
*/
|
||||||
|
export function stringToBytes(str: string): Uint8Array {
|
||||||
|
return new TextEncoder().encode(str)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert Uint8Array to string
|
||||||
|
*/
|
||||||
|
export function bytesToString(bytes: Uint8Array): string {
|
||||||
|
return new TextDecoder().decode(bytes)
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue