372 lines
No EOL
10 KiB
TypeScript
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
|
|
}
|
|
})
|