import { defineStore } from 'pinia' import { ref, computed, watch } from 'vue' import type { NostrEvent, NostrProfile, NostrAccount, DirectMessage } from '../types/nostr' import { isValidPrivateKey, formatPrivateKey } from '@/lib/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' ] // Helper functions async function withTimeout(promise: Promise, timeoutMs: number = 10000): Promise { return Promise.race([ promise, new Promise((_, reject) => setTimeout(() => reject(new Error('Operation timed out')), timeoutMs) ) ]) } // Add to state const connectionStatus = ref<'connected' | 'connecting' | 'disconnected'>('disconnected') // Update in connect function async function connectToRelay(url: string) { connectionStatus.value = 'connecting' console.log(`Attempting to connect to relay: ${url}`) const relay = window.NostrTools.relayInit(url) try { console.log(`Initializing connection to ${url}...`) await withTimeout(relay.connect()) console.log(`Successfully connected to ${url}`) connectionStatus.value = 'connected' return relay } catch (err) { console.error(`Failed to connect to ${url}:`, err) connectionStatus.value = 'disconnected' 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()) const currentSubscription = ref(null) const hasActiveSubscription = ref(false) // Load stored messages and IDs on initialization const initializeFromStorage = () => { try { const messageMap = new Map( JSON.parse(localStorage.getItem('nostr_messages') || '[]') ) messageMap.forEach((msgs: DirectMessage[]) => { msgs.forEach(msg => { processedMessageIds.value.add(msg.id) }) }) messages.value = messageMap } catch (err) { console.error('Failed to load stored messages:', err) localStorage.removeItem('nostr_messages') } } // Call initialization initializeFromStorage() // 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 }) // Watch messages for changes and persist watch(messages, (newMessages) => { try { localStorage.setItem('nostr_messages', JSON.stringify(Array.from(newMessages.entries())) ) } catch (err) { console.error('Failed to save messages:', err) } }, { 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) return try { // Only clear profiles and processed IDs profiles.value.clear() processedMessageIds.value.clear() // Connect to relays relayPool.value = (await Promise.all( account.value.relays.map(async relay => { console.log('Connecting to relay:', relay.url) const connection = await connectToRelay(relay.url) if (!connection) { console.error('Failed to connect to relay:', relay.url) } return connection }) )).filter((relay): relay is any => relay !== null) if (relayPool.value.length === 0) { throw new Error('Failed to connect to any relays') } // Setup visibility change handler setupVisibilityHandler() // Subscribe to messages in the background subscribeToMessages().catch(err => { console.error('Background subscription failed:', err) }) } catch (err) { console.error('Failed to initialize:', err) throw err } } // Actions async function login(privkey: string) { if (!isValidPrivateKey(privkey)) { throw new Error('Invalid private key') } const formattedKey = formatPrivateKey(privkey) const pubkey = window.NostrTools.getPublicKey(formattedKey) account.value = { pubkey, privkey: formattedKey, relays: DEFAULT_RELAYS.map(url => ({ url, read: true, write: true })) } // Initialize connection in the background init().catch(err => { console.error('Background initialization failed:', err) }) } async function logout() { account.value = null relayPool.value.forEach(relay => relay.close()) relayPool.value = [] messages.value.clear() profiles.value.clear() processedMessageIds.value.clear() activeChat.value = null localStorage.removeItem('nostr_messages') localStorage.removeItem('nostr_account') } 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) || [] // Check for duplicates by content and timestamp (backup check) const isDuplicate = userMessages.some(msg => msg.content === message.content && Math.abs(msg.created_at - message.created_at) < 1 ) if (!isDuplicate) { 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) } async function subscribeToMessages() { if (!account.value || hasActiveSubscription.value) return hasActiveSubscription.value = true // Cleanup existing subscription unsubscribeFromMessages() // Get timestamp from 24 hours ago const since = Math.floor(Date.now() / 1000) - (24 * 60 * 60) let hasReceivedMessages = false try { const subscribeToRelay = (relay: any) => { return new Promise((resolve) => { const subs: any[] = [] try { console.log('Setting up subscriptions for relay...') // Subscribe to received messages const receivedSub = relay.sub([{ kinds: [4], '#p': [account.value!.pubkey], since, limit: 100 // Add limit to ensure we get historical messages }]) subs.push(receivedSub) // Subscribe to sent messages const sentSub = relay.sub([{ kinds: [4], authors: [account.value!.pubkey], since, limit: 100 // Add limit to ensure we get historical messages }]) subs.push(sentSub) // Handle received messages receivedSub.on('event', async (event: NostrEvent) => { hasReceivedMessages = true try { 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) processedMessageIds.value.add(event.id) } catch (err) { console.error('Failed to decrypt received message:', err) } }) // Handle sent messages sentSub.on('event', async (event: NostrEvent) => { hasReceivedMessages = true try { if (processedMessageIds.value.has(event.id)) return 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) processedMessageIds.value.add(event.id) } catch (err) { console.error('Failed to decrypt sent message:', err) } }) // Handle EOSE (End of Stored Events) receivedSub.on('eose', () => { console.log('Received EOSE for received messages') if (!hasReceivedMessages) { console.log('No messages received yet, keeping subscription open') } }) sentSub.on('eose', () => { console.log('Received EOSE for sent messages') if (!hasReceivedMessages) { console.log('No messages received yet, keeping subscription open') } }) // Store subscriptions for cleanup currentSubscription.value = { unsub: () => { subs.forEach(sub => { try { if (sub && typeof sub.unsub === 'function') { sub.unsub() } } catch (err) { console.debug('Failed to unsubscribe:', err) } }) } } // Keep subscription open resolve(true) } catch (err) { console.debug('Error in subscription setup:', err) resolve(false) } }) } // Wait for all relays to set up subscriptions const results = await Promise.all( relayPool.value.map(relay => subscribeToRelay(relay)) ) // Consider success if at least one relay worked return results.some(result => result) } catch (err) { console.debug('Subscription process failed:', err) return false } } function unsubscribeFromMessages() { if (currentSubscription.value && typeof currentSubscription.value.unsub === 'function') { try { currentSubscription.value.unsub() } catch (err) { console.error('Failed to unsubscribe:', err) } currentSubscription.value = null } hasActiveSubscription.value = false } async function loadProfiles() { if (!account.value) return const pubkeysToLoad = new Set() // Collect all unique pubkeys from messages for (const [pubkey] of messages.value.entries()) { if (!profiles.value.has(pubkey)) { pubkeysToLoad.add(pubkey) } } if (pubkeysToLoad.size === 0) return try { const filter = { kinds: [0], authors: Array.from(pubkeysToLoad) } const loadFromRelay = (relay: any) => { return new Promise((resolve) => { 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) } }) // Resolve after receiving EOSE (End of Stored Events) sub.on('eose', () => { resolve() }) // Set a timeout in case EOSE is not received setTimeout(() => { resolve() }, 5000) }) } // Load profiles from all relays concurrently await Promise.all(relayPool.value.map(relay => loadFromRelay(relay))) } catch (err) { console.error('Failed to load profiles:', err) } } // Add a reconnection function async function reconnectToRelays() { if (!account.value) return console.log('Attempting to reconnect to relays...') // Close existing connections relayPool.value.forEach(relay => { try { relay.close() } catch (err) { console.error('Error closing relay:', err) } }) relayPool.value = [] // Reconnect relayPool.value = (await Promise.all( account.value.relays.map(async relay => { console.log('Reconnecting to relay:', relay.url) const connection = await connectToRelay(relay.url) if (!connection) { console.error('Failed to reconnect to relay:', relay.url) } return connection }) )).filter((relay): relay is any => relay !== null) if (relayPool.value.length === 0) { throw new Error('Failed to connect to any relays') } // Resubscribe to messages await subscribeToMessages() } // Update visibility handler function setupVisibilityHandler() { const handleVisibilityChange = async () => { if (document.visibilityState === 'visible' && account.value) { console.log('Page became visible, checking connection...') try { // Only reconnect if we don't have active connections if (relayPool.value.length === 0 || !hasActiveSubscription.value) { await reconnectToRelays() } } catch (err) { console.error('Failed to reconnect:', err) } } } // Remove any existing handler document.removeEventListener('visibilitychange', handleVisibilityChange) document.addEventListener('visibilitychange', handleVisibilityChange) // Add focus handler for mobile window.addEventListener('focus', handleVisibilityChange) } return { account, profiles, messages, activeChat, isLoggedIn, currentMessages, init, login, logout, sendMessage, subscribeToMessages, unsubscribeFromMessages, loadProfiles, connectionStatus, hasActiveSubscription, } })