big milestone!
This commit is contained in:
parent
2b35d6f39b
commit
ac906ca6c9
28 changed files with 1332 additions and 16 deletions
325
src/stores/nostr.ts
Normal file
325
src/stores/nostr.ts
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
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) {
|
||||
const relay = window.NostrTools.relayInit(url)
|
||||
try {
|
||||
await relay.connect()
|
||||
return relay
|
||||
} catch (err) {
|
||||
console.error(`Failed to connect to ${url}:`, err)
|
||||
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 })
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
async function addMessage(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])
|
||||
}
|
||||
|
||||
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) 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],
|
||||
'#p': [SUPPORT_NPUB],
|
||||
since: 0 // Get all historical messages
|
||||
}
|
||||
|
||||
relayPool.value.forEach(relay => {
|
||||
// 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
|
||||
}
|
||||
|
||||
const decrypted = await window.NostrTools.nip04.decrypt(
|
||||
account.value!.privkey,
|
||||
SUPPORT_NPUB,
|
||||
event.content
|
||||
)
|
||||
|
||||
const dm: DirectMessage = {
|
||||
id: event.id,
|
||||
pubkey: SUPPORT_NPUB,
|
||||
content: decrypted,
|
||||
created_at: event.created_at,
|
||||
sent: true
|
||||
}
|
||||
|
||||
await addMessage(SUPPORT_NPUB, dm)
|
||||
} catch (err) {
|
||||
console.error('Failed to decrypt sent message:', err)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
account,
|
||||
profiles,
|
||||
messages,
|
||||
activeChat,
|
||||
isLoggedIn,
|
||||
currentMessages,
|
||||
init,
|
||||
login,
|
||||
logout,
|
||||
sendMessage
|
||||
}
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue