feat: implement seller-join flow for E2E chat with pending conversation discovery

This commit is contained in:
2026-02-08 14:50:23 +01:00
parent 2f1ef2e725
commit d1375b2dcf
12 changed files with 317 additions and 96 deletions

View File

@@ -10,7 +10,7 @@ import { escapeHTML } from '../utils/helpers.js'
class ChatWidget extends HTMLElement {
static get observedAttributes() {
return ['listing-id', 'seller-public-key', 'recipient-name']
return ['listing-id', 'recipient-name']
}
constructor() {
@@ -20,18 +20,34 @@ class ChatWidget extends HTMLElement {
this.unsubscribe = null
this.loading = true
this.error = null
this._initialized = false
}
async connectedCallback() {
connectedCallback() {
this.listingId = this.getAttribute('listing-id')
this.recipientName = this.getAttribute('recipient-name') || 'Seller'
this.render()
}
disconnectedCallback() {
if (this.unsubscribe) this.unsubscribe()
if (this.i18nUnsubscribe) this.i18nUnsubscribe()
}
async activate() {
if (this._initialized) return
this._initialized = true
await cryptoService.ready
this.listingId = this.getAttribute('listing-id')
this.sellerPublicKey = this.getAttribute('seller-public-key')
this.recipientName = this.getAttribute('recipient-name') || 'Seller'
if (!cryptoService.getPublicKey()) {
this.loading = false
this.error = 'no-keypair'
this.render()
return
}
if (this.listingId && this.sellerPublicKey) {
if (this.listingId) {
await this.initConversation()
} else {
this.loading = false
@@ -43,17 +59,9 @@ class ChatWidget extends HTMLElement {
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
)
this.conversation = await conversationsService.startOrFindByListing(this.listingId)
await this.loadMessages()
} catch (e) {
console.error('Failed to init conversation:', e)
@@ -101,6 +109,8 @@ class ChatWidget extends HTMLElement {
return
}
const pending = this.conversation && !this.conversation.otherPublicKey
this.innerHTML = /* html */`
<div class="chat-widget">
<div class="chat-header">
@@ -109,17 +119,20 @@ class ChatWidget extends HTMLElement {
</div>
<div class="chat-messages" id="chat-messages">
${this.renderMessagesHtml()}
${pending
? /* html */`<div class="chat-empty"><p>${t('chat.pending')}</p></div>`
: this.renderMessagesHtml()}
</div>
<form class="chat-input" id="chat-form">
<input
type="text"
id="message-input"
placeholder="${t('chat.placeholder')}"
placeholder="${pending ? t('chat.pendingHint') : t('chat.placeholder')}"
autocomplete="off"
${pending ? 'disabled' : ''}
>
<button type="submit" class="btn btn-primary">
<button type="submit" class="btn btn-primary" ${pending ? 'disabled' : ''}>
<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>

View File

@@ -1,6 +1,7 @@
import { t, i18n } from '../../i18n.js'
import { directus } from '../../services/directus.js'
import { auth } from '../../services/auth.js'
import { conversationsService } from '../../services/conversations.js'
import { favoritesService } from '../../services/favorites.js'
import { getXmrRates, formatPrice as formatCurrencyPrice } from '../../services/currency.js'
import { escapeHTML } from '../../utils/helpers.js'
@@ -18,6 +19,7 @@ class PageListing extends HTMLElement {
this.isFavorite = false
this.rates = null
this.isOwner = false
this.hasPendingChats = false
this.handleCurrencyChange = this.handleCurrencyChange.bind(this)
}
@@ -58,6 +60,11 @@ class PageListing extends HTMLElement {
}
}
// Check for pending chats if owner
if (this.isOwner) {
this.hasPendingChats = await this.checkPendingChats()
}
// Load other listings from same seller
if (this.listing?.user_created) {
await this.loadSellerListings()
@@ -138,6 +145,23 @@ class PageListing extends HTMLElement {
}
}
async checkPendingChats() {
try {
const response = await directus.get('/items/conversations', {
fields: ['id'],
filter: {
listing_id: { _eq: this.listingId },
status: { _eq: 'pending' },
participant_hash_2: { _null: true }
},
limit: 1
})
return (response.data?.length || 0) > 0
} catch {
return false
}
}
async loadSellerListings() {
try {
const response = await directus.getListings({
@@ -349,6 +373,15 @@ class PageListing extends HTMLElement {
${t('listing.edit')}
</a>
${this.hasPendingChats ? /* html */`
<button class="btn btn-outline btn-lg sidebar-btn" id="contact-btn">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
</svg>
${t('listing.newMessages')}
</button>
` : ''}
<div class="sidebar-actions">
<button class="action-btn" id="share-btn" title="${t('listing.share')}">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -450,7 +483,6 @@ class PageListing extends HTMLElement {
<div class="tab-content" id="tab-chat">
<chat-widget
listing-id="${this.listing?.id || ''}"
seller-public-key="${this.listing?.seller_public_key || ''}"
recipient-name="${t('listing.anonymousSeller')}"
></chat-widget>
</div>
@@ -501,11 +533,19 @@ class PageListing extends HTMLElement {
setupEventListeners() {
// Contact dialog
const contactBtn = this.querySelector('#contact-btn')
const dialog = this.querySelector('#contact-dialog')
const closeBtn = this.querySelector('#dialog-close')
contactBtn?.addEventListener('click', () => dialog?.showModal())
this.querySelectorAll('#contact-btn').forEach(btn => {
btn.addEventListener('click', () => {
if (!auth.isLoggedIn()) {
document.querySelector('auth-modal')?.show()
return
}
dialog?.showModal()
this.querySelector('chat-widget')?.activate()
})
})
closeBtn?.addEventListener('click', () => dialog?.close())
dialog?.addEventListener('click', (e) => {
if (e.target === dialog) dialog.close()

View File

@@ -1,12 +1,14 @@
import { t, i18n } from '../../i18n.js'
import { auth } from '../../services/auth.js'
import { directus } from '../../services/directus.js'
import { conversationsService } from '../../services/conversations.js'
import { escapeHTML } from '../../utils/helpers.js'
class PageMessages extends HTMLElement {
constructor() {
super()
this.conversations = []
this.pendingConversations = []
this.loading = true
this.error = null
this.isLoggedIn = false
@@ -49,7 +51,14 @@ class PageMessages extends HTMLElement {
}
const userHash = await auth.hashString(user.id)
this.conversations = await directus.getConversations(userHash) || []
const [conversations, pending] = await Promise.all([
directus.getConversations(userHash),
conversationsService.getPendingForMyListings().catch(() => [])
])
this.conversations = conversations || []
this.pendingConversations = pending.filter(
p => !this.conversations.some(c => c.id === p.id)
)
this.loading = false
this.updateContent()
} catch (err) {
@@ -119,7 +128,7 @@ class PageMessages extends HTMLElement {
`
}
if (this.conversations.length === 0) {
if (this.conversations.length === 0 && this.pendingConversations.length === 0) {
return /* html */`
<div class="empty-state">
<div class="empty-icon">💬</div>
@@ -130,15 +139,33 @@ class PageMessages extends HTMLElement {
`
}
return this.conversations.map(conv => {
const listing = conv.listing_id
let html = ''
if (this.pendingConversations.length > 0) {
html += /* html */`<h2 class="section-title">${t('messages.pendingRequests')}</h2>`
html += this.pendingConversations.map(conv => this.renderConversationItem(conv, true)).join('')
}
if (this.conversations.length > 0) {
if (this.pendingConversations.length > 0) {
html += /* html */`<h2 class="section-title">${t('messages.activeChats')}</h2>`
}
html += this.conversations.map(conv => this.renderConversationItem(conv, false)).join('')
}
return html
}
renderConversationItem(conv, isPending) {
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
const imageUrl = imageId ? directus.getThumbnailUrl(imageId, 80) : ''
const title = listing?.status === 'deleted' ? t('messages.listingRemoved') : (listing?.title || t('messages.unknownListing'))
const dateStr = this.formatDate(conv.date_updated || conv.date_created)
return /* html */`
<a href="#/listing/${listing?.id}" class="conversation-item">
<a href="#/listing/${listingId}" class="conversation-item ${isPending ? 'conversation-pending' : ''}" data-conv-id="${conv.id}">
<div class="conversation-image">
${imageUrl
? `<img src="${imageUrl}" alt="" loading="lazy">`
@@ -148,10 +175,11 @@ class PageMessages extends HTMLElement {
<h3 class="conversation-title">${escapeHTML(title)}</h3>
<p class="conversation-date">${dateStr}</p>
</div>
<div class="conversation-arrow">→</div>
${isPending
? `<span class="conversation-badge">${t('messages.new')}</span>`
: `<div class="conversation-arrow">→</div>`}
</a>
`
}).join('')
}
formatDate(dateStr) {
@@ -298,5 +326,32 @@ style.textContent = /* css */`
color: var(--color-text-muted);
margin: 0 0 var(--space-lg);
}
page-messages .section-title {
font-size: var(--font-size-sm);
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
margin: var(--space-lg) 0 var(--space-sm);
}
page-messages .section-title:first-child {
margin-top: 0;
}
page-messages .conversation-pending {
border-color: var(--color-primary);
background: var(--color-bg);
}
page-messages .conversation-badge {
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
padding: var(--space-xs) var(--space-sm);
border-radius: var(--radius-full);
background: var(--color-primary);
color: var(--color-bg);
white-space: nowrap;
}
`
document.head.appendChild(style)

View File

@@ -67,6 +67,8 @@ class ConversationsService {
async getMessages(conversationId, otherPublicKey) {
await cryptoService.ready
if (!otherPublicKey) return []
const messages = await directus.getConversationMessages(conversationId)
const myHash = await this.getParticipantHash()
@@ -93,6 +95,8 @@ class ConversationsService {
async sendMessage(conversationId, otherPublicKey, plainText, type = 'text') {
await cryptoService.ready
if (!otherPublicKey) throw new Error('Cannot send: other party has not joined yet')
const { nonce, ciphertext } = cryptoService.encrypt(plainText, otherPublicKey)
const senderHash = await this.getParticipantHash()
@@ -138,6 +142,93 @@ 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 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 closeConversation(id) {
return directus.updateConversationStatus(id, 'closed')
}

View File

@@ -37,7 +37,6 @@ const DETAIL_FIELDS = [
'condition',
'shipping',
'shipping_cost',
'views',
'expires_at',
'date_created',
'user_created',
@@ -95,29 +94,10 @@ export async function updateListing(id, data) {
return response.data
}
export async function incrementListingViews(id) {
const viewedKey = `dgray_viewed_${id}`
if (sessionStorage.getItem(viewedKey)) {
// View counting requires server-side implementation (e.g. Directus Flow)
// Client-side increment is not possible with current permissions
export async function incrementListingViews(_id) {
return null
}
try {
const listing = await client.get(`/items/listings/${id}`, {
fields: ['views']
})
const currentViews = listing.data?.views || 0
const newViews = currentViews + 1
await client.patch(`/items/listings/${id}`, {
views: newViews
})
sessionStorage.setItem(viewedKey, 'true')
return newViews
} catch (error) {
console.error('Failed to increment views:', error)
return null
}
}
export async function deleteListing(id) {

View File

@@ -99,7 +99,8 @@
"edit": "Bearbeiten",
"expired": "Abgelaufen",
"expiresIn1Day": "Noch 1 Tag gültig",
"expiresInDays": "Noch {{days}} Tage gültig"
"expiresInDays": "Noch {{days}} Tage gültig",
"newMessages": "Neue Nachrichten"
},
"chat": {
"title": "Nachricht senden",
@@ -107,7 +108,9 @@
"encrypted": "Ende-zu-Ende verschlüsselt",
"startConversation": "Starte eine Unterhaltung mit dem Anbieter.",
"send": "Senden",
"unavailable": "Chat nicht verfügbar"
"unavailable": "Chat nicht verfügbar",
"pending": "Konversationsanfrage gesendet. Warte auf Antwort des Anbieters.",
"pendingHint": "Warte auf Anbieter..."
},
"create": {
"title": "Anzeige erstellen",
@@ -249,7 +252,10 @@
"today": "Heute",
"yesterday": "Gestern",
"daysAgo": "Vor {{days}} Tagen",
"listingRemoved": "Anzeige entfernt"
"listingRemoved": "Anzeige entfernt",
"pendingRequests": "Neue Anfragen",
"activeChats": "Konversationen",
"new": "Neu"
},
"settings": {
"title": "Einstellungen",

View File

@@ -99,7 +99,8 @@
"edit": "Edit",
"expired": "Expired",
"expiresIn1Day": "1 day left",
"expiresInDays": "{{days}} days left"
"expiresInDays": "{{days}} days left",
"newMessages": "New messages"
},
"chat": {
"title": "Send Message",
@@ -107,7 +108,9 @@
"encrypted": "End-to-end encrypted",
"startConversation": "Start a conversation with the seller.",
"send": "Send",
"unavailable": "Chat unavailable"
"unavailable": "Chat unavailable",
"pending": "Conversation request sent. Waiting for the seller to respond.",
"pendingHint": "Waiting for seller..."
},
"create": {
"title": "Create Listing",
@@ -249,7 +252,10 @@
"today": "Today",
"yesterday": "Yesterday",
"daysAgo": "{{days}} days ago",
"listingRemoved": "Listing removed"
"listingRemoved": "Listing removed",
"pendingRequests": "New requests",
"activeChats": "Conversations",
"new": "New"
},
"settings": {
"title": "Settings",

View File

@@ -99,7 +99,8 @@
"edit": "Editar",
"expired": "Caducado",
"expiresIn1Day": "Queda 1 día",
"expiresInDays": "Quedan {{days}} días"
"expiresInDays": "Quedan {{days}} días",
"newMessages": "Nuevos mensajes"
},
"chat": {
"title": "Enviar mensaje",
@@ -107,7 +108,9 @@
"encrypted": "Cifrado de extremo a extremo",
"startConversation": "Inicia una conversación con el vendedor.",
"send": "Enviar",
"unavailable": "Chat no disponible"
"unavailable": "Chat no disponible",
"pending": "Solicitud de conversación enviada. Esperando respuesta del vendedor.",
"pendingHint": "Esperando al vendedor..."
},
"create": {
"title": "Crear anuncio",
@@ -249,7 +252,10 @@
"today": "Hoy",
"yesterday": "Ayer",
"daysAgo": "Hace {{days}} días",
"listingRemoved": "Anuncio eliminado"
"listingRemoved": "Anuncio eliminado",
"pendingRequests": "Nuevas solicitudes",
"activeChats": "Conversaciones",
"new": "Nuevo"
},
"settings": {
"title": "Ajustes",

View File

@@ -99,7 +99,8 @@
"edit": "Modifier",
"expired": "Expiré",
"expiresIn1Day": "Encore 1 jour",
"expiresInDays": "Encore {{days}} jours"
"expiresInDays": "Encore {{days}} jours",
"newMessages": "Nouveaux messages"
},
"chat": {
"title": "Envoyer un message",
@@ -107,7 +108,9 @@
"encrypted": "Chiffré de bout en bout",
"startConversation": "Démarrez une conversation avec le vendeur.",
"send": "Envoyer",
"unavailable": "Chat non disponible"
"unavailable": "Chat non disponible",
"pending": "Demande de conversation envoyée. En attente de réponse du vendeur.",
"pendingHint": "En attente du vendeur..."
},
"create": {
"title": "Créer une annonce",
@@ -249,7 +252,10 @@
"today": "Aujourd'hui",
"yesterday": "Hier",
"daysAgo": "Il y a {{days}} jours",
"listingRemoved": "Annonce supprimée"
"listingRemoved": "Annonce supprimée",
"pendingRequests": "Nouvelles demandes",
"activeChats": "Conversations",
"new": "Nouveau"
},
"settings": {
"title": "Paramètres",

View File

@@ -99,7 +99,8 @@
"edit": "Modifica",
"expired": "Scaduto",
"expiresIn1Day": "1 giorno rimanente",
"expiresInDays": "{{days}} giorni rimanenti"
"expiresInDays": "{{days}} giorni rimanenti",
"newMessages": "Nuovi messaggi"
},
"chat": {
"title": "Invia messaggio",
@@ -107,7 +108,9 @@
"encrypted": "Crittografia end-to-end",
"startConversation": "Inizia una conversazione con il venditore.",
"send": "Invia",
"unavailable": "Chat non disponibile"
"unavailable": "Chat non disponibile",
"pending": "Richiesta di conversazione inviata. In attesa della risposta del venditore.",
"pendingHint": "In attesa del venditore..."
},
"create": {
"title": "Crea annuncio",
@@ -249,7 +252,10 @@
"today": "Oggi",
"yesterday": "Ieri",
"daysAgo": "{{days}} giorni fa",
"listingRemoved": "Annuncio rimosso"
"listingRemoved": "Annuncio rimosso",
"pendingRequests": "Nuove richieste",
"activeChats": "Conversazioni",
"new": "Nuovo"
},
"settings": {
"title": "Impostazioni",

View File

@@ -99,7 +99,8 @@
"edit": "Editar",
"expired": "Expirado",
"expiresIn1Day": "1 dia restante",
"expiresInDays": "{{days}} dias restantes"
"expiresInDays": "{{days}} dias restantes",
"newMessages": "Novas mensagens"
},
"chat": {
"title": "Enviar Mensagem",
@@ -107,7 +108,9 @@
"encrypted": "Criptografia de ponta a ponta",
"startConversation": "Inicie uma conversa com o vendedor.",
"send": "Enviar",
"unavailable": "Chat indisponível"
"unavailable": "Chat indisponível",
"pending": "Solicitação de conversa enviada. Aguardando resposta do vendedor.",
"pendingHint": "Aguardando vendedor..."
},
"create": {
"title": "Criar Anúncio",
@@ -249,7 +252,10 @@
"today": "Hoje",
"yesterday": "Ontem",
"daysAgo": "{{days}} dias atrás",
"listingRemoved": "Anúncio removido"
"listingRemoved": "Anúncio removido",
"pendingRequests": "Novas solicitações",
"activeChats": "Conversas",
"new": "Novo"
},
"settings": {
"title": "Configurações",

View File

@@ -99,7 +99,8 @@
"edit": "Редактировать",
"expired": "Истекло",
"expiresIn1Day": "Остался 1 день",
"expiresInDays": "Осталось {{days}} дн."
"expiresInDays": "Осталось {{days}} дн.",
"newMessages": "Новые сообщения"
},
"chat": {
"title": "Отправить сообщение",
@@ -107,7 +108,9 @@
"encrypted": "Сквозное шифрование",
"startConversation": "Начните разговор с продавцом.",
"send": "Отправить",
"unavailable": "Чат недоступен"
"unavailable": "Чат недоступен",
"pending": "Запрос на разговор отправлен. Ожидание ответа продавца.",
"pendingHint": "Ожидание продавца..."
},
"create": {
"title": "Создать объявление",
@@ -249,7 +252,10 @@
"today": "Сегодня",
"yesterday": "Вчера",
"daysAgo": "{{days}} дн. назад",
"listingRemoved": "Объявление удалено"
"listingRemoved": "Объявление удалено",
"pendingRequests": "Новые запросы",
"activeChats": "Разговоры",
"new": "Новый"
},
"settings": {
"title": "Настройки",