/** * Conversations Service - Zero-Knowledge Chat Implementation * Handles encrypted messaging between users using participant hashes */ import { directus } from './directus.js' import { cryptoService } from './crypto.js' import { auth } from './auth.js' class ConversationsService { constructor() { this.pollingInterval = null this.subscribers = new Set() } async getParticipantHash() { await cryptoService.ready const publicKey = cryptoService.getPublicKey() return this.hashPublicKey(publicKey) } async hashPublicKey(publicKey) { const encoder = new TextEncoder() const data = encoder.encode(publicKey) const hash = await crypto.subtle.digest('SHA-256', data) return Array.from(new Uint8Array(hash)) .map(b => b.toString(16).padStart(2, '0')) .join('') } async getMyConversations() { const participantHash = await this.getParticipantHash() const conversations = await directus.getConversations(participantHash) const listingKeyHashes = await this.getMyListingKeyHashes() const allHashes = new Set([participantHash, ...listingKeyHashes]) const sellerConvs = await this.getSellerConversations(listingKeyHashes) const allConvIds = new Set(conversations.map(c => c.id)) for (const conv of sellerConvs) { if (!allConvIds.has(conv.id)) { conversations.push(conv) allConvIds.add(conv.id) } } conversations.sort((a, b) => { const da = new Date(b.date_updated || b.date_created) const db = new Date(a.date_updated || a.date_created) return da - db }) return conversations.map(conv => { const isP1 = allHashes.has(conv.participant_hash_1) return { ...conv, isParticipant1: isP1, otherParticipantHash: isP1 ? conv.participant_hash_2 : conv.participant_hash_1, otherPublicKey: isP1 ? conv.public_key_2 : conv.public_key_1 } }) } async getMyListingKeyHashes() { if (this._listingKeyHashesCache) return this._listingKeyHashesCache const store = await cryptoService.getListingKeysStore() const hashes = [] for (const [, secretKeyB64] of Object.entries(store)) { const sk = cryptoService.naclUtil.decodeBase64(secretKeyB64) const kp = cryptoService.nacl.box.keyPair.fromSecretKey(sk) const pubB64 = cryptoService.naclUtil.encodeBase64(kp.publicKey) const hash = await this.hashPublicKey(pubB64) hashes.push(hash) } this._listingKeyHashesCache = hashes setTimeout(() => { this._listingKeyHashesCache = null }, 30000) return hashes } async getSellerConversations(listingKeyHashes) { if (listingKeyHashes.length === 0) return [] const response = await directus.get('/items/conversations', { fields: [ '*', 'listing_id.id', 'listing_id.title', 'listing_id.status', 'listing_id.images.directus_files_id.id' ], filter: { _or: listingKeyHashes.map(h => ({ participant_hash_2: { _eq: h } })) }, sort: ['-date_updated'] }) return response.data || [] } async getConversation(id) { const conversation = await directus.getConversation(id) if (!conversation) return null const participantHash = await this.getParticipantHash() const listingKeyHashes = await this.getMyListingKeyHashes() const allHashes = new Set([participantHash, ...listingKeyHashes]) const isP1 = allHashes.has(conversation.participant_hash_1) return { ...conversation, isParticipant1: isP1, otherParticipantHash: isP1 ? conversation.participant_hash_2 : conversation.participant_hash_1, otherPublicKey: isP1 ? conversation.public_key_2 : conversation.public_key_1, myPublicKey: isP1 ? conversation.public_key_1 : conversation.public_key_2 } } async getMessages(conversationId, otherPublicKey, mySecretKey) { await cryptoService.ready if (!otherPublicKey) return [] const messages = await directus.getConversationMessages(conversationId) const myHash = await this.getParticipantHash() const listingKeyHashes = await this.getMyListingKeyHashes() const allMyHashes = new Set([myHash, ...listingKeyHashes]) return messages.map(msg => { const isOwn = allMyHashes.has(msg.sender_hash) let text = '[Encrypted]' try { const decrypted = cryptoService.decrypt(msg.content_encrypted, msg.nonce, otherPublicKey, mySecretKey) text = decrypted || '[Decryption failed]' } catch (e) { text = '[Decryption failed]' } return { id: msg.id, text, isOwn, type: msg.type, timestamp: msg.date_created } }) } async sendMessage(conversationId, otherPublicKey, plainText, type = 'text', mySecretKey) { await cryptoService.ready if (!otherPublicKey) throw new Error('Cannot send: recipient key missing') const { nonce, ciphertext } = cryptoService.encrypt(plainText, otherPublicKey, mySecretKey) let senderHash if (mySecretKey) { const sk = cryptoService.naclUtil.decodeBase64(mySecretKey) const kp = cryptoService.nacl.box.keyPair.fromSecretKey(sk) const pubB64 = cryptoService.naclUtil.encodeBase64(kp.publicKey) senderHash = await this.hashPublicKey(pubB64) } else { senderHash = await this.getParticipantHash() } const message = await directus.sendMessage( conversationId, senderHash, ciphertext, nonce, type ) this.notifySubscribers() return { id: message.id, text: plainText, isOwn: true, type: message.type, timestamp: message.date_created } } async startOrGetConversation(listingId, sellerPublicKey) { await cryptoService.ready const myPublicKey = cryptoService.getPublicKey() const myHash = await this.getParticipantHash() const sellerHash = await this.hashPublicKey(sellerPublicKey) const existing = await directus.findConversation(listingId, myHash, sellerHash) if (existing) { return this.getConversation(existing.id) } const user = await auth.getUser() const newConv = await directus.startConversation( listingId, myHash, sellerHash, myPublicKey, sellerPublicKey, user?.id ) return this.getConversation(newConv.id) } async startOrFindByListing(listingId, sellerPublicKey) { return this.startOrGetConversation(listingId, sellerPublicKey) } async getMySecretKeyForConversation(conversation) { if (!conversation?.listing_id) return null const listingId = typeof conversation.listing_id === 'object' ? conversation.listing_id.id : conversation.listing_id const listingKey = await cryptoService.getListingSecretKey(listingId) return listingKey } async closeConversation(id) { return directus.updateConversationStatus(id, 'closed') } startPolling(intervalMs = 10000) { if (this.pollingInterval) return this.pollingInterval = setInterval(async () => { try { await this.getMyConversations() this.notifySubscribers() } catch (e) { console.warn('Conversation polling failed:', e) } }, intervalMs) } stopPolling() { if (this.pollingInterval) { clearInterval(this.pollingInterval) this.pollingInterval = null } } subscribe(callback) { this.subscribers.add(callback) return () => this.subscribers.delete(callback) } notifySubscribers() { this.subscribers.forEach(cb => cb()) } } export const conversationsService = new ConversationsService()