265 lines
8.6 KiB
JavaScript
265 lines
8.6 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)
|
|
}
|
|
}
|
|
|
|
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 {
|
|
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()
|