+
+
+
+
+
+
+ {{ identity.profileDisplay.value?.name || 'Anonymous' }}
+ Connected
+
+
+
+
+
+
+
+
-
+
+
+
+
diff --git a/src/components/nostr/IdentityDialog.vue b/src/components/nostr/IdentityDialog.vue
new file mode 100644
index 0000000..ad6b7eb
--- /dev/null
+++ b/src/components/nostr/IdentityDialog.vue
@@ -0,0 +1,453 @@
+
+
+
+
+
diff --git a/src/components/nostr/PasswordDialog.vue b/src/components/nostr/PasswordDialog.vue
new file mode 100644
index 0000000..ff0f25e
--- /dev/null
+++ b/src/components/nostr/PasswordDialog.vue
@@ -0,0 +1,105 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/src/composables/useIdentity.ts b/src/composables/useIdentity.ts
new file mode 100644
index 0000000..ecc3e69
--- /dev/null
+++ b/src/composables/useIdentity.ts
@@ -0,0 +1,193 @@
+import { ref, computed } from 'vue'
+import { IdentityManager, type NostrIdentity, type NostrProfile } from '@/lib/nostr/identity'
+
+const currentIdentity = ref
(null)
+const currentProfile = ref(null)
+const isAuthenticated = computed(() => !!currentIdentity.value)
+
+export function useIdentity() {
+ const isLoading = ref(false)
+ const error = ref(null)
+
+ /**
+ * Initialize identity on app start
+ */
+ async function initialize(password?: string): Promise {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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()
\ No newline at end of file
diff --git a/src/composables/useSocial.ts b/src/composables/useSocial.ts
new file mode 100644
index 0000000..620f864
--- /dev/null
+++ b/src/composables/useSocial.ts
@@ -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())
+
+ /**
+ * Publish a text note
+ */
+ async function publishNote(content: string, replyTo?: string): Promise {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ // 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)
\ No newline at end of file
diff --git a/src/lib/crypto/encryption.ts b/src/lib/crypto/encryption.ts
new file mode 100644
index 0000000..6a7630f
--- /dev/null
+++ b/src/lib/crypto/encryption.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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'
+ }
+}
\ No newline at end of file
diff --git a/src/lib/nostr/client.ts b/src/lib/nostr/client.ts
index 7f30922..2749c90 100644
--- a/src/lib/nostr/client.ts
+++ b/src/lib/nostr/client.ts
@@ -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 {
- 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 {
+ 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 {
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