Browse Source

Refactor comments

imwald
Nuša Pukšič 3 months ago
parent
commit
ae1decec6e
  1. 151
      assets/controllers/comments_mercure_controller.js
  2. 2
      src/Twig/Components/Organisms/Comments.php
  3. 83
      templates/components/Organisms/Comments.html.twig

151
assets/controllers/comments_mercure_controller.js

@ -1,135 +1,98 @@
import { Controller } from "@hotwired/stimulus"; import { Controller } from "@hotwired/stimulus";
/**
* 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, static targets = ["list", "loading"]
};
static targets = ["list", "loading"];
connect() { connect() {
console.log("[comments-mercure] Connecting to Mercure for comments at", this.coordinateValue);
this._debounceId = null; this._debounceId = null;
this._opened = false; this._liveRoot = this._findLiveRoot();
console.log(this._liveRoot);
// Initial paint: ask the LiveComponent to render once (gets cached HTML immediately)
this._renderLiveComponent(); // If the live controller isn't ready yet, wait for it.
if (!this._getLiveController()) {
// Subscribe to Mercure for live updates this._onLiveConnect = () => {
const topic = `/comments/${this.coordinateValue}`; // Once live connects, do an initial render to paint cached HTML
const hubUrl = this._renderLiveComponent();
window.MercureHubUrl || };
document.querySelector('meta[name="mercure-hub"]')?.content; this._liveRoot?.addEventListener('live:connect', this._onLiveConnect, { once: true });
} else {
// Live controller already attached -> initial render now
this._renderLiveComponent();
}
// Subscribe to Mercure updates
const hubUrl = window.MercureHubUrl || document.querySelector('meta[name="mercure-hub"]')?.content;
if (!hubUrl) { if (!hubUrl) {
console.warn( console.warn("[comments-mercure] Missing Mercure hub URL meta");
"[comments-mercure] Missing Mercure hub URL (meta[name=mercure-hub])"
);
this._hideLoading(); this._hideLoading();
return; return;
} }
const url = new URL(hubUrl); const topic = `/comments/${this.coordinateValue}`;
const url = new URL(hubUrl);
url.searchParams.append("topic", topic); url.searchParams.append("topic", topic);
this.eventSource = new EventSource(url.toString()); this.eventSource = new EventSource(url.toString());
this.eventSource.onopen = () => { this.eventSource.onopen = () => this._debouncedRefresh(50);
this._opened = true; this.eventSource.onerror = (e) => console.warn("[comments-mercure] EventSource error", e);
// When the connection opens, do a quick refresh to capture anything new this.eventSource.onmessage = () => this._debouncedRefresh();
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 = (e) => {
console.log('Mercure MSG', e.data);
// We ignore the payload; Mercure is just a signal to re-render the live component
this._debouncedRefresh();
};
} }
disconnect() { disconnect() {
if (this.eventSource) { if (this.eventSource) { try { this.eventSource.close(); } catch {} }
try { if (this._debounceId) { clearTimeout(this._debounceId); }
this.eventSource.close(); if (this._liveRoot && this._onLiveConnect) {
} catch {} this._liveRoot.removeEventListener('live:connect', this._onLiveConnect);
}
if (this._debounceId) {
clearTimeout(this._debounceId);
} }
} }
// ---- private helpers ----------------------------------------------------- // ---- private helpers -----------------------------------------------------
_findLiveRoot() {
// Works for both modern ("live") and older namespaced identifiers
return this.element.closest(
'[data-controller~="live"]'
);
}
_getLiveController() {
if (!this._liveRoot) return null;
// Try both identifiers
return (
this.application.getControllerForElementAndIdentifier(this._liveRoot, 'live') ||
this.application.getControllerForElementAndIdentifier(this._liveRoot, 'symfony--ux-live-component--live')
);
}
_debouncedRefresh(delay = 150) { _debouncedRefresh(delay = 150) {
if (this._debounceId) clearTimeout(this._debounceId); if (this._debounceId) clearTimeout(this._debounceId);
this._debounceId = setTimeout(() => { this._debounceId = setTimeout(() => this._renderLiveComponent(), delay);
this._renderLiveComponent();
}, delay);
} }
_renderLiveComponent() { _renderLiveComponent() {
// Show loading spinner (if present) only while we’re actually fetching const live = this._getLiveController();
this._showLoading(); if (!live || typeof live.render !== 'function') {
// Live not ready yet—try again very soon (and don't spam logs)
// The live component controller is bound to the same root element. setTimeout(() => this._renderLiveComponent(), 50);
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; return;
} }
// Ask server for the fresh HTML; morphdom will patch the DOM in place. this._showLoading();
// render() returns a Promise (in recent UX versions). Handle both cases. const p = live.render();
try { if (p && typeof p.finally === 'function') {
const maybePromise = liveController.render(); p.finally(() => this._hideLoading());
if (maybePromise && typeof maybePromise.then === "function") { } else {
maybePromise.finally(() => this._hideLoading()); setTimeout(() => this._hideLoading(), 0);
} 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() { _showLoading() {
if (this.hasLoadingTarget) this.loadingTarget.style.display = ""; if (this.hasLoadingTarget) this.loadingTarget.style.display = "";
if (this.hasListTarget) this.listTarget.style.opacity = "0.6"; if (this.hasListTarget) this.listTarget.style.opacity = "0.6";
} }
_hideLoading() { _hideLoading() {
if (this.hasLoadingTarget) this.loadingTarget.style.display = "none"; if (this.hasLoadingTarget) this.loadingTarget.style.display = "none";
if (this.hasListTarget) this.listTarget.style.opacity = ""; if (this.hasListTarget) this.listTarget.style.opacity = "";
} }
} }

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

@ -11,7 +11,7 @@ use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveProp; use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\DefaultActionTrait; use Symfony\UX\LiveComponent\DefaultActionTrait;
#[AsLiveComponent] #[AsLiveComponent('Organisms:Comments')]
final class Comments final class Comments
{ {
use DefaultActionTrait; use DefaultActionTrait;

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

@ -1,18 +1,21 @@
<div <div
{{ attributes }} {{ attributes }}
class="comments"
data-controller="comments_mercure"
data-comments-mercure-coordinate-value="{{ current }}"
data-comments-mercure-target="root"
id="comments-{{ current|e('html_attr') }}"
data-comments-coordinate="{{ current }}"> data-comments-coordinate="{{ current }}">
{% if loading %}
<div class="comments-loading" data-comments-mercure-target="loading">Loading comments…</div> <div
{% endif %} class="comments"
<div class="comments" data-comments-mercure-target="list" {% if loading %}style="display:none"{% endif %}> data-controller="comments-mercure"
{% for item in list %} data-comments-mercure-coordinate-value="{{ current }}"
<div class="card comment {% if item.kind is defined and item.kind == '9735' %}zap-comment{% endif %}"> data-comments-mercure-target="root"
<div class="metadata"> id="comments-{{ current|e('html_attr') }}">
{% if loading %}
<div class="comments-loading" data-comments-mercure-target="loading">Loading comments…</div>
{% endif %}
<div class="comments-list" data-comments-mercure-target="list" {% if loading %}style="display:none"{% endif %}>
{% for item in list %}
<div class="card comment {% if item.kind is defined and item.kind == '9735' %}zap-comment{% endif %}">
<div class="metadata">
{% if item.kind != '9735' %} {% if item.kind != '9735' %}
<twig:Molecules:UserFromNpub ident="{{ item.pubkey }}" :user="authorsMetadata[item.pubkey]" /> <twig:Molecules:UserFromNpub ident="{{ item.pubkey }}" :user="authorsMetadata[item.pubkey]" />
{% elseif zappers[item.id] %} {% elseif zappers[item.id] %}
@ -20,37 +23,39 @@
{% else %} {% else %}
<p><em>Unknown</em></p> <p><em>Unknown</em></p>
{% endif %} {% endif %}
<small>{{ item.created_at|date('F j Y') }}</small> <small>{{ item.created_at|date('F j Y') }}</small>
</div> </div>
<div class="card-body"> <div class="card-body">
{% if item.kind is defined and item.kind == '9735' %} {% if item.kind is defined and item.kind == '9735' %}
<div class="zap-amount"> <div class="zap-amount">
{% if zapAmounts[item.id] is defined %} {% if zapAmounts[item.id] is defined %}
<strong>{{ zapAmounts[item.id] }} sat</strong> <strong>{{ zapAmounts[item.id] }} sat</strong>
{% else %} {% else %}
<em>Zap</em> <em>Zap</em>
{% endif %} {% endif %}
</div>
{% endif %}
<twig:Atoms:Content content="{{ item.content }}" />
</div>
{# Display Nostr link previews if links detected #}
{% if commentLinks[item.id] is defined and commentLinks[item.id]|length > 0 %}
<div class="card-footer nostr-previews mt-3">
<div class="preview-container">
{% for link in commentLinks[item.id] %}
<div>
<twig:Molecules:NostrPreview preview="{{ link }}" />
</div>
{% endfor %}
</div>
</div> </div>
{% endif %} {% endif %}
<twig:Atoms:Content content="{{ item.content }}" />
</div> </div>
{% else %}
<div class="no-comments">No comments yet.</div>
{% endfor %}
</div>
{# Display Nostr link previews if links detected #}
{% if commentLinks[item.id] is defined and commentLinks[item.id]|length > 0 %}
<div class="card-footer nostr-previews mt-3">
<div class="preview-container">
{% for link in commentLinks[item.id] %}
<div>
<twig:Molecules:NostrPreview preview="{{ link }}" />
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
{% else %}
<div class="no-comments">No comments yet.</div>
{% endfor %}
</div> </div>
</div> </div>

Loading…
Cancel
Save