import { defineStore } from 'pinia' import { ref, computed, watch } from 'vue' import type { NostrEvent, NostrProfile, NostrAccount, DirectMessage } from '../types/nostr' declare global { interface Window { NostrTools: { getPublicKey: (privkey: string) => string generatePrivateKey: () => string nip04: { encrypt: (privkey: string, pubkey: string, content: string) => Promise decrypt: (privkey: string, pubkey: string, content: string) => Promise } getEventHash: (event: NostrEvent) => string signEvent: (event: NostrEvent, privkey: string) => Promise getSignature: (event: NostrEvent, privkey: string) => string verifySignature: (event: NostrEvent) => boolean nip19: { decode: (str: string) => { type: string; data: string } npubEncode: (hex: string) => string } relayInit: (url: string) => { connect: () => Promise sub: (filters: any[]) => { on: (event: string, callback: (event: NostrEvent) => void) => void } publish: (event: NostrEvent) => { on: (type: 'ok' | 'failed', cb: (msg?: string) => void) => void } close: () => void } } } } const DEFAULT_RELAYS = [ 'wss://nostr.atitlan.io' ] // Get support agent's public key from environment variable const SUPPORT_NPUB = import.meta.env.VITE_SUPPORT_NPUB // Helper functions async function connectToRelay(url: string) { console.log(`Attempting to connect to relay: ${url}`) const relay = window.NostrTools.relayInit(url) try { console.log(`Initializing connection to ${url}...`) await relay.connect() console.log(`Successfully connected to ${url}`) return relay } catch (err) { console.error(`Failed to connect to ${url}:`, err) if (err instanceof Error) { console.error('Error details:', { message: err.message, name: err.name, stack: err.stack }) } return null } } async function publishEvent(event: NostrEvent, relays: { url: string }[]) { const promises = relays.map(async ({ url }) => { const relay = window.NostrTools.relayInit(url) try { await relay.connect() const pub = relay.publish(event) return new Promise((resolve, reject) => { pub.on('ok', () => resolve(true)) pub.on('failed', reject) }) } catch (err) { console.error(`Failed to publish to ${url}:`, err) return false } }) await Promise.all(promises) } export const useNostrStore = defineStore('nostr', () => { // State const account = ref(JSON.parse(localStorage.getItem('nostr_account') || 'null')) const profiles = ref>(new Map()) const messages = ref>(new Map()) const activeChat = ref(null) const relayPool = ref([]) const processedMessageIds = ref(new Set()) // Watch account changes and persist to localStorage watch(account, (newAccount) => { if (newAccount) { localStorage.setItem('nostr_account', JSON.stringify(newAccount)) } else { localStorage.removeItem('nostr_account') } }, { deep: true }) // Initialize store if account exists in localStorage if (account.value) { console.log('Found existing account, initializing connection...') init() } // Computed const isLoggedIn = computed(() => !!account.value) const currentMessages = computed(() => activeChat.value ? messages.value.get(activeChat.value) || [] : [] ) // Initialize connection if account exists async function init() { if (account.value) { // Clear existing state messages.value.clear() profiles.value.clear() processedMessageIds.value.clear() // Close existing connections relayPool.value.forEach(relay => relay.close()) relayPool.value = [] // Connect to relays const connectedRelays = await Promise.all( account.value.relays.map(relay => connectToRelay(relay.url)) ) relayPool.value = connectedRelays.filter(relay => relay !== null) // Subscribe to messages and load history await Promise.all([ subscribeToMessages(), loadProfiles() ]) // Set active chat to support agent activeChat.value = SUPPORT_NPUB } } // Actions async function login(privkey: string) { const pubkey = window.NostrTools.getPublicKey(privkey) account.value = { pubkey, privkey, relays: DEFAULT_RELAYS.map(url => ({ url, read: true, write: true })) } // Initialize connection and load messages await init() } async function loadProfiles() { if (!account.value) return const filter = { kinds: [0], authors: Array.from(messages.value.keys()) } if (filter.authors.length === 0) return relayPool.value.forEach(relay => { const sub = relay.sub([filter]) sub.on('event', (event: NostrEvent) => { try { const profile = JSON.parse(event.content) profiles.value.set(event.pubkey, { pubkey: event.pubkey, name: profile.name, picture: profile.picture, about: profile.about, nip05: profile.nip05 }) } catch (err) { console.error('Failed to parse profile:', err) } }) }) } async function logout() { account.value = null relayPool.value.forEach(relay => relay.close()) relayPool.value = [] messages.value.clear() profiles.value.clear() activeChat.value = null } const addMessage = async (pubkey: string, message: DirectMessage) => { // Skip if we've already processed this message if (processedMessageIds.value.has(message.id)) { return } // Add message ID to processed set processedMessageIds.value.add(message.id) // Add message to the map const userMessages = messages.value.get(pubkey) || [] messages.value.set(pubkey, [...userMessages, message]) // Sort messages by timestamp const sortedMessages = messages.value.get(pubkey) || [] sortedMessages.sort((a, b) => a.created_at - b.created_at) messages.value.set(pubkey, sortedMessages) } async function sendMessage(to: string, content: string) { if (!account.value) return const encrypted = await window.NostrTools.nip04.encrypt(account.value.privkey, to, content) const event: NostrEvent = { kind: 4, pubkey: account.value.pubkey, created_at: Math.floor(Date.now() / 1000), tags: [['p', to]], content: encrypted, id: '', sig: '' } event.id = window.NostrTools.getEventHash(event) event.sig = await window.NostrTools.signEvent(event, account.value.privkey) // Add to local messages first const dm: DirectMessage = { id: event.id, pubkey: to, content, created_at: event.created_at, sent: true } await addMessage(to, dm) // Then publish to relays await publishEvent(event, account.value.relays) } const subscribeToMessages = async () => { if (!account.value) return // Filter for received messages with history const receivedFilter = { kinds: [4], '#p': [account.value.pubkey], since: 0 // Get all historical messages } // Filter for sent messages with history const sentFilter = { kinds: [4], authors: [account.value.pubkey], since: 0 // Get all historical messages } const subscribeToRelay = (relay: any) => { return new Promise((resolve) => { let eoseCount = 0 // Subscribe to received messages const receivedSub = relay.sub([receivedFilter]) receivedSub.on('event', async (event: NostrEvent) => { try { // Skip if we've already processed this message if (processedMessageIds.value.has(event.id)) { return } const decrypted = await window.NostrTools.nip04.decrypt( account.value!.privkey, event.pubkey, event.content ) const dm: DirectMessage = { id: event.id, pubkey: event.pubkey, content: decrypted, created_at: event.created_at, sent: false } await addMessage(event.pubkey, dm) // Load profile if not already loaded if (!profiles.value.has(event.pubkey)) { await loadProfiles() } } catch (err) { console.error('Failed to decrypt received message:', err) } }) // Subscribe to sent messages const sentSub = relay.sub([sentFilter]) sentSub.on('event', async (event: NostrEvent) => { try { // Skip if we've already processed this message if (processedMessageIds.value.has(event.id)) { return } // Find the target pubkey from the p tag const targetPubkey = event.tags.find(tag => tag[0] === 'p')?.[1] if (!targetPubkey) return const decrypted = await window.NostrTools.nip04.decrypt( account.value!.privkey, targetPubkey, event.content ) const dm: DirectMessage = { id: event.id, pubkey: targetPubkey, content: decrypted, created_at: event.created_at, sent: true } await addMessage(targetPubkey, dm) } catch (err) { console.error('Failed to decrypt sent message:', err) } }) // Listen for end of stored events receivedSub.on('eose', () => { eoseCount++ if (eoseCount >= 2) { // Both subscriptions have finished resolve(true) } }) sentSub.on('eose', () => { eoseCount++ if (eoseCount >= 2) { // Both subscriptions have finished resolve(true) } }) }) } // Wait for all relays to load their historical messages await Promise.all(relayPool.value.map(relay => subscribeToRelay(relay))) } return { account, profiles, messages, activeChat, isLoggedIn, currentMessages, init, login, logout, sendMessage, subscribeToMessages } })