implement chat
This commit is contained in:
287
js/components/chat-widget.js
Normal file
287
js/components/chat-widget.js
Normal file
@@ -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 */`
|
||||||
|
<div class="chat-widget">
|
||||||
|
<div class="chat-header">
|
||||||
|
<span class="chat-recipient">${this.escapeHtml(this.recipientName)}</span>
|
||||||
|
<span class="chat-encrypted" title="${t('chat.encrypted')}">🔒</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chat-messages" id="chat-messages">
|
||||||
|
${this.renderMessagesHtml()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="chat-input" id="chat-form">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="message-input"
|
||||||
|
placeholder="${t('chat.placeholder')}"
|
||||||
|
autocomplete="off"
|
||||||
|
>
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<line x1="22" y1="2" x2="11" y2="13"></line>
|
||||||
|
<polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
this.setupEventListeners();
|
||||||
|
this.scrollToBottom();
|
||||||
|
}
|
||||||
|
|
||||||
|
renderMessagesHtml() {
|
||||||
|
if (this.messages.length === 0) {
|
||||||
|
return /* html */`
|
||||||
|
<div class="chat-empty">
|
||||||
|
<p>${t('chat.startConversation')}</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.messages.map(msg => /* html */`
|
||||||
|
<div class="chat-message ${msg.isOwn ? 'own' : 'other'}">
|
||||||
|
<div class="message-bubble">
|
||||||
|
<p>${this.escapeHtml(msg.text)}</p>
|
||||||
|
<span class="message-time">${this.formatTime(msg.timestamp)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).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 };
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { t, i18n } from '../../i18n.js';
|
import { t, i18n } from '../../i18n.js';
|
||||||
import { getListingById } from '../../data/mock-listings.js';
|
import { getListingById } from '../../data/mock-listings.js';
|
||||||
|
import '../chat-widget.js';
|
||||||
|
|
||||||
class PageListing extends HTMLElement {
|
class PageListing extends HTMLElement {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -106,23 +107,39 @@ class PageListing extends HTMLElement {
|
|||||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<h2>${t('listing.contactSeller')}</h2>
|
|
||||||
<p class="dialog-subtitle">${t('listing.paymentInfo')}</p>
|
|
||||||
|
|
||||||
<div class="monero-section">
|
<div class="dialog-tabs">
|
||||||
<label>${t('listing.moneroAddress')}</label>
|
<button class="tab-btn active" data-tab="chat">${t('chat.title')}</button>
|
||||||
<div class="monero-address">
|
<button class="tab-btn" data-tab="payment">${t('listing.moneroAddress')}</button>
|
||||||
<code id="monero-addr">${this.listing.seller.moneroAddress || '888tNkZrPN6JsEgekjMnABU4TBzc2Dt29EPAvkRxbANsAnjyPbb3iQ1YBRk1UXcdRsiKc9dhwMVgN5S9cQUiyoogDavup3H'}</code>
|
|
||||||
<button class="btn btn-outline btn-copy" id="copy-btn" title="${t('listing.copyAddress')}">
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
|
||||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="dialog-hint">${t('listing.contactHint')}</p>
|
<div class="tab-content" id="tab-chat">
|
||||||
|
<chat-widget
|
||||||
|
listing-id="${this.listing.id}"
|
||||||
|
recipient-id="${this.listing.seller.name}"
|
||||||
|
recipient-key="${this.listing.seller.publicKey || 'demo-key-' + this.listing.id}"
|
||||||
|
recipient-name="${this.escapeHtml(this.listing.seller.name)}"
|
||||||
|
></chat-widget>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-content hidden" id="tab-payment">
|
||||||
|
<p class="dialog-subtitle">${t('listing.paymentInfo')}</p>
|
||||||
|
|
||||||
|
<div class="monero-section">
|
||||||
|
<label>${t('listing.moneroAddress')}</label>
|
||||||
|
<div class="monero-address">
|
||||||
|
<code id="monero-addr">${this.listing.seller.moneroAddress || '888tNkZrPN6JsEgekjMnABU4TBzc2Dt29EPAvkRxbANsAnjyPbb3iQ1YBRk1UXcdRsiKc9dhwMVgN5S9cQUiyoogDavup3H'}</code>
|
||||||
|
<button class="btn btn-outline btn-copy" id="copy-btn" title="${t('listing.copyAddress')}">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
||||||
|
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="dialog-hint">${t('listing.contactHint')}</p>
|
||||||
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -134,6 +151,7 @@ class PageListing extends HTMLElement {
|
|||||||
const dialog = this.querySelector('#contact-dialog');
|
const dialog = this.querySelector('#contact-dialog');
|
||||||
const closeBtn = this.querySelector('#dialog-close');
|
const closeBtn = this.querySelector('#dialog-close');
|
||||||
const copyBtn = this.querySelector('#copy-btn');
|
const copyBtn = this.querySelector('#copy-btn');
|
||||||
|
const tabBtns = this.querySelectorAll('.tab-btn');
|
||||||
|
|
||||||
contactBtn?.addEventListener('click', () => {
|
contactBtn?.addEventListener('click', () => {
|
||||||
dialog?.showModal();
|
dialog?.showModal();
|
||||||
@@ -157,6 +175,19 @@ class PageListing extends HTMLElement {
|
|||||||
setTimeout(() => copyBtn.classList.remove('copied'), 2000);
|
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) {
|
escapeHtml(text) {
|
||||||
@@ -292,6 +323,37 @@ style.textContent = /* css */`
|
|||||||
background: var(--color-overlay);
|
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 {
|
page-listing .dialog-close {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: var(--space-md);
|
top: var(--space-md);
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ export const mockListings = [
|
|||||||
seller: {
|
seller: {
|
||||||
name: 'Max M.',
|
name: 'Max M.',
|
||||||
memberSince: '2023',
|
memberSince: '2023',
|
||||||
moneroAddress: '888tNkZrPN6JsEgekjMnABU4TBzc2Dt29EPAvkRxbANsAnjyPbb3iQ1YBRk1UXcdRsiKc9dhwMVgN5S9cQUiyoogDavup3H'
|
moneroAddress: '888tNkZrPN6JsEgekjMnABU4TBzc2Dt29EPAvkRxbANsAnjyPbb3iQ1YBRk1UXcdRsiKc9dhwMVgN5S9cQUiyoogDavup3H',
|
||||||
|
publicKey: 'dGVzdC1wdWJsaWMta2V5LW1heC1tLTEyMzQ1Njc4OTA='
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -26,7 +27,8 @@ export const mockListings = [
|
|||||||
seller: {
|
seller: {
|
||||||
name: 'Anna K.',
|
name: 'Anna K.',
|
||||||
memberSince: '2024',
|
memberSince: '2024',
|
||||||
moneroAddress: '47sghzufGhJJDQEbScMCwVBimTuq6L5JiRixD8VeGbpjCTA12GwZVPWzjmpfLDJNDAWvuNDAWvuNDAWvuNDAWvuN'
|
moneroAddress: '47sghzufGhJJDQEbScMCwVBimTuq6L5JiRixD8VeGbpjCTA12GwZVPWzjmpfLDJNDAWvuNDAWvuNDAWvuNDAWvuN',
|
||||||
|
publicKey: 'dGVzdC1wdWJsaWMta2V5LWFubmEtay0xMjM0NTY3ODkw'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -41,7 +43,8 @@ export const mockListings = [
|
|||||||
seller: {
|
seller: {
|
||||||
name: 'Thomas B.',
|
name: 'Thomas B.',
|
||||||
memberSince: '2022',
|
memberSince: '2022',
|
||||||
moneroAddress: '44AFFq5kSiGBoZ4NMDwYtN18obc8AemS33DBLWs3H7otXft3XjrpDtQGv7SqSsaBYBb98uNbr2VBBEt7f2wfn3RVGQBEP3A'
|
moneroAddress: '44AFFq5kSiGBoZ4NMDwYtN18obc8AemS33DBLWs3H7otXft3XjrpDtQGv7SqSsaBYBb98uNbr2VBBEt7f2wfn3RVGQBEP3A',
|
||||||
|
publicKey: 'dGVzdC1wdWJsaWMta2V5LXRob21hcy1iLTEyMzQ1Njc='
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -56,7 +59,8 @@ export const mockListings = [
|
|||||||
seller: {
|
seller: {
|
||||||
name: 'Felix R.',
|
name: 'Felix R.',
|
||||||
memberSince: '2023',
|
memberSince: '2023',
|
||||||
moneroAddress: '48iWMy1PH6VGBJVvHDg9mY7mJ6vBDWVHpGgXEtCGp99kT4Xk5QfN3v7nqMrqGpvU'
|
moneroAddress: '48iWMy1PH6VGBJVvHDg9mY7mJ6vBDWVHpGgXEtCGp99kT4Xk5QfN3v7nqMrqGpvU',
|
||||||
|
publicKey: 'dGVzdC1wdWJsaWMta2V5LWZlbGl4LXItMTIzNDU2Nzg='
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -71,7 +75,8 @@ export const mockListings = [
|
|||||||
seller: {
|
seller: {
|
||||||
name: 'Lisa S.',
|
name: 'Lisa S.',
|
||||||
memberSince: '2025',
|
memberSince: '2025',
|
||||||
moneroAddress: '45dEQp8dFKrMXKvFWJFmZKCZhH3ARYMW4MJYM9FJcPuNT5Kek9R'
|
moneroAddress: '45dEQp8dFKrMXKvFWJFmZKCZhH3ARYMW4MJYM9FJcPuNT5Kek9R',
|
||||||
|
publicKey: 'dGVzdC1wdWJsaWMta2V5LWxpc2Etcy0xMjM0NTY3ODkw'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -86,7 +91,8 @@ export const mockListings = [
|
|||||||
seller: {
|
seller: {
|
||||||
name: 'Jan P.',
|
name: 'Jan P.',
|
||||||
memberSince: '2024',
|
memberSince: '2024',
|
||||||
moneroAddress: '42nTNQp8dFKrMXKvFWJFmZKCZhH3ARYMW4MJYM9FJcPuNT5Kek9R'
|
moneroAddress: '42nTNQp8dFKrMXKvFWJFmZKCZhH3ARYMW4MJYM9FJcPuNT5Kek9R',
|
||||||
|
publicKey: 'dGVzdC1wdWJsaWMta2V5LWphbi1wLTEyMzQ1Njc4OTA='
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -101,7 +107,8 @@ export const mockListings = [
|
|||||||
seller: {
|
seller: {
|
||||||
name: 'Sarah M.',
|
name: 'Sarah M.',
|
||||||
memberSince: '2021',
|
memberSince: '2021',
|
||||||
moneroAddress: '46BeWrHpwXmHDpDEUmZBWZfoQpdc6HaERCNmx1pEYL2rAcuwufPN9rXHHtyUA4QVy24G3euoGWpzR7T3'
|
moneroAddress: '46BeWrHpwXmHDpDEUmZBWZfoQpdc6HaERCNmx1pEYL2rAcuwufPN9rXHHtyUA4QVy24G3euoGWpzR7T3',
|
||||||
|
publicKey: 'dGVzdC1wdWJsaWMta2V5LXNhcmFoLW0tMTIzNDU2Nzg='
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -116,7 +123,8 @@ export const mockListings = [
|
|||||||
seller: {
|
seller: {
|
||||||
name: 'Michael W.',
|
name: 'Michael W.',
|
||||||
memberSince: '2022',
|
memberSince: '2022',
|
||||||
moneroAddress: '43gFrHpwXmHDpDEUmZBWZfoQpdc6HaERCNmx1pEYL2rAcuwufPN9rXHHtyUA4QVy24G3euoGWpzR7T3'
|
moneroAddress: '43gFrHpwXmHDpDEUmZBWZfoQpdc6HaERCNmx1pEYL2rAcuwufPN9rXHHtyUA4QVy24G3euoGWpzR7T3',
|
||||||
|
publicKey: 'dGVzdC1wdWJsaWMta2V5LW1pY2hhZWwtdy0xMjM0NTY='
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -131,7 +139,8 @@ export const mockListings = [
|
|||||||
seller: {
|
seller: {
|
||||||
name: 'Klaus H.',
|
name: 'Klaus H.',
|
||||||
memberSince: '2023',
|
memberSince: '2023',
|
||||||
moneroAddress: '47sghzufGhJJDQEbScMCwVBimTuq6L5JiRixD8VeGbpjCTA12GwZVPWzjmpfLDJNDAWvuNDAWvuNDAWvuNDAWvuN'
|
moneroAddress: '47sghzufGhJJDQEbScMCwVBimTuq6L5JiRixD8VeGbpjCTA12GwZVPWzjmpfLDJNDAWvuNDAWvuNDAWvuNDAWvuN',
|
||||||
|
publicKey: 'dGVzdC1wdWJsaWMta2V5LWtsYXVzLWgtMTIzNDU2Nzg='
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -146,7 +155,8 @@ export const mockListings = [
|
|||||||
seller: {
|
seller: {
|
||||||
name: 'Nina L.',
|
name: 'Nina L.',
|
||||||
memberSince: '2024',
|
memberSince: '2024',
|
||||||
moneroAddress: '44AFFq5kSiGBoZ4NMDwYtN18obc8AemS33DBLWs3H7otXft3XjrpDtQGv7SqSsaBYBb98uNbr2VBBEt7f2wfn3RVGQBEP3A'
|
moneroAddress: '44AFFq5kSiGBoZ4NMDwYtN18obc8AemS33DBLWs3H7otXft3XjrpDtQGv7SqSsaBYBb98uNbr2VBBEt7f2wfn3RVGQBEP3A',
|
||||||
|
publicKey: 'dGVzdC1wdWJsaWMta2V5LW5pbmEtbC0xMjM0NTY3ODkw'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|||||||
217
js/services/chat.js
Normal file
217
js/services/chat.js
Normal file
@@ -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();
|
||||||
127
js/services/crypto.js
Normal file
127
js/services/crypto.js
Normal file
@@ -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();
|
||||||
@@ -105,6 +105,13 @@
|
|||||||
"copyAddress": "Adresse kopieren",
|
"copyAddress": "Adresse kopieren",
|
||||||
"contactHint": "Kopiere die Adresse und sende den Betrag über dein Monero-Wallet."
|
"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": {
|
"create": {
|
||||||
"title": "Anzeige erstellen",
|
"title": "Anzeige erstellen",
|
||||||
"listingTitle": "Titel",
|
"listingTitle": "Titel",
|
||||||
|
|||||||
@@ -105,6 +105,13 @@
|
|||||||
"copyAddress": "Copy address",
|
"copyAddress": "Copy address",
|
||||||
"contactHint": "Copy the address and send the amount using your Monero wallet."
|
"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": {
|
"create": {
|
||||||
"title": "Create Listing",
|
"title": "Create Listing",
|
||||||
"listingTitle": "Title",
|
"listingTitle": "Title",
|
||||||
|
|||||||
@@ -105,6 +105,13 @@
|
|||||||
"copyAddress": "Copier l'adresse",
|
"copyAddress": "Copier l'adresse",
|
||||||
"contactHint": "Copiez l'adresse et envoyez le montant via votre portefeuille Monero."
|
"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": {
|
"create": {
|
||||||
"title": "Créer une annonce",
|
"title": "Créer une annonce",
|
||||||
"listingTitle": "Titre",
|
"listingTitle": "Titre",
|
||||||
|
|||||||
Reference in New Issue
Block a user