4 changed files with 187 additions and 139 deletions
@ -1,124 +1,133 @@
@@ -1,124 +1,133 @@
|
||||
import { Controller } from "@hotwired/stimulus"; |
||||
|
||||
// Connects to data-controller="comments-mercure"
|
||||
/** |
||||
* Server-driven comments via Mercure + Symfony UX LiveComponent. |
||||
* |
||||
* Usage in Twig (root element is the LiveComponent root): |
||||
* <div {{ attributes }} |
||||
* data-controller="comments-mercure" |
||||
* data-comments-mercure-coordinate-value="{{ current }}" |
||||
* data-comments-mercure-target="list" |
||||
* data-comments-mercure-target="loading"> |
||||
* ... |
||||
* </div> |
||||
*/ |
||||
export default class extends Controller { |
||||
static values = { |
||||
coordinate: String |
||||
} |
||||
static targets = ["list", "loading"]; |
||||
|
||||
connect() { |
||||
const coordinate = this.coordinateValue; |
||||
const topic = `/comments/${coordinate}`; |
||||
const hubUrl = window.MercureHubUrl || (document.querySelector('meta[name="mercure-hub"]')?.content); |
||||
console.log('[comments-mercure] connect', { coordinate, topic, hubUrl }); |
||||
if (!hubUrl) return; |
||||
const url = new URL(hubUrl); |
||||
url.searchParams.append('topic', topic); |
||||
this.eventSource = new EventSource(url.toString()); |
||||
this.eventSource.onopen = () => { |
||||
console.log('[comments-mercure] EventSource opened', url.toString()); |
||||
}; |
||||
this.eventSource.onerror = (e) => { |
||||
console.error('[comments-mercure] EventSource error', e); |
||||
}; |
||||
this.eventSource.onmessage = (event) => { |
||||
console.log('[comments-mercure] Event received', event.data); |
||||
const data = JSON.parse(event.data); |
||||
this.profiles = data.profiles || {}; |
||||
if (this.hasLoadingTarget) this.loadingTarget.style.display = 'none'; |
||||
if (this.hasListTarget) { |
||||
if (data.comments && data.comments.length > 0) { |
||||
this.listTarget.innerHTML = data.comments.map((item) => { |
||||
const zapData = this.parseZapAmount(item) || {}; |
||||
const zapAmount = zapData.amount; |
||||
const zapperPubkey = zapData.zapper; |
||||
const parsedContent = this.parseContent(item.content); |
||||
const isZap = item.kind === 9735; |
||||
const displayPubkey = isZap ? (zapperPubkey || item.pubkey) : item.pubkey; |
||||
const profile = this.profiles[displayPubkey]; |
||||
const displayName = profile?.name || displayPubkey; |
||||
return `<div class="card comment ${isZap ? 'zap-comment' : ''}">
|
||||
<div class="metadata"> |
||||
<span><a href="/p/${displayPubkey}">${displayName}</a></span> |
||||
<small>${item.created_at ? new Date(item.created_at * 1000).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }) : ''}</small> |
||||
</div> |
||||
<div class="card-body"> |
||||
${isZap ? `<div class="zap-amount">${zapAmount ? `<strong>${zapAmount} sat</strong>` : '<em>Zap</em>'}</div>` : ''} |
||||
<div>${parsedContent}</div> |
||||
</div> |
||||
</div>`; |
||||
}).join(''); |
||||
} else { |
||||
this.listTarget.innerHTML = '<div class="no-comments">No comments yet.</div>'; |
||||
} |
||||
this.listTarget.style.display = ''; |
||||
} |
||||
}; |
||||
static values = { |
||||
coordinate: String, // e.g. "nevent1..." or your coordinate id
|
||||
}; |
||||
|
||||
static targets = ["list", "loading"]; |
||||
|
||||
connect() { |
||||
this._debounceId = null; |
||||
this._opened = false; |
||||
|
||||
// Initial paint: ask the LiveComponent to render once (gets cached HTML immediately)
|
||||
this._renderLiveComponent(); |
||||
|
||||
// Subscribe to Mercure for live updates
|
||||
const topic = `/comments/${this.coordinateValue}`; |
||||
const hubUrl = |
||||
window.MercureHubUrl || |
||||
document.querySelector('meta[name="mercure-hub"]')?.content; |
||||
|
||||
if (!hubUrl) { |
||||
console.warn( |
||||
"[comments-mercure] Missing Mercure hub URL (meta[name=mercure-hub])" |
||||
); |
||||
this._hideLoading(); |
||||
return; |
||||
} |
||||
|
||||
disconnect() { |
||||
if (this.eventSource) { |
||||
this.eventSource.close(); |
||||
console.log('[comments-mercure] EventSource closed'); |
||||
} |
||||
const url = new URL(hubUrl); |
||||
url.searchParams.append("topic", topic); |
||||
|
||||
this.eventSource = new EventSource(url.toString()); |
||||
this.eventSource.onopen = () => { |
||||
this._opened = true; |
||||
// When the connection opens, do a quick refresh to capture anything new
|
||||
this._debouncedRefresh(); |
||||
}; |
||||
this.eventSource.onerror = (e) => { |
||||
console.warn("[comments-mercure] EventSource error", e); |
||||
// Keep the UI usable even if Mercure hiccups
|
||||
this._hideLoading(); |
||||
}; |
||||
this.eventSource.onmessage = () => { |
||||
// We ignore the payload; Mercure is just a signal to re-render the live component
|
||||
this._debouncedRefresh(); |
||||
}; |
||||
} |
||||
|
||||
disconnect() { |
||||
if (this.eventSource) { |
||||
try { |
||||
this.eventSource.close(); |
||||
} catch {} |
||||
} |
||||
|
||||
parseContent(content) { |
||||
if (!content) return ''; |
||||
|
||||
// Escape HTML to prevent XSS
|
||||
let html = content.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); |
||||
|
||||
// Parse URLs
|
||||
html = html.replace(/(https?:\/\/[^\s]+)/g, '<a href="$1" target="_blank" rel="noopener">$1</a>'); |
||||
|
||||
// Parse Nostr npub
|
||||
html = html.replace(/\b(npub1[a-z0-9]+)\b/g, '<a href="/user/$1">$1</a>'); |
||||
|
||||
// Parse Nostr nevent
|
||||
html = html.replace(/\b(nevent1[a-z0-9]+)\b/g, '<a href="/event/$1">$1</a>'); |
||||
|
||||
// Parse Nostr nprofile
|
||||
html = html.replace(/\b(nprofile1[a-z0-9]+)\b/g, '<a href="/profile/$1">$1</a>'); |
||||
|
||||
// Parse Nostr note
|
||||
html = html.replace(/\b(note1[a-z0-9]+)\b/g, '<a href="/note/$1">$1</a>'); |
||||
|
||||
return html; |
||||
if (this._debounceId) { |
||||
clearTimeout(this._debounceId); |
||||
} |
||||
} |
||||
|
||||
// ---- private helpers -----------------------------------------------------
|
||||
|
||||
_debouncedRefresh(delay = 150) { |
||||
if (this._debounceId) clearTimeout(this._debounceId); |
||||
this._debounceId = setTimeout(() => { |
||||
this._renderLiveComponent(); |
||||
}, delay); |
||||
} |
||||
|
||||
_renderLiveComponent() { |
||||
// Show loading spinner (if present) only while we’re actually fetching
|
||||
this._showLoading(); |
||||
|
||||
// The live component controller is bound to the same root element.
|
||||
const liveRoot = |
||||
this.element.closest( |
||||
'[data-controller~="symfony--ux-live-component--live"]' |
||||
) || this.element; |
||||
|
||||
const liveController = |
||||
this.application.getControllerForElementAndIdentifier( |
||||
liveRoot, |
||||
"symfony--ux-live-component--live" |
||||
); |
||||
|
||||
if (!liveController || typeof liveController.render !== "function") { |
||||
console.warn( |
||||
"[comments-mercure] LiveComponent controller not found on element:", |
||||
liveRoot |
||||
); |
||||
this._hideLoading(); |
||||
return; |
||||
} |
||||
|
||||
parseZapAmount(item) { |
||||
if (item.kind !== 9735) return null; |
||||
|
||||
const tags = item.tags || []; |
||||
let amount = null; |
||||
let zapper = null; |
||||
|
||||
// Find zapper from 'p' tag
|
||||
const pTag = tags.find(tag => tag[0] === 'p'); |
||||
if (pTag && pTag[1]) { |
||||
zapper = pTag[1]; |
||||
} |
||||
|
||||
// Find amount in 'amount' tag (msat)
|
||||
const amountTag = tags.find(tag => tag[0] === 'amount'); |
||||
if (amountTag && amountTag[1]) { |
||||
const msat = parseInt(amountTag[1], 10); |
||||
amount = Math.floor(msat / 1000); // Convert to sat
|
||||
} |
||||
|
||||
// Fallback to description for content
|
||||
const descTag = tags.find(tag => tag[0] === 'description'); |
||||
if (descTag && descTag[1]) { |
||||
try { |
||||
const desc = JSON.parse(descTag[1]); |
||||
if (desc.content) { |
||||
item.content = desc.content; // Update content
|
||||
} |
||||
} catch (e) {} |
||||
} |
||||
|
||||
return { amount, zapper }; |
||||
// Ask server for the fresh HTML; morphdom will patch the DOM in place.
|
||||
// render() returns a Promise (in recent UX versions). Handle both cases.
|
||||
try { |
||||
const maybePromise = liveController.render(); |
||||
if (maybePromise && typeof maybePromise.then === "function") { |
||||
maybePromise.finally(() => this._hideLoading()); |
||||
} else { |
||||
// Older versions might not return a promise—hide the spinner soon.
|
||||
setTimeout(() => this._hideLoading(), 0); |
||||
} |
||||
} catch (e) { |
||||
console.error("[comments-mercure] live.render() failed", e); |
||||
this._hideLoading(); |
||||
} |
||||
} |
||||
|
||||
_showLoading() { |
||||
if (this.hasLoadingTarget) this.loadingTarget.style.display = ""; |
||||
if (this.hasListTarget) this.listTarget.style.opacity = "0.6"; |
||||
} |
||||
|
||||
_hideLoading() { |
||||
if (this.hasLoadingTarget) this.loadingTarget.style.display = "none"; |
||||
if (this.hasListTarget) this.listTarget.style.opacity = ""; |
||||
} |
||||
} |
||||
|
||||
Loading…
Reference in new issue