4 changed files with 187 additions and 139 deletions
@ -1,124 +1,133 @@ |
|||||||
import { Controller } from "@hotwired/stimulus"; |
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 { |
export default class extends Controller { |
||||||
static values = { |
static values = { |
||||||
coordinate: String |
coordinate: String, // e.g. "nevent1..." or your coordinate id
|
||||||
} |
}; |
||||||
static targets = ["list", "loading"]; |
|
||||||
|
static targets = ["list", "loading"]; |
||||||
connect() { |
|
||||||
const coordinate = this.coordinateValue; |
connect() { |
||||||
const topic = `/comments/${coordinate}`; |
this._debounceId = null; |
||||||
const hubUrl = window.MercureHubUrl || (document.querySelector('meta[name="mercure-hub"]')?.content); |
this._opened = false; |
||||||
console.log('[comments-mercure] connect', { coordinate, topic, hubUrl }); |
|
||||||
if (!hubUrl) return; |
// Initial paint: ask the LiveComponent to render once (gets cached HTML immediately)
|
||||||
const url = new URL(hubUrl); |
this._renderLiveComponent(); |
||||||
url.searchParams.append('topic', topic); |
|
||||||
this.eventSource = new EventSource(url.toString()); |
// Subscribe to Mercure for live updates
|
||||||
this.eventSource.onopen = () => { |
const topic = `/comments/${this.coordinateValue}`; |
||||||
console.log('[comments-mercure] EventSource opened', url.toString()); |
const hubUrl = |
||||||
}; |
window.MercureHubUrl || |
||||||
this.eventSource.onerror = (e) => { |
document.querySelector('meta[name="mercure-hub"]')?.content; |
||||||
console.error('[comments-mercure] EventSource error', e); |
|
||||||
}; |
if (!hubUrl) { |
||||||
this.eventSource.onmessage = (event) => { |
console.warn( |
||||||
console.log('[comments-mercure] Event received', event.data); |
"[comments-mercure] Missing Mercure hub URL (meta[name=mercure-hub])" |
||||||
const data = JSON.parse(event.data); |
); |
||||||
this.profiles = data.profiles || {}; |
this._hideLoading(); |
||||||
if (this.hasLoadingTarget) this.loadingTarget.style.display = 'none'; |
return; |
||||||
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 = ''; |
|
||||||
} |
|
||||||
}; |
|
||||||
} |
} |
||||||
|
|
||||||
disconnect() { |
const url = new URL(hubUrl); |
||||||
if (this.eventSource) { |
url.searchParams.append("topic", topic); |
||||||
this.eventSource.close(); |
|
||||||
console.log('[comments-mercure] EventSource closed'); |
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 {} |
||||||
} |
} |
||||||
|
if (this._debounceId) { |
||||||
parseContent(content) { |
clearTimeout(this._debounceId); |
||||||
if (!content) return ''; |
} |
||||||
|
} |
||||||
// Escape HTML to prevent XSS
|
|
||||||
let html = content.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); |
// ---- private helpers -----------------------------------------------------
|
||||||
|
|
||||||
// Parse URLs
|
_debouncedRefresh(delay = 150) { |
||||||
html = html.replace(/(https?:\/\/[^\s]+)/g, '<a href="$1" target="_blank" rel="noopener">$1</a>'); |
if (this._debounceId) clearTimeout(this._debounceId); |
||||||
|
this._debounceId = setTimeout(() => { |
||||||
// Parse Nostr npub
|
this._renderLiveComponent(); |
||||||
html = html.replace(/\b(npub1[a-z0-9]+)\b/g, '<a href="/user/$1">$1</a>'); |
}, delay); |
||||||
|
} |
||||||
// Parse Nostr nevent
|
|
||||||
html = html.replace(/\b(nevent1[a-z0-9]+)\b/g, '<a href="/event/$1">$1</a>'); |
_renderLiveComponent() { |
||||||
|
// Show loading spinner (if present) only while we’re actually fetching
|
||||||
// Parse Nostr nprofile
|
this._showLoading(); |
||||||
html = html.replace(/\b(nprofile1[a-z0-9]+)\b/g, '<a href="/profile/$1">$1</a>'); |
|
||||||
|
// The live component controller is bound to the same root element.
|
||||||
// Parse Nostr note
|
const liveRoot = |
||||||
html = html.replace(/\b(note1[a-z0-9]+)\b/g, '<a href="/note/$1">$1</a>'); |
this.element.closest( |
||||||
|
'[data-controller~="symfony--ux-live-component--live"]' |
||||||
return html; |
) || 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) { |
// Ask server for the fresh HTML; morphdom will patch the DOM in place.
|
||||||
if (item.kind !== 9735) return null; |
// render() returns a Promise (in recent UX versions). Handle both cases.
|
||||||
|
try { |
||||||
const tags = item.tags || []; |
const maybePromise = liveController.render(); |
||||||
let amount = null; |
if (maybePromise && typeof maybePromise.then === "function") { |
||||||
let zapper = null; |
maybePromise.finally(() => this._hideLoading()); |
||||||
|
} else { |
||||||
// Find zapper from 'p' tag
|
// Older versions might not return a promise—hide the spinner soon.
|
||||||
const pTag = tags.find(tag => tag[0] === 'p'); |
setTimeout(() => this._hideLoading(), 0); |
||||||
if (pTag && pTag[1]) { |
} |
||||||
zapper = pTag[1]; |
} catch (e) { |
||||||
} |
console.error("[comments-mercure] live.render() failed", e); |
||||||
|
this._hideLoading(); |
||||||
// 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 }; |
|
||||||
} |
} |
||||||
|
} |
||||||
|
|
||||||
|
_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