From c60e20279049fd17536969d6fda3c14297a5c22b Mon Sep 17 00:00:00 2001 From: Alexander Schmidt Date: Wed, 28 Jan 2026 08:18:33 +0100 Subject: [PATCH] implement chat --- js/components/chat-widget.js | 287 ++++++++++++++++++++++++++++ js/components/pages/page-listing.js | 90 +++++++-- js/data/mock-listings.js | 30 ++- js/services/chat.js | 217 +++++++++++++++++++++ js/services/crypto.js | 127 ++++++++++++ locales/de.json | 7 + locales/en.json | 7 + locales/fr.json | 7 + 8 files changed, 748 insertions(+), 24 deletions(-) create mode 100644 js/components/chat-widget.js create mode 100644 js/services/chat.js create mode 100644 js/services/crypto.js diff --git a/js/components/chat-widget.js b/js/components/chat-widget.js new file mode 100644 index 0000000..6acf142 --- /dev/null +++ b/js/components/chat-widget.js @@ -0,0 +1,287 @@ +/** + * Chat Widget Component + * Embedded chat for buyer-seller communication + */ + +import { t, i18n } from '../i18n.js'; +import { chatService } from '../services/chat.js'; +import { cryptoService } from '../services/crypto.js'; + +class ChatWidget extends HTMLElement { + static get observedAttributes() { + return ['listing-id', 'recipient-id', 'recipient-key', 'recipient-name']; + } + + constructor() { + super(); + this.chat = null; + this.messages = []; + this.unsubscribe = null; + } + + async connectedCallback() { + await cryptoService.ready; + + this.listingId = this.getAttribute('listing-id'); + this.recipientId = this.getAttribute('recipient-id'); + this.recipientKey = this.getAttribute('recipient-key'); + this.recipientName = this.getAttribute('recipient-name') || 'Seller'; + + if (this.listingId && this.recipientId && this.recipientKey) { + this.chat = chatService.getOrCreateChat( + this.recipientId, + this.recipientKey, + this.listingId + ); + await this.loadMessages(); + } + + this.render(); + this.setupEventListeners(); + + this.unsubscribe = chatService.subscribe(() => this.refreshMessages()); + this.i18nUnsubscribe = i18n.subscribe(() => this.render()); + } + + disconnectedCallback() { + if (this.unsubscribe) this.unsubscribe(); + if (this.i18nUnsubscribe) this.i18nUnsubscribe(); + } + + async loadMessages() { + if (!this.chat) return; + this.messages = await chatService.getMessages(this.chat.id, this.recipientKey); + } + + async refreshMessages() { + await this.loadMessages(); + this.renderMessages(); + this.scrollToBottom(); + } + + render() { + this.innerHTML = /* html */` +
+
+ ${this.escapeHtml(this.recipientName)} + 🔒 +
+ +
+ ${this.renderMessagesHtml()} +
+ +
+ + +
+
+ `; + + this.setupEventListeners(); + this.scrollToBottom(); + } + + renderMessagesHtml() { + if (this.messages.length === 0) { + return /* html */` +
+

${t('chat.startConversation')}

+
+ `; + } + + return this.messages.map(msg => /* html */` +
+
+

${this.escapeHtml(msg.text)}

+ ${this.formatTime(msg.timestamp)} +
+
+ `).join(''); + } + + renderMessages() { + const container = this.querySelector('#chat-messages'); + if (container) { + container.innerHTML = this.renderMessagesHtml(); + } + } + + setupEventListeners() { + const form = this.querySelector('#chat-form'); + form?.addEventListener('submit', (e) => this.handleSubmit(e)); + } + + async handleSubmit(e) { + e.preventDefault(); + + const input = this.querySelector('#message-input'); + const text = input?.value.trim(); + + if (!text || !this.chat) return; + + input.value = ''; + + await chatService.sendMessage( + this.chat.id, + this.recipientKey, + text + ); + } + + scrollToBottom() { + const container = this.querySelector('#chat-messages'); + if (container) { + container.scrollTop = container.scrollHeight; + } + } + + formatTime(timestamp) { + const date = new Date(timestamp); + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + } + + escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } +} + +customElements.define('chat-widget', ChatWidget); + +const style = document.createElement('style'); +style.textContent = /* css */` + chat-widget { + display: block; + } + + chat-widget .chat-widget { + display: flex; + flex-direction: column; + height: 400px; + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + overflow: hidden; + background: var(--color-bg); + } + + chat-widget .chat-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-md); + background: var(--color-bg-secondary); + border-bottom: 1px solid var(--color-border); + } + + chat-widget .chat-recipient { + font-weight: var(--font-weight-medium); + } + + chat-widget .chat-encrypted { + font-size: var(--font-size-sm); + cursor: help; + } + + chat-widget .chat-messages { + flex: 1; + overflow-y: auto; + padding: var(--space-md); + display: flex; + flex-direction: column; + gap: var(--space-sm); + } + + chat-widget .chat-empty { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + color: var(--color-text-muted); + text-align: center; + } + + chat-widget .chat-message { + display: flex; + max-width: 80%; + } + + chat-widget .chat-message.own { + align-self: flex-end; + } + + chat-widget .chat-message.other { + align-self: flex-start; + } + + chat-widget .message-bubble { + padding: var(--space-sm) var(--space-md); + border-radius: var(--radius-lg); + position: relative; + } + + chat-widget .chat-message.own .message-bubble { + background: var(--color-primary); + color: white; + border-bottom-right-radius: var(--radius-sm); + } + + chat-widget .chat-message.other .message-bubble { + background: var(--color-bg-secondary); + border-bottom-left-radius: var(--radius-sm); + } + + chat-widget .message-bubble p { + margin: 0; + word-break: break-word; + } + + chat-widget .message-time { + display: block; + font-size: var(--font-size-xs); + opacity: 0.7; + margin-top: var(--space-xs); + } + + chat-widget .chat-input { + display: flex; + gap: var(--space-sm); + padding: var(--space-md); + border-top: 1px solid var(--color-border); + background: var(--color-bg-secondary); + } + + chat-widget .chat-input input { + flex: 1; + padding: var(--space-sm) var(--space-md); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-bg); + font-size: var(--font-size-base); + } + + chat-widget .chat-input input:focus { + outline: none; + border-color: var(--color-primary); + } + + chat-widget .chat-input button { + padding: var(--space-sm); + border-radius: var(--radius-md); + } +`; +document.head.appendChild(style); + +export { ChatWidget }; diff --git a/js/components/pages/page-listing.js b/js/components/pages/page-listing.js index a7a72b7..cd96f69 100644 --- a/js/components/pages/page-listing.js +++ b/js/components/pages/page-listing.js @@ -1,5 +1,6 @@ import { t, i18n } from '../../i18n.js'; import { getListingById } from '../../data/mock-listings.js'; +import '../chat-widget.js'; class PageListing extends HTMLElement { constructor() { @@ -106,23 +107,39 @@ class PageListing extends HTMLElement { -

${t('listing.contactSeller')}

-

${t('listing.paymentInfo')}

-
- -
- ${this.listing.seller.moneroAddress || '888tNkZrPN6JsEgekjMnABU4TBzc2Dt29EPAvkRxbANsAnjyPbb3iQ1YBRk1UXcdRsiKc9dhwMVgN5S9cQUiyoogDavup3H'} - -
+
+ +
-

${t('listing.contactHint')}

+
+ +
+ + `; @@ -134,6 +151,7 @@ class PageListing extends HTMLElement { const dialog = this.querySelector('#contact-dialog'); const closeBtn = this.querySelector('#dialog-close'); const copyBtn = this.querySelector('#copy-btn'); + const tabBtns = this.querySelectorAll('.tab-btn'); contactBtn?.addEventListener('click', () => { dialog?.showModal(); @@ -157,6 +175,19 @@ class PageListing extends HTMLElement { setTimeout(() => copyBtn.classList.remove('copied'), 2000); } }); + + tabBtns.forEach(btn => { + btn.addEventListener('click', () => { + const tab = btn.dataset.tab; + + tabBtns.forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + + this.querySelectorAll('.tab-content').forEach(content => { + content.classList.toggle('hidden', content.id !== `tab-${tab}`); + }); + }); + }); } escapeHtml(text) { @@ -292,6 +323,37 @@ style.textContent = /* css */` background: var(--color-overlay); } + page-listing .dialog-tabs { + display: flex; + gap: var(--space-sm); + margin-bottom: var(--space-lg); + border-bottom: 1px solid var(--color-border); + padding-bottom: var(--space-sm); + } + + page-listing .tab-btn { + padding: var(--space-sm) var(--space-md); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + color: var(--color-text-muted); + border-radius: var(--radius-md); + transition: all var(--transition-fast); + } + + page-listing .tab-btn:hover { + color: var(--color-text); + background: var(--color-bg-secondary); + } + + page-listing .tab-btn.active { + color: var(--color-primary); + background: var(--color-primary-light); + } + + page-listing .tab-content.hidden { + display: none; + } + page-listing .dialog-close { position: absolute; top: var(--space-md); diff --git a/js/data/mock-listings.js b/js/data/mock-listings.js index 012d212..b03bf98 100644 --- a/js/data/mock-listings.js +++ b/js/data/mock-listings.js @@ -11,7 +11,8 @@ export const mockListings = [ seller: { name: 'Max M.', memberSince: '2023', - moneroAddress: '888tNkZrPN6JsEgekjMnABU4TBzc2Dt29EPAvkRxbANsAnjyPbb3iQ1YBRk1UXcdRsiKc9dhwMVgN5S9cQUiyoogDavup3H' + moneroAddress: '888tNkZrPN6JsEgekjMnABU4TBzc2Dt29EPAvkRxbANsAnjyPbb3iQ1YBRk1UXcdRsiKc9dhwMVgN5S9cQUiyoogDavup3H', + publicKey: 'dGVzdC1wdWJsaWMta2V5LW1heC1tLTEyMzQ1Njc4OTA=' } }, { @@ -26,7 +27,8 @@ export const mockListings = [ seller: { name: 'Anna K.', memberSince: '2024', - moneroAddress: '47sghzufGhJJDQEbScMCwVBimTuq6L5JiRixD8VeGbpjCTA12GwZVPWzjmpfLDJNDAWvuNDAWvuNDAWvuNDAWvuN' + moneroAddress: '47sghzufGhJJDQEbScMCwVBimTuq6L5JiRixD8VeGbpjCTA12GwZVPWzjmpfLDJNDAWvuNDAWvuNDAWvuNDAWvuN', + publicKey: 'dGVzdC1wdWJsaWMta2V5LWFubmEtay0xMjM0NTY3ODkw' } }, { @@ -41,7 +43,8 @@ export const mockListings = [ seller: { name: 'Thomas B.', memberSince: '2022', - moneroAddress: '44AFFq5kSiGBoZ4NMDwYtN18obc8AemS33DBLWs3H7otXft3XjrpDtQGv7SqSsaBYBb98uNbr2VBBEt7f2wfn3RVGQBEP3A' + moneroAddress: '44AFFq5kSiGBoZ4NMDwYtN18obc8AemS33DBLWs3H7otXft3XjrpDtQGv7SqSsaBYBb98uNbr2VBBEt7f2wfn3RVGQBEP3A', + publicKey: 'dGVzdC1wdWJsaWMta2V5LXRob21hcy1iLTEyMzQ1Njc=' } }, { @@ -56,7 +59,8 @@ export const mockListings = [ seller: { name: 'Felix R.', memberSince: '2023', - moneroAddress: '48iWMy1PH6VGBJVvHDg9mY7mJ6vBDWVHpGgXEtCGp99kT4Xk5QfN3v7nqMrqGpvU' + moneroAddress: '48iWMy1PH6VGBJVvHDg9mY7mJ6vBDWVHpGgXEtCGp99kT4Xk5QfN3v7nqMrqGpvU', + publicKey: 'dGVzdC1wdWJsaWMta2V5LWZlbGl4LXItMTIzNDU2Nzg=' } }, { @@ -71,7 +75,8 @@ export const mockListings = [ seller: { name: 'Lisa S.', memberSince: '2025', - moneroAddress: '45dEQp8dFKrMXKvFWJFmZKCZhH3ARYMW4MJYM9FJcPuNT5Kek9R' + moneroAddress: '45dEQp8dFKrMXKvFWJFmZKCZhH3ARYMW4MJYM9FJcPuNT5Kek9R', + publicKey: 'dGVzdC1wdWJsaWMta2V5LWxpc2Etcy0xMjM0NTY3ODkw' } }, { @@ -86,7 +91,8 @@ export const mockListings = [ seller: { name: 'Jan P.', memberSince: '2024', - moneroAddress: '42nTNQp8dFKrMXKvFWJFmZKCZhH3ARYMW4MJYM9FJcPuNT5Kek9R' + moneroAddress: '42nTNQp8dFKrMXKvFWJFmZKCZhH3ARYMW4MJYM9FJcPuNT5Kek9R', + publicKey: 'dGVzdC1wdWJsaWMta2V5LWphbi1wLTEyMzQ1Njc4OTA=' } }, { @@ -101,7 +107,8 @@ export const mockListings = [ seller: { name: 'Sarah M.', memberSince: '2021', - moneroAddress: '46BeWrHpwXmHDpDEUmZBWZfoQpdc6HaERCNmx1pEYL2rAcuwufPN9rXHHtyUA4QVy24G3euoGWpzR7T3' + moneroAddress: '46BeWrHpwXmHDpDEUmZBWZfoQpdc6HaERCNmx1pEYL2rAcuwufPN9rXHHtyUA4QVy24G3euoGWpzR7T3', + publicKey: 'dGVzdC1wdWJsaWMta2V5LXNhcmFoLW0tMTIzNDU2Nzg=' } }, { @@ -116,7 +123,8 @@ export const mockListings = [ seller: { name: 'Michael W.', memberSince: '2022', - moneroAddress: '43gFrHpwXmHDpDEUmZBWZfoQpdc6HaERCNmx1pEYL2rAcuwufPN9rXHHtyUA4QVy24G3euoGWpzR7T3' + moneroAddress: '43gFrHpwXmHDpDEUmZBWZfoQpdc6HaERCNmx1pEYL2rAcuwufPN9rXHHtyUA4QVy24G3euoGWpzR7T3', + publicKey: 'dGVzdC1wdWJsaWMta2V5LW1pY2hhZWwtdy0xMjM0NTY=' } }, { @@ -131,7 +139,8 @@ export const mockListings = [ seller: { name: 'Klaus H.', memberSince: '2023', - moneroAddress: '47sghzufGhJJDQEbScMCwVBimTuq6L5JiRixD8VeGbpjCTA12GwZVPWzjmpfLDJNDAWvuNDAWvuNDAWvuNDAWvuN' + moneroAddress: '47sghzufGhJJDQEbScMCwVBimTuq6L5JiRixD8VeGbpjCTA12GwZVPWzjmpfLDJNDAWvuNDAWvuNDAWvuNDAWvuN', + publicKey: 'dGVzdC1wdWJsaWMta2V5LWtsYXVzLWgtMTIzNDU2Nzg=' } }, { @@ -146,7 +155,8 @@ export const mockListings = [ seller: { name: 'Nina L.', memberSince: '2024', - moneroAddress: '44AFFq5kSiGBoZ4NMDwYtN18obc8AemS33DBLWs3H7otXft3XjrpDtQGv7SqSsaBYBb98uNbr2VBBEt7f2wfn3RVGQBEP3A' + moneroAddress: '44AFFq5kSiGBoZ4NMDwYtN18obc8AemS33DBLWs3H7otXft3XjrpDtQGv7SqSsaBYBb98uNbr2VBBEt7f2wfn3RVGQBEP3A', + publicKey: 'dGVzdC1wdWJsaWMta2V5LW5pbmEtbC0xMjM0NTY3ODkw' } } ]; diff --git a/js/services/chat.js b/js/services/chat.js new file mode 100644 index 0000000..93c9614 --- /dev/null +++ b/js/services/chat.js @@ -0,0 +1,217 @@ +/** + * 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(); diff --git a/js/services/crypto.js b/js/services/crypto.js new file mode 100644 index 0000000..74742fb --- /dev/null +++ b/js/services/crypto.js @@ -0,0 +1,127 @@ +/** + * E2E Encryption Service using TweetNaCl + * https://tweetnacl.js.org/ + */ + +const STORAGE_KEY = 'dgray_keypair'; + +class CryptoService { + constructor() { + this.nacl = null; + this.naclUtil = null; + this.keyPair = null; + this.ready = this.init(); + } + + async init() { + // Dynamically import TweetNaCl from CDN + if (!window.nacl) { + await this.loadScript('https://cdn.jsdelivr.net/npm/tweetnacl@1.0.3/nacl-fast.min.js'); + await this.loadScript('https://cdn.jsdelivr.net/npm/tweetnacl-util@0.15.1/nacl-util.min.js'); + } + + this.nacl = window.nacl; + this.naclUtil = window.nacl.util; + + this.loadOrCreateKeyPair(); + } + + loadScript(src) { + return new Promise((resolve, reject) => { + if (document.querySelector(`script[src="${src}"]`)) { + resolve(); + return; + } + const script = document.createElement('script'); + script.src = src; + script.onload = resolve; + script.onerror = reject; + document.head.appendChild(script); + }); + } + + loadOrCreateKeyPair() { + const stored = localStorage.getItem(STORAGE_KEY); + + if (stored) { + try { + const parsed = JSON.parse(stored); + this.keyPair = { + publicKey: this.naclUtil.decodeBase64(parsed.publicKey), + secretKey: this.naclUtil.decodeBase64(parsed.secretKey) + }; + return; + } catch (e) { + console.warn('Failed to load keypair, generating new one'); + } + } + + this.generateKeyPair(); + } + + generateKeyPair() { + this.keyPair = this.nacl.box.keyPair(); + + const toStore = { + publicKey: this.naclUtil.encodeBase64(this.keyPair.publicKey), + secretKey: this.naclUtil.encodeBase64(this.keyPair.secretKey) + }; + + localStorage.setItem(STORAGE_KEY, JSON.stringify(toStore)); + } + + getPublicKey() { + if (!this.keyPair) return null; + return this.naclUtil.encodeBase64(this.keyPair.publicKey); + } + + /** + * Encrypt a message for a recipient + * @param {string} message - Plain text message + * @param {string} recipientPublicKey - Base64 encoded public key + * @returns {object} - { nonce, ciphertext } both base64 encoded + */ + encrypt(message, recipientPublicKey) { + const nonce = this.nacl.randomBytes(this.nacl.box.nonceLength); + const messageUint8 = this.naclUtil.decodeUTF8(message); + const recipientKey = this.naclUtil.decodeBase64(recipientPublicKey); + + const encrypted = this.nacl.box( + messageUint8, + nonce, + recipientKey, + this.keyPair.secretKey + ); + + return { + nonce: this.naclUtil.encodeBase64(nonce), + ciphertext: this.naclUtil.encodeBase64(encrypted) + }; + } + + /** + * Decrypt a message from a sender + * @param {string} ciphertext - Base64 encoded ciphertext + * @param {string} nonce - Base64 encoded nonce + * @param {string} senderPublicKey - Base64 encoded public key + * @returns {string|null} - Decrypted message or null if failed + */ + decrypt(ciphertext, nonce, senderPublicKey) { + try { + const decrypted = this.nacl.box.open( + this.naclUtil.decodeBase64(ciphertext), + this.naclUtil.decodeBase64(nonce), + this.naclUtil.decodeBase64(senderPublicKey), + this.keyPair.secretKey + ); + + if (!decrypted) return null; + return this.naclUtil.encodeUTF8(decrypted); + } catch (e) { + console.error('Decryption failed:', e); + return null; + } + } +} + +export const cryptoService = new CryptoService(); diff --git a/locales/de.json b/locales/de.json index 6bdee7c..d960801 100644 --- a/locales/de.json +++ b/locales/de.json @@ -105,6 +105,13 @@ "copyAddress": "Adresse kopieren", "contactHint": "Kopiere die Adresse und sende den Betrag über dein Monero-Wallet." }, + "chat": { + "title": "Nachricht senden", + "placeholder": "Nachricht schreiben...", + "encrypted": "Ende-zu-Ende verschlüsselt", + "startConversation": "Starte eine Unterhaltung mit dem Anbieter.", + "send": "Senden" + }, "create": { "title": "Anzeige erstellen", "listingTitle": "Titel", diff --git a/locales/en.json b/locales/en.json index 1af9473..da53ada 100644 --- a/locales/en.json +++ b/locales/en.json @@ -105,6 +105,13 @@ "copyAddress": "Copy address", "contactHint": "Copy the address and send the amount using your Monero wallet." }, + "chat": { + "title": "Send Message", + "placeholder": "Write a message...", + "encrypted": "End-to-end encrypted", + "startConversation": "Start a conversation with the seller.", + "send": "Send" + }, "create": { "title": "Create Listing", "listingTitle": "Title", diff --git a/locales/fr.json b/locales/fr.json index 7d43508..ec17573 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -105,6 +105,13 @@ "copyAddress": "Copier l'adresse", "contactHint": "Copiez l'adresse et envoyez le montant via votre portefeuille Monero." }, + "chat": { + "title": "Envoyer un message", + "placeholder": "Écrire un message...", + "encrypted": "Chiffré de bout en bout", + "startConversation": "Démarrez une conversation avec le vendeur.", + "send": "Envoyer" + }, "create": { "title": "Créer une annonce", "listingTitle": "Titre",