Files
kashilo/js/components/chat-widget.js

355 lines
9.8 KiB
JavaScript

/**
* Chat Widget Component
* Embedded chat for buyer-seller communication using Directus conversations
*/
import { t, i18n } from '../i18n.js'
import { conversationsService } from '../services/conversations.js'
import { cryptoService } from '../services/crypto.js'
class ChatWidget extends HTMLElement {
static get observedAttributes() {
return ['listing-id', 'seller-public-key', 'recipient-name']
}
constructor() {
super()
this.conversation = null
this.messages = []
this.unsubscribe = null
this.loading = true
this.error = null
}
async connectedCallback() {
await cryptoService.ready
this.listingId = this.getAttribute('listing-id')
this.sellerPublicKey = this.getAttribute('seller-public-key')
this.recipientName = this.getAttribute('recipient-name') || 'Seller'
this.render()
if (this.listingId && this.sellerPublicKey) {
await this.initConversation()
} else {
this.loading = false
this.error = 'missing-data'
this.render()
}
this.unsubscribe = conversationsService.subscribe(() => this.refreshMessages())
this.i18nUnsubscribe = i18n.subscribe(() => this.render())
}
disconnectedCallback() {
if (this.unsubscribe) this.unsubscribe()
if (this.i18nUnsubscribe) this.i18nUnsubscribe()
}
async initConversation() {
try {
this.conversation = await conversationsService.startOrGetConversation(
this.listingId,
this.sellerPublicKey
)
await this.loadMessages()
} catch (e) {
console.error('Failed to init conversation:', e)
this.error = 'init-failed'
}
this.loading = false
this.render()
this.setupEventListeners()
}
async loadMessages() {
if (!this.conversation) return
this.messages = await conversationsService.getMessages(
this.conversation.id,
this.conversation.otherPublicKey
)
}
async refreshMessages() {
await this.loadMessages()
this.renderMessages()
this.scrollToBottom()
}
render() {
if (this.loading) {
this.innerHTML = /* html */`
<div class="chat-widget">
<div class="chat-loading">
<span>${t('common.loading')}</span>
</div>
</div>
`
return
}
if (this.error) {
this.innerHTML = /* html */`
<div class="chat-widget">
<div class="chat-error">
<p>${t('chat.unavailable')}</p>
</div>
</div>
`
return
}
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.conversation) return
input.value = ''
await conversationsService.sendMessage(
this.conversation.id,
this.conversation.otherPublicKey,
text
)
await this.refreshMessages()
}
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-loading,
chat-widget .chat-error {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-text-muted);
text-align: center;
padding: var(--space-lg);
}
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);
color: var(--color-text);
}
chat-widget .chat-encrypted {
font-size: var(--font-size-sm);
cursor: help;
filter: grayscale(1);
}
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-text);
color: var(--color-bg);
border-bottom-right-radius: var(--radius-sm);
}
chat-widget .chat-message.other .message-bubble {
background: var(--color-bg-tertiary);
color: var(--color-text);
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);
color: var(--color-text);
font-size: var(--font-size-base);
}
chat-widget .chat-input input::placeholder {
color: var(--color-text-muted);
}
chat-widget .chat-input input:focus {
outline: none;
border-color: var(--color-text-muted);
}
chat-widget .chat-input button {
padding: var(--space-sm);
border-radius: var(--radius-md);
background: var(--color-text);
color: var(--color-bg);
}
chat-widget .chat-input button:hover {
background: var(--color-text-secondary);
}
`
document.head.appendChild(style)
export { ChatWidget }