web-app/src/stores/nostr.ts
2025-02-16 11:00:41 +01:00

372 lines
No EOL
10 KiB
TypeScript

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<string>
decrypt: (privkey: string, pubkey: string, content: string) => Promise<string>
}
getEventHash: (event: NostrEvent) => string
signEvent: (event: NostrEvent, privkey: string) => Promise<string>
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<void>
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<NostrAccount | null>(JSON.parse(localStorage.getItem('nostr_account') || 'null'))
const profiles = ref<Map<string, NostrProfile>>(new Map())
const messages = ref<Map<string, DirectMessage[]>>(new Map())
const activeChat = ref<string | null>(null)
const relayPool = ref<any[]>([])
const processedMessageIds = ref(new Set<string>())
// 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
}
})