Files
kashilo/js/services/conversations.js

259 lines
8.4 KiB
JavaScript

/**
* 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)
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)
}
}
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 {
text = cryptoService.decrypt(msg.content_encrypted, msg.nonce, otherPublicKey, mySecretKey)
} 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 newConv = await directus.startConversation(
listingId,
myHash,
sellerHash,
myPublicKey,
sellerPublicKey
)
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()