From 1cf641052fa085d6a10e1d37e790b7cd4d182e13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nu=C5=A1a=20Puk=C5=A1i=C4=8D?= Date: Sun, 26 Oct 2025 16:54:25 +0100 Subject: [PATCH] Refactor comments --- .../comments_mercure_controller.js | 237 +++++++++--------- src/Form/EditorType.php | 2 +- src/Twig/Components/Organisms/Comments.php | 73 ++++-- .../components/Organisms/Comments.html.twig | 14 +- 4 files changed, 187 insertions(+), 139 deletions(-) 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 `
- -
- ${isZap ? `
${zapAmount ? `${zapAmount} sat` : 'Zap'}
` : ''} -
${parsedContent}
-
-
`; - }).join(''); - } else { - this.listTarget.innerHTML = '
No comments yet.
'; - } - 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 @@ -
+
{% if loading %}
Loading comments…
{% endif %}