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:
padreug 2025-07-02 16:25:20 +02:00
parent d3e8b23c86
commit ee7eb461c4
12 changed files with 1777 additions and 21 deletions

View file

@ -1,14 +1,47 @@
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'
import { onMounted, onUnmounted, ref } from 'vue'
import Navbar from '@/components/layout/Navbar.vue'
import Footer from '@/components/layout/Footer.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 { identity } from '@/composables/useIdentity'
import { toast } from 'vue-sonner'
const relays = JSON.parse(import.meta.env.VITE_NOSTR_RELAYS as string)
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 () => {
// 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()
})
@ -39,5 +72,17 @@ onUnmounted(() => {
<Footer />
</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>
</template>

View file

@ -3,8 +3,12 @@ import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useTheme } from '@/components/theme-provider'
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 IdentityDialog from '@/components/nostr/IdentityDialog.vue'
import { identity } from '@/composables/useIdentity'
interface NavigationItem {
name: string
@ -14,6 +18,7 @@ interface NavigationItem {
const { t } = useI18n()
const { theme, setTheme } = useTheme()
const isOpen = ref(false)
const showIdentityDialog = ref(false)
const navigation = computed<NavigationItem[]>(() => [
{ name: t('nav.home'), href: '/' },
@ -28,6 +33,10 @@ const toggleMenu = () => {
const toggleTheme = () => {
setTheme(theme.value === 'dark' ? 'light' : 'dark')
}
const openIdentityDialog = () => {
showIdentityDialog.value = true
}
</script>
<template>
@ -51,8 +60,41 @@ const toggleTheme = () => {
</div>
</div>
<!-- Theme Toggle and Language -->
<!-- Theme Toggle, Language, and Identity -->
<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 -->
<Button variant="ghost" size="icon" @click="toggleTheme"
class="hidden sm:inline-flex text-foreground hover:text-foreground/90 hover:bg-accent">
@ -79,8 +121,33 @@ const toggleTheme = () => {
<div v-show="isOpen"
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">
<!-- 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 -->
<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"
class="text-foreground hover:text-foreground/90 hover:bg-accent">
<Sun v-if="theme === 'dark'" class="h-5 w-5" />
@ -98,5 +165,8 @@ const toggleTheme = () => {
</router-link>
</div>
</div>
<!-- Identity Dialog -->
<IdentityDialog v-model:is-open="showIdentityDialog" />
</nav>
</template>

View 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>

View 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>

View 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()

View 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)

View 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'
}
}

View file

@ -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 {
relays: string[]
@ -6,8 +7,12 @@ export interface NostrClientConfig {
export interface NostrNote extends Event {
// Add any additional note-specific fields we want to track
replyCount?: number
reactionCount?: number
replyCount: number
reactionCount: number
reactions: { [reaction: string]: number }
isReply: boolean
replyTo?: string
mentions: string[]
}
export class NostrClient {
@ -51,23 +56,146 @@ export class NostrClient {
async fetchNotes(options: {
limit?: number
since?: number // Unix timestamp in seconds
authors?: string[]
includeReplies?: boolean
} = {}): 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 filters: Filter[] = [
{
kinds: [EventKinds.TEXT_NOTE],
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: [1], // Regular notes
since,
kinds: [EventKinds.TEXT_NOTE],
'#e': [noteId],
limit
}
try {
// Get events from all relays
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 from relay ${relay}:`, error)
console.warn(`Failed to fetch replies from relay ${relay}:`, error)
return []
}
})
@ -81,14 +209,70 @@ export class NostrClient {
)
return uniqueEvents
.sort((a: Event, b: Event) => b.created_at - a.created_at) // Sort by newest first
.map((event: Event): NostrNote => ({
...event,
replyCount: 0, // We'll implement this later
reactionCount: 0 // We'll implement this later
}))
.sort((a: Event, b: Event) => a.created_at - b.created_at) // Chronological order for replies
.map((event: Event): NostrNote => {
const replyInfo = getReplyInfo(event)
return {
...event,
replyCount: 0, // Individual replies don't need reply counts in this context
reactionCount: 0, // We could add this later
reactions: {},
isReply: replyInfo.isReply,
replyTo: replyInfo.replyTo,
mentions: replyInfo.mentions
}
})
} 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
}
}
@ -96,7 +280,7 @@ export class NostrClient {
// Subscribe to new notes in real-time
subscribeToNotes(onNote: (note: NostrNote) => void): () => void {
const filters = [{
kinds: [1],
kinds: [EventKinds.TEXT_NOTE],
since: Math.floor(Date.now() / 1000)
}]
@ -107,10 +291,15 @@ export class NostrClient {
filters,
{
onevent: (event: Event) => {
const replyInfo = getReplyInfo(event)
onNote({
...event,
replyCount: 0,
reactionCount: 0
reactionCount: 0,
reactions: {},
isReply: replyInfo.isReply,
replyTo: replyInfo.replyTo,
mentions: replyInfo.mentions
})
}
}

156
src/lib/nostr/events.ts Normal file
View 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
View 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
}
}
}

View file

@ -13,3 +13,6 @@ export function valueUpdater<T extends Updater<any>>(updaterOrValue: T, ref: Ref
? updaterOrValue(ref.value)
: updaterOrValue
}
// Toast function using vue-sonner
export { toast } from 'vue-sonner'

42
src/lib/utils/crypto.ts Normal file
View 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)
}