Browse Source

fix threading

imwald
Silberengel 7 days ago
parent
commit
514bedc02f
  1. 17
      assets/controllers/article_comments_controller.js
  2. 44
      assets/controllers/comment_reply_controller.js
  3. 4
      assets/controllers/login_controller.js
  4. 92
      assets/styles/article.css
  5. 3
      src/Controller/ArticleController.php
  6. 9
      src/Controller/CommentReplyController.php
  7. 136
      src/Service/ArticleCommentThreadLoader.php
  8. 178
      templates/components/Organisms/Comments.html.twig
  9. 1
      templates/pages/article.html.twig

17
assets/controllers/article_comments_controller.js

@ -12,6 +12,8 @@ export default class extends Controller {
static targets = ['container']; static targets = ['container'];
connect() { connect() {
this.boundOnAuth = this.onAuthChanged.bind(this);
window.addEventListener('unfold:auth-changed', this.boundOnAuth);
if (!this.hasContainerTarget || !this.urlValue) { if (!this.hasContainerTarget || !this.urlValue) {
return; return;
} }
@ -20,15 +22,26 @@ export default class extends Controller {
void this.load(); void this.load();
}; };
if (typeof requestIdleCallback !== 'undefined') { if (typeof requestIdleCallback !== 'undefined') {
requestIdleCallback(run, { timeout: 15_000 }); requestIdleCallback(run, { timeout: 8_000 });
} else { } else {
setTimeout(run, 2_000); setTimeout(run, 800);
} }
return; return;
} }
void this.load(); void this.load();
} }
disconnect() {
window.removeEventListener('unfold:auth-changed', this.boundOnAuth);
}
onAuthChanged() {
if (!this.hasContainerTarget || !this.urlValue) {
return;
}
void this.load();
}
async load() { async load() {
const t0 = performance.now(); const t0 = performance.now();
try { try {

44
assets/controllers/comment_reply_controller.js

@ -1,10 +1,10 @@
import { Controller } from '@hotwired/stimulus'; import { Controller } from '@hotwired/stimulus';
/** /**
* Builds a NIP-22 kind-1111 event (blurb + body), signs with NIP-07, POSTs to /comment/publish. * NIP-22 kind-1111 reply: optional collapsed panel (Reply button), sign with NIP-07, POST, refresh thread.
*/ */
export default class extends Controller { export default class extends Controller {
static targets = ['hint']; static targets = ['hint', 'panel', 'toggleBtn'];
static values = { static values = {
publishUrl: String, publishUrl: String,
@ -32,6 +32,21 @@ export default class extends Controller {
} }
} }
togglePanel() {
if (!this.hasPanelTarget) {
return;
}
const hidden = this.panelTarget.classList.toggle('comment-reply__panel--hidden');
const open = !hidden;
if (this.hasToggleBtnTarget) {
this.toggleBtnTarget.setAttribute('aria-expanded', open ? 'true' : 'false');
}
if (open) {
const ta = this.panelTarget.querySelector('textarea[name="body"]');
requestAnimationFrame(() => ta?.focus());
}
}
/** /**
* @param {Event} ev * @param {Event} ev
*/ */
@ -41,7 +56,8 @@ export default class extends Controller {
this.setHint('Install a Nostr extension (NIP-07) to sign comments.'); this.setHint('Install a Nostr extension (NIP-07) to sign comments.');
return; return;
} }
const ta = this.element.querySelector('textarea[name="body"]'); const root = this.hasPanelTarget ? this.panelTarget : this.element;
const ta = root.querySelector('textarea[name="body"]');
const text = (ta?.value ?? '').trim(); const text = (ta?.value ?? '').trim();
if (!text) { if (!text) {
this.setHint('Write something first.'); this.setHint('Write something first.');
@ -99,10 +115,16 @@ export default class extends Controller {
this.setHint(data.error || `HTTP ${res.status}`); this.setHint(data.error || `HTTP ${res.status}`);
return; return;
} }
this.setHint('Published. It may take a short time to show on all relays.'); this.setHint('Published.');
if (ta) { if (ta) {
ta.value = ''; ta.value = '';
} }
if (this.hasPanelTarget) {
this.panelTarget.classList.add('comment-reply__panel--hidden');
if (this.hasToggleBtnTarget) {
this.toggleBtnTarget.setAttribute('aria-expanded', 'false');
}
}
if (this.refreshAfterValue && this.fragmentUrlValue) { if (this.refreshAfterValue && this.fragmentUrlValue) {
this.refreshThread(); this.refreshThread();
} }
@ -133,13 +155,19 @@ export default class extends Controller {
} }
refreshThread() { refreshThread() {
const el = document.querySelector('[data-article-comments-url-value]'); const wrap = this.element.closest('[data-article-comments-wrapper]');
const u = el?.getAttribute('data-article-comments-url-value'); const url =
const container = document.querySelector('[data-article-comments-target="container"]'); wrap?.getAttribute('data-article-comments-url-value') ||
if (!u || !container) { document.querySelector('[data-article-comments-wrapper]')?.getAttribute('data-article-comments-url-value');
const container =
wrap?.querySelector('[data-article-comments-target="container"]') ||
document.querySelector('[data-article-comments-target="container"]');
if (!url || !container) {
window.location.reload(); window.location.reload();
return; return;
} }
const bust = `cb=${Date.now()}`;
const u = url.includes('?') ? `${url}&${bust}` : `${url}?${bust}`;
void fetch(u, { headers: { Accept: 'text/html', 'X-Requested-With': 'XMLHttpRequest' } }) void fetch(u, { headers: { Accept: 'text/html', 'X-Requested-With': 'XMLHttpRequest' } })
.then((r) => (r.ok ? r.text() : Promise.reject(new Error(String(r.status))))) .then((r) => (r.ok ? r.text() : Promise.reject(new Error(String(r.status)))))
.then((html) => { .then((html) => {

4
assets/controllers/login_controller.js

@ -31,6 +31,10 @@ export default class extends Controller {
}) })
if (!!result) { if (!!result) {
this.component.render(); this.component.render();
window.dispatchEvent(
new CustomEvent('unfold:auth-changed', { detail: { loggedIn: true } })
);
} }
} }
} }

92
assets/styles/article.css

@ -128,6 +128,49 @@ blockquote p {
gap: 0.35rem; gap: 0.35rem;
} }
/* Thread depth: light indent, max visual level 3 (deeper uses --depth-3) */
.comments .card.comment--depth-0 {
margin-left: 0;
}
.comments .card.comment--depth-1 {
margin-left: 0.28rem;
}
.comments .card.comment--depth-2 {
margin-left: 0.6rem;
}
.comments .card.comment--depth-3 {
margin-left: 0.95rem;
}
.comment__reply-blurb {
padding: 0.5rem 0.75rem 0.35rem;
margin: 0 0 0 0.25rem;
border-left: 3px solid var(--color-border, rgba(128, 128, 128, 0.45));
background: var(--color-bg-light, rgba(0, 0, 0, 0.12));
border-radius: 0 4px 4px 0;
font-size: 0.95em;
line-height: 1.45;
}
.comment__reply-blurb blockquote,
.comment__reply-blurb :where(blockquote) {
border-left: none;
margin: 0;
padding-left: 0;
}
.comment__reply-blurb blockquote p,
.comment__reply-blurb :where(blockquote) p {
font-size: inherit;
line-height: inherit;
font-style: normal;
margin: 0;
padding-left: 0;
}
.visually-hidden { .visually-hidden {
position: absolute; position: absolute;
width: 1px; width: 1px;
@ -153,13 +196,53 @@ blockquote p {
border-top: 1px solid var(--color-border); border-top: 1px solid var(--color-border);
} }
.comment-reply--article__inner {
padding: 0.9rem 1rem 1rem;
}
.comment-reply__toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.5rem 0.75rem;
margin-bottom: 0.35rem;
}
.comment-reply__lede {
margin: 0;
font-size: 0.9rem;
line-height: 1.35;
flex: 1 1 12rem;
min-width: 0;
}
.comment-reply__toolbar--inline {
margin-bottom: 0.25rem;
margin-top: 0.5rem;
justify-content: flex-end;
}
.comment-reply__heading { .comment-reply__heading {
font-size: 1.05rem; font-size: 1.05rem;
margin: 0 0 0.75rem; margin: 0;
}
.comment-reply__panel {
margin-top: 0.6rem;
padding: 0.75rem 0.8rem 0.85rem;
border-radius: 6px;
background: var(--color-bg-light, rgba(0, 0, 0, 0.2));
border: 1px solid var(--color-border);
box-sizing: border-box;
}
.comment-reply__panel--hidden {
display: none;
} }
.comment-reply--nested { .comment-reply--nested {
margin-top: 1rem; margin-top: 0.5rem;
} }
.comment-reply__head { .comment-reply__head {
@ -171,13 +254,14 @@ blockquote p {
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
box-sizing: border-box; box-sizing: border-box;
padding: 0.5rem 0.65rem; padding: 0.6rem 0.75rem;
margin: 0;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: 6px; border-radius: 6px;
background: var(--color-bg); background: var(--color-bg);
color: var(--color-text); color: var(--color-text);
font: inherit; font: inherit;
line-height: 1.45; line-height: 1.5;
min-height: 4.5rem; min-height: 4.5rem;
resize: vertical; resize: vertical;
} }

3
src/Controller/ArticleController.php

@ -184,7 +184,8 @@ class ArticleController extends AbstractController
if (!\is_array($rawTags)) { if (!\is_array($rawTags)) {
$rawTags = []; $rawTags = [];
} }
$snippet = trim((string) ($row->content ?? '')); $forSnippet = (string) ($row->unfold_body ?? $row->content ?? '');
$snippet = trim($forSnippet);
if (strlen($snippet) > 120) { if (strlen($snippet) > 120) {
$snippet = substr($snippet, 0, 117).'…'; $snippet = substr($snippet, 0, 117).'…';
} }

9
src/Controller/CommentReplyController.php

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Controller; namespace App\Controller;
use App\Entity\User; use App\Entity\User;
use App\Service\ArticleCommentThreadLoader;
use App\Service\CommentReplyService; use App\Service\CommentReplyService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\JsonResponse;
@ -22,7 +23,7 @@ final class CommentReplyController extends AbstractController
*/ */
#[Route('/comment/publish', name: 'comment_reply_publish', methods: ['POST'])] #[Route('/comment/publish', name: 'comment_reply_publish', methods: ['POST'])]
#[IsGranted('ROLE_USER')] #[IsGranted('ROLE_USER')]
public function publish(Request $request, CommentReplyService $commentReply): JsonResponse public function publish(Request $request, CommentReplyService $commentReply, ArticleCommentThreadLoader $commentThreadLoader): JsonResponse
{ {
$raw = $request->getContent(); $raw = $request->getContent();
if ($raw === '') { if ($raw === '') {
@ -47,6 +48,12 @@ final class CommentReplyController extends AbstractController
$out = $commentReply->publishFromRequestPayload($user, $data); $out = $commentReply->publishFromRequestPayload($user, $data);
if ($out['ok'] === true) { if ($out['ok'] === true) {
$coord = $data['expected_coordinate'] ?? null;
if (\is_string($coord) && $coord !== '') {
$eid = isset($data['article_event_id']) && \is_string($data['article_event_id']) && $data['article_event_id'] !== '' ? $data['article_event_id'] : null;
$commentThreadLoader->invalidateThread($coord, 64 === \strlen((string) $eid) && ctype_xdigit((string) $eid) ? $eid : null);
}
return $this->json(['ok' => true, 'id' => $out['id']]); return $this->json(['ok' => true, 'id' => $out['id']]);
} }

136
src/Service/ArticleCommentThreadLoader.php

@ -33,6 +33,9 @@ final readonly class ArticleCommentThreadLoader
* quoteLinks: array<string, array<int, mixed>>, * quoteLinks: array<string, array<int, mixed>>,
* processedContent: array<string, string> * processedContent: array<string, string>
* }|null * }|null
*
* Each object in `list` may be enriched with: unfold_reply_blurb, unfold_body, unfold_depth
* (0–3, for UI indentation).
*/ */
public function tryLoadFromCacheOnly(string $coordinate, ?string $articleEventHexId = null): ?array public function tryLoadFromCacheOnly(string $coordinate, ?string $articleEventHexId = null): ?array
{ {
@ -58,7 +61,7 @@ final readonly class ArticleCommentThreadLoader
]); ]);
} }
return $this->expandFromDiscussion($discussion, microtime(true)); return $this->expandFromDiscussion($discussion, microtime(true), $articleEventHexId);
} }
/** /**
@ -69,6 +72,8 @@ final readonly class ArticleCommentThreadLoader
* quoteLinks: array<string, array<int, mixed>>, * quoteLinks: array<string, array<int, mixed>>,
* processedContent: array<string, string> * processedContent: array<string, string>
* } * }
*
* @see self::tryLoadFromCacheOnly() for list object enrichments
*/ */
public function load(string $coordinate, ?string $articleEventHexId = null): array public function load(string $coordinate, ?string $articleEventHexId = null): array
{ {
@ -106,7 +111,19 @@ final readonly class ArticleCommentThreadLoader
$discussion = ['thread' => [], 'quotes' => []]; $discussion = ['thread' => [], 'quotes' => []];
} }
return $this->expandFromDiscussion($discussion, $t0); return $this->expandFromDiscussion($discussion, $t0, $articleEventHexId);
}
/**
* Drop cached thread so the next load refetches from relays (e.g. after publishing a comment).
*/
public function invalidateThread(string $coordinate, ?string $articleEventHexId): void
{
$key = $this->cacheKeyForThread($coordinate, $articleEventHexId);
try {
$this->appCachePool->deleteItem($key);
} catch (InvalidArgumentException) {
}
} }
/** /**
@ -129,7 +146,7 @@ final readonly class ArticleCommentThreadLoader
* processedContent: array<string, string> * processedContent: array<string, string>
* } * }
*/ */
private function expandFromDiscussion(array $discussion, float $t0): array private function expandFromDiscussion(array $discussion, float $t0, ?string $articleEventHexId = null): array
{ {
$list = $discussion['thread'] ?? []; $list = $discussion['thread'] ?? [];
$quotes = $discussion['quotes'] ?? []; $quotes = $discussion['quotes'] ?? [];
@ -139,6 +156,8 @@ final readonly class ArticleCommentThreadLoader
'quote_events' => \count($quotes), 'quote_events' => \count($quotes),
]); ]);
$this->enrichThreadListForDisplay($list, $articleEventHexId);
$commentLinks = []; $commentLinks = [];
$quoteLinks = []; $quoteLinks = [];
$processedContent = []; $processedContent = [];
@ -202,4 +221,115 @@ final readonly class ArticleCommentThreadLoader
$linkBucket[$idKey] = $links; $linkBucket[$idKey] = $links;
} }
} }
/**
* Adds reply blurb / body split and capped thread depth (0–3) on each thread event for Twig/CSS.
*
* @param array<int, object> $list
*/
private function enrichThreadListForDisplay(array $list, ?string $articleEventHexId): void
{
$threadIdSet = [];
foreach ($list as $ev) {
$hid = isset($ev->id) ? (string) $ev->id : '';
if ($hid !== '') {
$threadIdSet[$hid] = true;
}
}
$parentOf = [];
foreach ($list as $ev) {
$id = isset($ev->id) ? (string) $ev->id : '';
if ($id === '') {
continue;
}
$p = $this->resolveParentCommentId($ev, $threadIdSet, $articleEventHexId);
if ($p !== null) {
$parentOf[$id] = $p;
}
}
foreach ($list as $ev) {
$id = isset($ev->id) ? (string) $ev->id : '';
$raw = isset($ev->content) ? (string) $ev->content : '';
$split = $this->splitNip22ReplyBlurb($raw);
$ev->unfold_reply_blurb = $split['blurb'];
$ev->unfold_body = $split['body'];
$ev->unfold_depth = $id === '' ? 0 : $this->threadDepthCapped($id, $parentOf, 3);
}
}
/**
* @return array{blurb: string|null, body: string}
*/
private function splitNip22ReplyBlurb(string $content): array
{
if (!str_contains($content, "\n\n")) {
return ['blurb' => null, 'body' => $content];
}
$parts = explode("\n\n", $content, 2);
$first = trim((string) ($parts[0] ?? ''));
$rest = (string) ($parts[1] ?? '');
if ($first === '' || !str_starts_with($first, '>')) {
return ['blurb' => null, 'body' => $content];
}
if (!str_contains($first, 'nostr:')) {
return ['blurb' => null, 'body' => $content];
}
return ['blurb' => $first, 'body' => $rest];
}
/**
* NIP-22 nested replies use a lowercase `e` tag for the immediate parent comment; root comments
* under the article usually have no such tag. Some clients also use `E` for the article root.
*
* @param array<string, true> $threadIdSet
*/
private function resolveParentCommentId(object $event, array $threadIdSet, ?string $articleEventHexId): ?string
{
$selfId = isset($event->id) ? (string) $event->id : '';
$last = null;
foreach ($event->tags ?? [] as $tag) {
if (!\is_array($tag) || \count($tag) < 2) {
continue;
}
if ((string) ($tag[0] ?? '') !== 'e') {
continue;
}
$pid = (string) ($tag[1] ?? '');
if (64 !== \strlen($pid) || !ctype_xdigit($pid)) {
continue;
}
if ($selfId !== '' && hash_equals($pid, $selfId)) {
continue;
}
if ($articleEventHexId !== null && $articleEventHexId !== '' && hash_equals($pid, $articleEventHexId)) {
continue;
}
if (isset($threadIdSet[$pid])) {
$last = $pid;
}
}
return $last;
}
/**
* @param array<string, string> $parentOf child id => parent id
*/
private function threadDepthCapped(string $id, array $parentOf, int $max): int
{
$depth = 0;
$current = $id;
for ($i = 0; $i < 64; ++$i) {
if (!isset($parentOf[$current])) {
break;
}
$current = $parentOf[$current];
++$depth;
}
return $depth > $max ? $max : $depth;
}
} }

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

@ -2,42 +2,55 @@
{% if ctx and ctx.can_publish|default(false) and ctx.rows|default([])|length > 0 %} {% if ctx and ctx.can_publish|default(false) and ctx.rows|default([])|length > 0 %}
{% for row in ctx.rows %} {% for row in ctx.rows %}
{% if row.mode|default('') == 'article' %} {% if row.mode|default('') == 'article' %}
<div class="comment-reply comment-reply--article card"> <div
<div class="card-body"> class="comment-reply comment-reply--article card"
<h3 class="comment-reply__heading">Reply to this article</h3> data-controller="comment-reply"
<form data-comment-reply-publish-url-value="{{ path('comment_reply_publish')|e('html_attr') }}"
class="comment-reply__form" data-comment-reply-csrf-value="{{ csrf_token('comment_reply')|e('html_attr') }}"
data-controller="comment-reply" data-comment-reply-expected-coordinate-value="{{ ctx.coordinate|e('html_attr') }}"
data-action="submit->comment-reply#publish" data-comment-reply-article-event-id-value="{{ ctx.article_event_id|default('')|e('html_attr') }}"
data-comment-reply-publish-url-value="{{ path('comment_reply_publish')|e('html_attr') }}" data-comment-reply-fragment-url-value="{{ ctx.fragment_url|default('')|e('html_attr') }}"
data-comment-reply-csrf-value="{{ csrf_token('comment_reply')|e('html_attr') }}" data-comment-reply-refresh-after-value="1"
data-comment-reply-expected-coordinate-value="{{ ctx.coordinate|e('html_attr') }}" data-comment-reply-blurb-label-value="{{ row.blurbLabel|default('Article')|e('html_attr') }}"
data-comment-reply-article-event-id-value="{{ ctx.article_event_id|default('')|e('html_attr') }}" data-comment-reply-expected-tags-value='{{ row.expectedTags|default([])|json_encode|e('html_attr') }}'
data-comment-reply-fragment-url-value="{{ ctx.fragment_url|default('')|e('html_attr') }}" data-comment-reply-parent-kind-value="{{ row.parentKind|default(0) }}"
data-comment-reply-refresh-after-value="1" data-comment-reply-parent-id-value="{{ row.parentId|default('')|e('html_attr') }}"
data-comment-reply-blurb-label-value="{{ row.blurbLabel|default('Article')|e('html_attr') }}" data-comment-reply-author-pubkey-value="{{ row.authorPubkey|default('')|e('html_attr') }}"
data-comment-reply-expected-tags-value='{{ row.expectedTags|default([])|json_encode|e('html_attr') }}' >
data-comment-reply-parent-kind-value="{{ row.parentKind|default(0) }}" <div class="card-body comment-reply--article__inner">
data-comment-reply-parent-id-value="{{ row.parentId|default('')|e('html_attr') }}" <div class="comment-reply__toolbar">
data-comment-reply-author-pubkey-value="{{ row.authorPubkey|default('')|e('html_attr') }}" <p class="comment-reply__lede text-subtle">Reply to this note on Nostr (kind 1111).</p>
> <button
<div class="comment-reply__body"> type="button"
<label class="visually-hidden" for="comment-reply-article-body">Your reply</label> class="btn btn-secondary btn-sm comment-reply__toggle"
<textarea data-comment-reply-target="toggleBtn"
class="form-control" data-action="click->comment-reply#togglePanel"
id="comment-reply-article-body" aria-expanded="false"
name="body" >Reply</button>
rows="4" </div>
required <div class="comment-reply__panel comment-reply__panel--hidden" data-comment-reply-target="panel">
minlength="1" <form
placeholder="Write a Nostr comment (kind 1111). A quoted parent line is added automatically." class="comment-reply__form"
></textarea> data-action="submit->comment-reply#publish"
</div> >
<div class="comment-reply__actions"> <div class="comment-reply__body">
<button class="btn btn-primary" type="submit">Sign &amp; publish</button> <label class="visually-hidden" for="comment-reply-article-body">Your reply</label>
</div> <textarea
<p class="comment-reply__hint text-subtle" data-comment-reply-target="hint" aria-live="polite"></p> class="form-control"
</form> id="comment-reply-article-body"
name="body"
rows="4"
required
minlength="1"
placeholder="Write a NIP-22 comment (kind 1111). A quoted parent line is added when you publish."
></textarea>
</div>
<div class="comment-reply__actions">
<button class="btn btn-primary" type="submit">Sign &amp; publish</button>
</div>
<p class="comment-reply__hint text-subtle" data-comment-reply-target="hint" aria-live="polite"></p>
</form>
</div>
</div> </div>
</div> </div>
{% endif %} {% endif %}
@ -49,7 +62,8 @@
{% set cid = item.id|default('') %} {% set cid = item.id|default('') %}
{% set cpk = item.pubkey|default('') %} {% set cpk = item.pubkey|default('') %}
{% set cts = item.created_at|default(null) %} {% set cts = item.created_at|default(null) %}
<div class="card comment"> {% set cdepth = item.unfold_depth|default(0) %}
<div class="card comment comment--depth-{{ cdepth }}">
<div class="metadata"> <div class="metadata">
<p> <p>
{% if item.kind is defined and item.kind == 1 %} {% if item.kind is defined and item.kind == 1 %}
@ -61,8 +75,13 @@
</p> </p>
<small>{% if cts is not null and cts != '' %}{{ cts|date('F j Y') }}{% endif %}</small> <small>{% if cts is not null and cts != '' %}{{ cts|date('F j Y') }}{% endif %}</small>
</div> </div>
{% if item.unfold_reply_blurb|default('')|trim != '' %}
<div class="comment__reply-blurb" role="note" aria-label="Reply context">
<twig:Atoms:Content content="{{ item.unfold_reply_blurb }}" />
</div>
{% endif %}
<div class="card-body"> <div class="card-body">
<twig:Atoms:Content content="{{ item.content|default('') }}" /> <twig:Atoms:Content content="{{ item.unfold_body|default(item.content|default('')) }}" />
</div> </div>
{% if cid != '' and commentLinks[cid] is defined and commentLinks[cid]|length > 0 %} {% if cid != '' and commentLinks[cid] is defined and commentLinks[cid]|length > 0 %}
<div class="card-footer nostr-previews mt-3"> <div class="card-footer nostr-previews mt-3">
@ -78,41 +97,54 @@
{% if ctx and ctx.can_publish|default(false) and item.kind|default(0) == 1111 %} {% if ctx and ctx.can_publish|default(false) and item.kind|default(0) == 1111 %}
{% for row in ctx.rows|default([]) %} {% for row in ctx.rows|default([]) %}
{% if row.mode|default('') == 'comment' and row.parentId|default('') == cid %} {% if row.mode|default('') == 'comment' and row.parentId|default('') == cid %}
<div class="comment-reply comment-reply--nested"> <div
<form class="comment-reply comment-reply--nested"
class="comment-reply__form" data-controller="comment-reply"
data-controller="comment-reply" data-comment-reply-publish-url-value="{{ path('comment_reply_publish')|e('html_attr') }}"
data-action="submit->comment-reply#publish" data-comment-reply-csrf-value="{{ csrf_token('comment_reply')|e('html_attr') }}"
data-comment-reply-publish-url-value="{{ path('comment_reply_publish')|e('html_attr') }}" data-comment-reply-expected-coordinate-value="{{ ctx.coordinate|e('html_attr') }}"
data-comment-reply-csrf-value="{{ csrf_token('comment_reply')|e('html_attr') }}" data-comment-reply-article-event-id-value="{{ ctx.article_event_id|default('')|e('html_attr') }}"
data-comment-reply-expected-coordinate-value="{{ ctx.coordinate|e('html_attr') }}" data-comment-reply-fragment-url-value="{{ ctx.fragment_url|default('')|e('html_attr') }}"
data-comment-reply-article-event-id-value="{{ ctx.article_event_id|default('')|e('html_attr') }}" data-comment-reply-refresh-after-value="1"
data-comment-reply-fragment-url-value="{{ ctx.fragment_url|default('')|e('html_attr') }}" data-comment-reply-blurb-label-value="{{ row.blurbLabel|default('Comment')|e('html_attr') }}"
data-comment-reply-refresh-after-value="1" data-comment-reply-expected-tags-value='{{ row.expectedTags|default([])|json_encode|e('html_attr') }}'
data-comment-reply-blurb-label-value="{{ row.blurbLabel|default('Comment')|e('html_attr') }}" data-comment-reply-parent-kind-value="{{ row.parentKind|default(0) }}"
data-comment-reply-expected-tags-value='{{ row.expectedTags|default([])|json_encode|e('html_attr') }}' data-comment-reply-parent-id-value="{{ row.parentId|default('')|e('html_attr') }}"
data-comment-reply-parent-kind-value="{{ row.parentKind|default(0) }}" data-comment-reply-author-pubkey-value="{{ row.authorPubkey|default('')|e('html_attr') }}"
data-comment-reply-parent-id-value="{{ row.parentId|default('')|e('html_attr') }}" >
data-comment-reply-author-pubkey-value="{{ row.authorPubkey|default('')|e('html_attr') }}" <div class="comment-reply__toolbar comment-reply__toolbar--inline">
> <button
<div class="comment-reply__head text-subtle">Reply to this note</div> type="button"
<div class="comment-reply__body"> class="btn btn-secondary btn-sm comment-reply__toggle"
<label class="visually-hidden" for="comment-reply-{{ cid }}">Your reply</label> data-comment-reply-target="toggleBtn"
<textarea data-action="click->comment-reply#togglePanel"
class="form-control" aria-expanded="false"
id="comment-reply-{{ cid }}" >Reply</button>
name="body" </div>
rows="3" <div class="comment-reply__panel comment-reply__panel--hidden" data-comment-reply-target="panel">
required <form
minlength="1" class="comment-reply__form"
placeholder="Sign with your Nostr extension (kind 1111)…" data-action="submit->comment-reply#publish"
></textarea> >
</div> <div class="comment-reply__head text-subtle">Reply to this note</div>
<div class="comment-reply__actions"> <div class="comment-reply__body">
<button class="btn btn-primary" type="submit">Sign &amp; publish</button> <label class="visually-hidden" for="comment-reply-{{ cid }}">Your reply</label>
</div> <textarea
<p class="comment-reply__hint text-subtle" data-comment-reply-target="hint" aria-live="polite"></p> class="form-control"
</form> id="comment-reply-{{ cid }}"
name="body"
rows="3"
required
minlength="1"
placeholder="NIP-22 comment; parent quote line is added on publish…"
></textarea>
</div>
<div class="comment-reply__actions">
<button class="btn btn-primary" type="submit">Sign &amp; publish</button>
</div>
<p class="comment-reply__hint text-subtle" data-comment-reply-target="hint" aria-live="polite"></p>
</form>
</div>
</div> </div>
{% endif %} {% endif %}
{% endfor %} {% endfor %}

1
templates/pages/article.html.twig

@ -125,6 +125,7 @@
<section class="article-comments-async" id="article-comments" aria-label="Comments"> <section class="article-comments-async" id="article-comments" aria-label="Comments">
<div <div
data-controller="article-comments" data-controller="article-comments"
data-article-comments-wrapper
data-article-comments-url-value="{{ path('article_comments_fragment', comments_query)|e('html_attr') }}" data-article-comments-url-value="{{ path('article_comments_fragment', comments_query)|e('html_attr') }}"
data-article-comments-preloaded-value="{{ (comments_preloaded|default(false)) ? 'true' : 'false' }}" data-article-comments-preloaded-value="{{ (comments_preloaded|default(false)) ? 'true' : 'false' }}"
> >

Loading…
Cancel
Save