/** * Conversations Service - Zero-Knowledge Chat Implementation * Handles encrypted messaging between users using participant hashes */ import { directus } from './directus.js' import { cryptoService } from './crypto.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) return conversations.map(conv => ({ ...conv, isParticipant1: conv.participant_hash_1 === participantHash, otherParticipantHash: conv.participant_hash_1 === participantHash ? conv.participant_hash_2 : conv.participant_hash_1, otherPublicKey: conv.participant_hash_1 === participantHash ? conv.public_key_2 : conv.public_key_1 })) } async getConversation(id) { const conversation = await directus.getConversation(id) if (!conversation) return null const participantHash = await this.getParticipantHash() return { ...conversation, isParticipant1: conversation.participant_hash_1 === participantHash, otherParticipantHash: conversation.participant_hash_1 === participantHash ? conversation.participant_hash_2 : conversation.participant_hash_1, otherPublicKey: conversation.participant_hash_1 === participantHash ? conversation.public_key_2 : conversation.public_key_1, myPublicKey: conversation.participant_hash_1 === participantHash ? conversation.public_key_1 : conversation.public_key_2 } } async getMessages(conversationId, otherPublicKey) { await cryptoService.ready const messages = await directus.getConversationMessages(conversationId) const myHash = await this.getParticipantHash() return messages.map(msg => { const isOwn = msg.sender_hash === myHash let text = '[Encrypted]' try { text = cryptoService.decrypt(msg.content_encrypted, msg.nonce, otherPublicKey) } 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') { await cryptoService.ready const { nonce, ciphertext } = cryptoService.encrypt(plainText, otherPublicKey) const 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 newConv = await directus.startConversation( listingId, myHash, sellerHash, myPublicKey, sellerPublicKey ) return this.getConversation(newConv.id) } 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()