218 lines
6.5 KiB
JavaScript
218 lines
6.5 KiB
JavaScript
/**
|
|
* Chat Service - Handles message storage and retrieval
|
|
* Uses LocalStorage as mock backend until Directus is ready
|
|
*/
|
|
|
|
import { cryptoService } from './crypto.js'
|
|
|
|
const CHATS_STORAGE_KEY = 'dgray_chats'
|
|
const MESSAGES_STORAGE_KEY = 'dgray_messages'
|
|
|
|
class ChatService {
|
|
constructor() {
|
|
this.subscribers = new Set()
|
|
}
|
|
|
|
/**
|
|
* Get or create a chat between current user and another user
|
|
* @param {string} recipientId - The other user's ID
|
|
* @param {string} recipientPublicKey - The other user's public key
|
|
* @param {string} listingId - The listing this chat is about
|
|
* @returns {object} - Chat object
|
|
*/
|
|
getOrCreateChat(recipientId, recipientPublicKey, listingId) {
|
|
const chats = this.getAllChats()
|
|
const myPublicKey = cryptoService.getPublicKey()
|
|
|
|
// Find existing chat for this listing + recipient
|
|
let chat = chats.find(c =>
|
|
c.listingId === listingId &&
|
|
c.recipientId === recipientId
|
|
)
|
|
|
|
if (!chat) {
|
|
chat = {
|
|
id: this.generateId(),
|
|
listingId,
|
|
recipientId,
|
|
recipientPublicKey,
|
|
myPublicKey,
|
|
createdAt: new Date().toISOString(),
|
|
lastMessageAt: null
|
|
}
|
|
chats.push(chat)
|
|
this.saveChats(chats)
|
|
}
|
|
|
|
return chat
|
|
}
|
|
|
|
getAllChats() {
|
|
try {
|
|
return JSON.parse(localStorage.getItem(CHATS_STORAGE_KEY) || '[]')
|
|
} catch {
|
|
return []
|
|
}
|
|
}
|
|
|
|
saveChats(chats) {
|
|
localStorage.setItem(CHATS_STORAGE_KEY, JSON.stringify(chats))
|
|
}
|
|
|
|
/**
|
|
* Send an encrypted message
|
|
* @param {string} chatId - Chat ID
|
|
* @param {string} recipientPublicKey - Recipient's public key
|
|
* @param {string} plainText - Message content
|
|
* @returns {object} - The saved message
|
|
*/
|
|
async sendMessage(chatId, recipientPublicKey, plainText) {
|
|
await cryptoService.ready
|
|
|
|
const { nonce, ciphertext } = cryptoService.encrypt(plainText, recipientPublicKey)
|
|
|
|
const message = {
|
|
id: this.generateId(),
|
|
chatId,
|
|
senderPublicKey: cryptoService.getPublicKey(),
|
|
nonce,
|
|
ciphertext,
|
|
timestamp: new Date().toISOString(),
|
|
// Store plain text for own messages (we can't decrypt our own box messages)
|
|
_plainText: plainText
|
|
}
|
|
|
|
const messages = this.getAllMessages()
|
|
messages.push(message)
|
|
this.saveMessages(messages)
|
|
|
|
// Update chat's lastMessageAt
|
|
const chats = this.getAllChats()
|
|
const chat = chats.find(c => c.id === chatId)
|
|
if (chat) {
|
|
chat.lastMessageAt = message.timestamp
|
|
this.saveChats(chats)
|
|
}
|
|
|
|
this.notifySubscribers()
|
|
|
|
return message
|
|
}
|
|
|
|
/**
|
|
* Get all messages for a chat, decrypted
|
|
* @param {string} chatId - Chat ID
|
|
* @param {string} otherPublicKey - The other party's public key
|
|
* @returns {Array} - Decrypted messages
|
|
*/
|
|
async getMessages(chatId, otherPublicKey) {
|
|
await cryptoService.ready
|
|
|
|
const messages = this.getAllMessages().filter(m => m.chatId === chatId)
|
|
const myPublicKey = cryptoService.getPublicKey()
|
|
|
|
return messages.map(msg => {
|
|
const isOwn = msg.senderPublicKey === myPublicKey
|
|
|
|
let text
|
|
if (isOwn) {
|
|
// Use stored plain text for own messages
|
|
text = msg._plainText || '[Encrypted]'
|
|
} else {
|
|
// Decrypt messages from others
|
|
text = cryptoService.decrypt(msg.ciphertext, msg.nonce, msg.senderPublicKey)
|
|
if (!text) text = '[Decryption failed]'
|
|
}
|
|
|
|
return {
|
|
id: msg.id,
|
|
text,
|
|
isOwn,
|
|
timestamp: msg.timestamp
|
|
}
|
|
})
|
|
}
|
|
|
|
getAllMessages() {
|
|
try {
|
|
return JSON.parse(localStorage.getItem(MESSAGES_STORAGE_KEY) || '[]')
|
|
} catch {
|
|
return []
|
|
}
|
|
}
|
|
|
|
saveMessages(messages) {
|
|
localStorage.setItem(MESSAGES_STORAGE_KEY, JSON.stringify(messages))
|
|
}
|
|
|
|
generateId() {
|
|
return 'msg_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9)
|
|
}
|
|
|
|
subscribe(callback) {
|
|
this.subscribers.add(callback)
|
|
return () => this.subscribers.delete(callback)
|
|
}
|
|
|
|
notifySubscribers() {
|
|
this.subscribers.forEach(cb => cb())
|
|
}
|
|
|
|
/**
|
|
* Simulate receiving a message (for demo purposes)
|
|
* In production, this would come from Directus/WebSocket
|
|
*/
|
|
async simulateIncomingMessage(chatId, senderPublicKey, plainText) {
|
|
await cryptoService.ready
|
|
|
|
const myPublicKey = cryptoService.getPublicKey()
|
|
const { nonce, ciphertext } = this.encryptForRecipient(plainText, myPublicKey, senderPublicKey)
|
|
|
|
const message = {
|
|
id: this.generateId(),
|
|
chatId,
|
|
senderPublicKey,
|
|
nonce,
|
|
ciphertext,
|
|
timestamp: new Date().toISOString()
|
|
}
|
|
|
|
const messages = this.getAllMessages()
|
|
messages.push(message)
|
|
this.saveMessages(messages)
|
|
|
|
this.notifySubscribers()
|
|
|
|
return message
|
|
}
|
|
|
|
// Helper for simulation - encrypt as if from another user
|
|
encryptForRecipient(message, recipientPublicKey, senderSecretKey) {
|
|
// This is a simplified simulation - in reality the sender would have their own keypair
|
|
const nacl = window.nacl
|
|
const naclUtil = window.nacl.util
|
|
|
|
const nonce = nacl.randomBytes(nacl.box.nonceLength)
|
|
const messageUint8 = naclUtil.decodeUTF8(message)
|
|
|
|
// For simulation, we create a temporary keypair for the "sender"
|
|
const senderKeyPair = nacl.box.keyPair.fromSecretKey(
|
|
naclUtil.decodeBase64(senderSecretKey || naclUtil.encodeBase64(nacl.randomBytes(32)))
|
|
)
|
|
|
|
const encrypted = nacl.box(
|
|
messageUint8,
|
|
nonce,
|
|
naclUtil.decodeBase64(recipientPublicKey),
|
|
senderKeyPair.secretKey
|
|
)
|
|
|
|
return {
|
|
nonce: naclUtil.encodeBase64(nonce),
|
|
ciphertext: naclUtil.encodeBase64(encrypted)
|
|
}
|
|
}
|
|
}
|
|
|
|
export const chatService = new ChatService()
|