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

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