Browse Source

Refactor comments

imwald
Nuša Pukšič 3 months ago
parent
commit
1cf641052f
  1. 189
      assets/controllers/comments_mercure_controller.js
  2. 2
      src/Form/EditorType.php
  3. 69
      src/Twig/Components/Organisms/Comments.php
  4. 4
      templates/components/Organisms/Comments.html.twig

189
assets/controllers/comments_mercure_controller.js

@ -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
}
coordinate: String, // e.g. "nevent1..." or your coordinate id
};
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;
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;
}
const url = new URL(hubUrl);
url.searchParams.append('topic', topic);
url.searchParams.append("topic", topic);
this.eventSource = new EventSource(url.toString());
this.eventSource.onopen = () => {
console.log('[comments-mercure] EventSource opened', url.toString());
this._opened = true;
// When the connection opens, do a quick refresh to capture anything new
this._debouncedRefresh();
};
this.eventSource.onerror = (e) => {
console.error('[comments-mercure] EventSource error', e);
console.warn("[comments-mercure] EventSource error", e);
// Keep the UI usable even if Mercure hiccups
this._hideLoading();
};
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 = '';
}
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();
console.log('[comments-mercure] EventSource closed');
} catch {}
}
if (this._debounceId) {
clearTimeout(this._debounceId);
}
parseContent(content) {
if (!content) return '';
// Escape HTML to prevent XSS
let html = content.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
// 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;
}
parseZapAmount(item) {
if (item.kind !== 9735) return null;
// ---- private helpers -----------------------------------------------------
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];
_debouncedRefresh(delay = 150) {
if (this._debounceId) clearTimeout(this._debounceId);
this._debounceId = setTimeout(() => {
this._renderLiveComponent();
}, delay);
}
// 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
_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;
}
// Fallback to description for content
const descTag = tags.find(tag => tag[0] === 'description');
if (descTag && descTag[1]) {
// 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 desc = JSON.parse(descTag[1]);
if (desc.content) {
item.content = desc.content; // Update content
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();
}
} 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 = "";
}
}

2
src/Form/EditorType.php

@ -46,7 +46,7 @@ class EditorType extends AbstractType @@ -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,

69
src/Twig/Components/Organisms/Comments.php

@ -3,15 +3,24 @@ @@ -3,15 +3,24 @@
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 = [];
@ -20,27 +29,55 @@ final class Comments @@ -20,27 +29,55 @@ final class Comments
public array $authorsMetadata = [];
public bool $loading = true;
private MessageBusInterface $bus;
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 = [];
// Kick off (async) fetch to populate cache + publish Mercure
try {
$this->bus->dispatch(new FetchCommentsMessage($current));
// The actual comments will be loaded via Mercure on the frontend
} 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 @@ -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) {

4
templates/components/Organisms/Comments.html.twig

@ -1,4 +1,6 @@ @@ -1,4 +1,6 @@
<div class="comments"
<div
{{ attributes }}
class="comments"
data-controller="comments-mercure"
data-comments-mercure-coordinate-value="{{ current }}"
data-comments-mercure-target="root"

Loading…
Cancel
Save