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.listing.seller.moneroAddress || '888tNkZrPN6JsEgekjMnABU4TBzc2Dt29EPAvkRxbANsAnjyPbb3iQ1YBRk1UXcdRsiKc9dhwMVgN5S9cQUiyoogDavup3H'}
-
-
+
+
+
-
${t('listing.contactHint')}
+
+
+
+
+
+
${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",