💬
@@ -139,24 +131,10 @@ class PageMessages extends HTMLElement {
`
}
- let html = ''
-
- if (this.pendingConversations.length > 0) {
- html += /* html */`
${t('messages.pendingRequests')}
`
- html += this.pendingConversations.map(conv => this.renderConversationItem(conv, true)).join('')
- }
-
- if (this.conversations.length > 0) {
- if (this.pendingConversations.length > 0) {
- html += /* html */`
${t('messages.activeChats')}
`
- }
- html += this.conversations.map(conv => this.renderConversationItem(conv, false)).join('')
- }
-
- return html
+ return this.conversations.map(conv => this.renderConversationItem(conv)).join('')
}
- renderConversationItem(conv, isPending) {
+ renderConversationItem(conv) {
const listing = typeof conv.listing_id === 'object' ? conv.listing_id : null
const listingId = listing?.id || conv.listing_id
const imageId = listing?.images?.[0]?.directus_files_id?.id
@@ -165,7 +143,7 @@ class PageMessages extends HTMLElement {
const dateStr = this.formatDate(conv.date_updated || conv.date_created)
return /* html */`
-
+
${imageUrl
? `

`
@@ -175,9 +153,7 @@ class PageMessages extends HTMLElement {
${escapeHTML(title)}
${dateStr}
- ${isPending
- ? `${t('messages.new')}`
- : `→
`}
+ →
`
}
diff --git a/js/services/conversations.js b/js/services/conversations.js
index a0e9598..c7981cf 100644
--- a/js/services/conversations.js
+++ b/js/services/conversations.js
@@ -30,17 +30,69 @@ class ConversationsService {
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 => ({
- ...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
- }))
+ 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) {
@@ -48,36 +100,41 @@ class ConversationsService {
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: conversation.participant_hash_1 === participantHash,
- otherParticipantHash: conversation.participant_hash_1 === participantHash
+ isParticipant1: isP1,
+ otherParticipantHash: isP1
? conversation.participant_hash_2
: conversation.participant_hash_1,
- otherPublicKey: conversation.participant_hash_1 === participantHash
+ otherPublicKey: isP1
? conversation.public_key_2
: conversation.public_key_1,
- myPublicKey: conversation.participant_hash_1 === participantHash
+ myPublicKey: isP1
? conversation.public_key_1
: conversation.public_key_2
}
}
- async getMessages(conversationId, otherPublicKey) {
+ 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 = msg.sender_hash === myHash
+ const isOwn = allMyHashes.has(msg.sender_hash)
let text = '[Encrypted]'
try {
- text = cryptoService.decrypt(msg.content_encrypted, msg.nonce, otherPublicKey)
+ text = cryptoService.decrypt(msg.content_encrypted, msg.nonce, otherPublicKey, mySecretKey)
} catch (e) {
text = '[Decryption failed]'
}
@@ -92,13 +149,22 @@ class ConversationsService {
})
}
- async sendMessage(conversationId, otherPublicKey, plainText, type = 'text') {
+ async sendMessage(conversationId, otherPublicKey, plainText, type = 'text', mySecretKey) {
await cryptoService.ready
- if (!otherPublicKey) throw new Error('Cannot send: other party has not joined yet')
+ if (!otherPublicKey) throw new Error('Cannot send: recipient key missing')
- const { nonce, ciphertext } = cryptoService.encrypt(plainText, otherPublicKey)
- const senderHash = await this.getParticipantHash()
+ 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,
@@ -142,91 +208,17 @@ class ConversationsService {
return this.getConversation(newConv.id)
}
- async startOrFindByListing(listingId) {
- await cryptoService.ready
-
- const myHash = await this.getParticipantHash()
- const myPublicKey = cryptoService.getPublicKey()
-
- const response = await directus.get('/items/conversations', {
- filter: {
- listing_id: { _eq: listingId },
- _or: [
- { participant_hash_1: { _eq: myHash } },
- { participant_hash_2: { _eq: myHash } }
- ]
- },
- limit: 1
- })
-
- const existing = response.data?.[0]
- if (existing) {
- return this.getConversation(existing.id)
- }
-
- const pendingResponse = await directus.get('/items/conversations', {
- filter: {
- listing_id: { _eq: listingId },
- status: { _eq: 'pending' },
- participant_hash_2: { _null: true },
- participant_hash_1: { _neq: myHash }
- },
- limit: 1
- })
-
- const pending = pendingResponse.data?.[0]
- if (pending) {
- return this.joinConversation(pending.id)
- }
-
- const newConv = await directus.post('/items/conversations', {
- listing_id: listingId,
- participant_hash_1: myHash,
- participant_hash_2: null,
- public_key_1: myPublicKey,
- public_key_2: null,
- status: 'pending'
- })
-
- return this.getConversation(newConv.data?.id || newConv.id)
+ async startOrFindByListing(listingId, sellerPublicKey) {
+ return this.startOrGetConversation(listingId, sellerPublicKey)
}
- async joinConversation(conversationId) {
- await cryptoService.ready
-
- const myHash = await this.getParticipantHash()
- const myPublicKey = cryptoService.getPublicKey()
-
- await directus.patch(`/items/conversations/${conversationId}`, {
- participant_hash_2: myHash,
- public_key_2: myPublicKey,
- status: 'active'
- })
-
- return this.getConversation(conversationId)
- }
-
- async getPendingForMyListings() {
- const myListings = await directus.getMyListings()
- if (!myListings || myListings.length === 0) return []
-
- const listingIds = myListings.map(l => l.id)
- const listingMap = new Map(myListings.map(l => [l.id, l]))
-
- const response = await directus.get('/items/conversations', {
- fields: ['*'],
- filter: {
- status: { _eq: 'pending' },
- participant_hash_2: { _null: true },
- listing_id: { _in: listingIds }
- },
- sort: ['-date_created']
- })
-
- return (response.data || []).map(conv => {
- const lid = typeof conv.listing_id === 'object' ? conv.listing_id?.id : conv.listing_id
- return { ...conv, listing_id: listingMap.get(lid) || { id: lid } }
- })
+ 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) {
diff --git a/js/services/crypto.js b/js/services/crypto.js
index 82ba6fa..c859932 100644
--- a/js/services/crypto.js
+++ b/js/services/crypto.js
@@ -8,12 +8,14 @@
const STORAGE_KEY = 'dgray_keypair'
const SALT_KEY = 'dgray_keypair_salt'
+const LISTING_KEYS_STORAGE = 'dgray_listing_keys'
class CryptoService {
constructor() {
this.nacl = null
this.naclUtil = null
this.keyPair = null
+ this.wrappingKey = null
this.ready = this.init()
}
@@ -91,6 +93,7 @@ class CryptoService {
async unlock(uuid) {
await this.ready
const wrappingKey = await this.deriveKey(uuid)
+ this.wrappingKey = wrappingKey
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) {
@@ -138,12 +141,15 @@ class CryptoService {
lock() {
this.keyPair = null
+ this.wrappingKey = null
}
destroyKeyPair() {
this.keyPair = null
+ this.wrappingKey = null
localStorage.removeItem(STORAGE_KEY)
localStorage.removeItem(SALT_KEY)
+ localStorage.removeItem(LISTING_KEYS_STORAGE)
}
getPublicKey() {
@@ -151,17 +157,55 @@ class CryptoService {
return this.naclUtil.encodeBase64(this.keyPair.publicKey)
}
+ async getListingKeysStore() {
+ if (!this.wrappingKey) return {}
+ const stored = localStorage.getItem(LISTING_KEYS_STORAGE)
+ if (!stored) return {}
+ try {
+ const parsed = JSON.parse(stored)
+ return await this.decryptFromStorage(parsed, this.wrappingKey)
+ } catch (e) {
+ console.warn('Failed to load listing keys store', e)
+ return {}
+ }
+ }
+
+ async saveListingKeysStore(store) {
+ if (!this.wrappingKey) throw new Error('Not unlocked')
+ const encrypted = await this.encryptForStorage(store, this.wrappingKey)
+ localStorage.setItem(LISTING_KEYS_STORAGE, JSON.stringify(encrypted))
+ }
+
+ async generateListingKeyPair(listingId) {
+ if (!this.wrappingKey) throw new Error('Not unlocked')
+ await this.ready
+ const kp = this.nacl.box.keyPair()
+ const store = await this.getListingKeysStore()
+ store[listingId] = this.naclUtil.encodeBase64(kp.secretKey)
+ await this.saveListingKeysStore(store)
+ return this.naclUtil.encodeBase64(kp.publicKey)
+ }
+
+ async getListingSecretKey(listingId) {
+ const store = await this.getListingKeysStore()
+ return store[listingId] || null
+ }
+
/**
* Encrypt a message for a recipient
* @param {string} message - Plain text message
* @param {string} recipientPublicKey - Base64 encoded public key
+ * @param {string} [senderSecretKey] - Optional base64 secret key (e.g. listing-specific)
* @returns {object} - { nonce, ciphertext } both base64 encoded
*/
- encrypt(message, recipientPublicKey) {
+ encrypt(message, recipientPublicKey, senderSecretKey) {
const nonce = this.nacl.randomBytes(this.nacl.box.nonceLength)
const messageUint8 = this.naclUtil.decodeUTF8(message)
const recipientKey = this.naclUtil.decodeBase64(recipientPublicKey)
- const sharedKey = this.nacl.box.before(recipientKey, this.keyPair.secretKey)
+ const sk = senderSecretKey
+ ? this.naclUtil.decodeBase64(senderSecretKey)
+ : this.keyPair.secretKey
+ const sharedKey = this.nacl.box.before(recipientKey, sk)
const encrypted = this.nacl.secretbox(messageUint8, nonce, sharedKey)
@@ -176,12 +220,16 @@ class CryptoService {
* @param {string} ciphertext - Base64 encoded ciphertext
* @param {string} nonce - Base64 encoded nonce
* @param {string} otherPublicKey - Base64 encoded public key of the other party
+ * @param {string} [mySecretKey] - Optional base64 secret key (e.g. listing-specific)
* @returns {string|null} - Decrypted message or null if failed
*/
- decrypt(ciphertext, nonce, otherPublicKey) {
+ decrypt(ciphertext, nonce, otherPublicKey, mySecretKey) {
try {
const otherKey = this.naclUtil.decodeBase64(otherPublicKey)
- const sharedKey = this.nacl.box.before(otherKey, this.keyPair.secretKey)
+ const sk = mySecretKey
+ ? this.naclUtil.decodeBase64(mySecretKey)
+ : this.keyPair.secretKey
+ const sharedKey = this.nacl.box.before(otherKey, sk)
const decrypted = this.nacl.secretbox.open(
this.naclUtil.decodeBase64(ciphertext),
diff --git a/js/services/directus/listings.js b/js/services/directus/listings.js
index c929124..181ffb7 100644
--- a/js/services/directus/listings.js
+++ b/js/services/directus/listings.js
@@ -48,7 +48,8 @@ const DETAIL_FIELDS = [
'location.id',
'location.name',
'location.postal_code',
- 'location.country'
+ 'location.country',
+ 'contact_public_key'
]
export async function getListings(options = {}) {