diff --git a/assets/controllers/comments_mercure_controller.js b/assets/controllers/comments_mercure_controller.js
index 0b2cb3b..e5d5fa9 100644
--- a/assets/controllers/comments_mercure_controller.js
+++ b/assets/controllers/comments_mercure_controller.js
@@ -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):
+ *
+ * ...
+ *
+ */
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 ``;
- }).join('');
- } else {
- this.listTarget.innerHTML = '';
- }
- 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, '>');
-
- // Parse URLs
- html = html.replace(/(https?:\/\/[^\s]+)/g, '$1');
-
- // Parse Nostr npub
- html = html.replace(/\b(npub1[a-z0-9]+)\b/g, '$1');
-
- // Parse Nostr nevent
- html = html.replace(/\b(nevent1[a-z0-9]+)\b/g, '$1');
-
- // Parse Nostr nprofile
- html = html.replace(/\b(nprofile1[a-z0-9]+)\b/g, '$1');
-
- // Parse Nostr note
- html = html.replace(/\b(note1[a-z0-9]+)\b/g, '$1');
-
- 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 = "";
+ }
}
diff --git a/src/Form/EditorType.php b/src/Form/EditorType.php
index 763d2b6..6aa54d5 100644
--- a/src/Form/EditorType.php
+++ b/src/Form/EditorType.php
@@ -46,7 +46,7 @@ class EditorType extends AbstractType
'required' => false,
'sanitize_html' => true,
'help' => 'Separate tags with commas, skip #',
- 'attr' => ['placeholder' => 'Add tags', 'class' => 'form-control']])
+ 'attr' => ['placeholder' => 'philosophy, nature, economics', 'class' => 'form-control']])
->add('clientTag', CheckboxType::class, [
'label' => 'Add client tag to article (Decent Newsroom)',
'required' => false,
diff --git a/src/Twig/Components/Organisms/Comments.php b/src/Twig/Components/Organisms/Comments.php
index c74da7b..51fc9bb 100644
--- a/src/Twig/Components/Organisms/Comments.php
+++ b/src/Twig/Components/Organisms/Comments.php
@@ -3,44 +3,81 @@
namespace App\Twig\Components\Organisms;
use App\Message\FetchCommentsMessage;
-use App\Service\NostrClient;
use App\Service\NostrLinkParser;
use App\Service\RedisCacheService;
+use Symfony\Component\Messenger\Exception\ExceptionInterface;
use Symfony\Component\Messenger\MessageBusInterface;
-use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
+use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
+use Symfony\UX\LiveComponent\Attribute\LiveProp;
+use Symfony\UX\LiveComponent\DefaultActionTrait;
-#[AsTwigComponent()]
+#[AsLiveComponent]
final class Comments
{
+ use DefaultActionTrait;
+
+ // Live input
+ #[LiveProp(writable: false)]
+ public string $current;
+
+ // same fields you used before (the template will read from the payload)
public array $list = [];
public array $commentLinks = [];
public array $processedContent = [];
public array $zapAmounts = [];
public array $zappers = [];
public array $authorsMetadata = [];
- public bool $loading = true;
-
- private MessageBusInterface $bus;
+ public bool $loading = true;
public function __construct(
- private readonly NostrClient $nostrClient,
private readonly NostrLinkParser $nostrLinkParser,
private readonly RedisCacheService $redisCacheService,
- MessageBusInterface $bus
- ) {
- $this->bus = $bus;
- }
+ private readonly MessageBusInterface $bus,
+ ) {}
/**
- * @throws \Exception
+ * Mount the component with the current coordinate
*/
- public function mount($current): void
+ public function mount(string $current): void
{
- // Instead of fetching comments directly, dispatch async message
+ $this->current = $current;
$this->loading = true;
- $this->list = [];
- $this->bus->dispatch(new FetchCommentsMessage($current));
- // The actual comments will be loaded via Mercure on the frontend
+
+ // Kick off (async) fetch to populate cache + publish Mercure
+ try {
+ $this->bus->dispatch(new FetchCommentsMessage($current));
+ } catch (ExceptionInterface) {
+ // Doing nothing for now
+ }
+ }
+
+ /** Expose a view model to the template; keeps all parsing server-side */
+ public function getPayload(): array
+ {
+ // Uses the helper we added earlier: getCommentsPayload($coordinate)
+ $payload = $this->redisCacheService->getCommentsPayload($this->current) ?? [
+ 'comments' => [],
+ 'profiles' => [],
+ 'zappers' => [],
+ 'zapAmounts' => [],
+ 'commentLinks' => [],
+ ];
+
+ // If your handler doesn’t compute zaps/links yet, reuse your helpers here:
+ $this->list = $payload['comments'];
+ $this->authorsMetadata = $payload['profiles'] ?? [];
+
+ $this->parseZaps(); // your existing method – fills $zapAmounts & $zappers
+ $this->parseNostrLinks(); // your existing method – fills $commentLinks & $processedContent
+
+ return [
+ 'list' => $this->list,
+ 'authorsMetadata' => $this->authorsMetadata,
+ 'zappers' => $this->zappers,
+ 'zapAmounts' => $this->zapAmounts,
+ 'commentLinks' => $this->commentLinks,
+ 'loading' => false,
+ ];
}
/**
@@ -100,7 +137,7 @@ final class Comments
$amountSats = null;
$amountMsatStr = $this->findTagValue($description->tags, 'amount');
- if ($amountMsatStr !== null && is_numeric($amountMsatStr)) {
+ if (is_numeric($amountMsatStr)) {
// amount in millisats per NIP-57
$msats = (int) $amountMsatStr;
if ($msats > 0) {
diff --git a/templates/components/Organisms/Comments.html.twig b/templates/components/Organisms/Comments.html.twig
index 7baccbe..e54a064 100644
--- a/templates/components/Organisms/Comments.html.twig
+++ b/templates/components/Organisms/Comments.html.twig
@@ -1,9 +1,11 @@
-