From 514bedc02f048d9151923d1edc7e62aca538f82a Mon Sep 17 00:00:00 2001 From: Silberengel Date: Thu, 23 Apr 2026 13:19:51 +0200 Subject: [PATCH] fix threading --- .../article_comments_controller.js | 17 +- .../controllers/comment_reply_controller.js | 44 ++++- assets/controllers/login_controller.js | 4 + assets/styles/article.css | 92 ++++++++- src/Controller/ArticleController.php | 3 +- src/Controller/CommentReplyController.php | 9 +- src/Service/ArticleCommentThreadLoader.php | 136 ++++++++++++- .../components/Organisms/Comments.html.twig | 178 +++++++++++------- templates/pages/article.html.twig | 1 + 9 files changed, 392 insertions(+), 92 deletions(-) diff --git a/assets/controllers/article_comments_controller.js b/assets/controllers/article_comments_controller.js index 42e3a30..c9000c0 100644 --- a/assets/controllers/article_comments_controller.js +++ b/assets/controllers/article_comments_controller.js @@ -12,6 +12,8 @@ export default class extends Controller { static targets = ['container']; connect() { + this.boundOnAuth = this.onAuthChanged.bind(this); + window.addEventListener('unfold:auth-changed', this.boundOnAuth); if (!this.hasContainerTarget || !this.urlValue) { return; } @@ -20,15 +22,26 @@ export default class extends Controller { void this.load(); }; if (typeof requestIdleCallback !== 'undefined') { - requestIdleCallback(run, { timeout: 15_000 }); + requestIdleCallback(run, { timeout: 8_000 }); } else { - setTimeout(run, 2_000); + setTimeout(run, 800); } return; } void this.load(); } + disconnect() { + window.removeEventListener('unfold:auth-changed', this.boundOnAuth); + } + + onAuthChanged() { + if (!this.hasContainerTarget || !this.urlValue) { + return; + } + void this.load(); + } + async load() { const t0 = performance.now(); try { diff --git a/assets/controllers/comment_reply_controller.js b/assets/controllers/comment_reply_controller.js index 6b4a885..d4a75df 100644 --- a/assets/controllers/comment_reply_controller.js +++ b/assets/controllers/comment_reply_controller.js @@ -1,10 +1,10 @@ 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 { - static targets = ['hint']; + static targets = ['hint', 'panel', 'toggleBtn']; static values = { 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 */ @@ -41,7 +56,8 @@ export default class extends Controller { this.setHint('Install a Nostr extension (NIP-07) to sign comments.'); 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(); if (!text) { this.setHint('Write something first.'); @@ -99,10 +115,16 @@ export default class extends Controller { this.setHint(data.error || `HTTP ${res.status}`); return; } - this.setHint('Published. It may take a short time to show on all relays.'); + this.setHint('Published.'); if (ta) { 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) { this.refreshThread(); } @@ -133,13 +155,19 @@ export default class extends Controller { } refreshThread() { - const el = document.querySelector('[data-article-comments-url-value]'); - const u = el?.getAttribute('data-article-comments-url-value'); - const container = document.querySelector('[data-article-comments-target="container"]'); - if (!u || !container) { + const wrap = this.element.closest('[data-article-comments-wrapper]'); + const url = + wrap?.getAttribute('data-article-comments-url-value') || + 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(); 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' } }) .then((r) => (r.ok ? r.text() : Promise.reject(new Error(String(r.status))))) .then((html) => { diff --git a/assets/controllers/login_controller.js b/assets/controllers/login_controller.js index 7e8da2f..c1a1268 100644 --- a/assets/controllers/login_controller.js +++ b/assets/controllers/login_controller.js @@ -31,6 +31,10 @@ export default class extends Controller { }) if (!!result) { this.component.render(); + window.dispatchEvent( + new CustomEvent('unfold:auth-changed', { detail: { loggedIn: true } }) + ); } } } + diff --git a/assets/styles/article.css b/assets/styles/article.css index c208f79..fd0a160 100644 --- a/assets/styles/article.css +++ b/assets/styles/article.css @@ -128,6 +128,49 @@ blockquote p { 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 { position: absolute; width: 1px; @@ -153,13 +196,53 @@ blockquote p { 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 { 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 { - margin-top: 1rem; + margin-top: 0.5rem; } .comment-reply__head { @@ -171,13 +254,14 @@ blockquote p { width: 100%; max-width: 100%; box-sizing: border-box; - padding: 0.5rem 0.65rem; + padding: 0.6rem 0.75rem; + margin: 0; border: 1px solid var(--color-border); border-radius: 6px; background: var(--color-bg); color: var(--color-text); font: inherit; - line-height: 1.45; + line-height: 1.5; min-height: 4.5rem; resize: vertical; } diff --git a/src/Controller/ArticleController.php b/src/Controller/ArticleController.php index 44ade75..0ee7be4 100644 --- a/src/Controller/ArticleController.php +++ b/src/Controller/ArticleController.php @@ -184,7 +184,8 @@ class ArticleController extends AbstractController if (!\is_array($rawTags)) { $rawTags = []; } - $snippet = trim((string) ($row->content ?? '')); + $forSnippet = (string) ($row->unfold_body ?? $row->content ?? ''); + $snippet = trim($forSnippet); if (strlen($snippet) > 120) { $snippet = substr($snippet, 0, 117).'…'; } diff --git a/src/Controller/CommentReplyController.php b/src/Controller/CommentReplyController.php index d8ff27b..807d17a 100644 --- a/src/Controller/CommentReplyController.php +++ b/src/Controller/CommentReplyController.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Controller; use App\Entity\User; +use App\Service\ArticleCommentThreadLoader; use App\Service\CommentReplyService; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; @@ -22,7 +23,7 @@ final class CommentReplyController extends AbstractController */ #[Route('/comment/publish', name: 'comment_reply_publish', methods: ['POST'])] #[IsGranted('ROLE_USER')] - public function publish(Request $request, CommentReplyService $commentReply): JsonResponse + public function publish(Request $request, CommentReplyService $commentReply, ArticleCommentThreadLoader $commentThreadLoader): JsonResponse { $raw = $request->getContent(); if ($raw === '') { @@ -47,6 +48,12 @@ final class CommentReplyController extends AbstractController $out = $commentReply->publishFromRequestPayload($user, $data); 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']]); } diff --git a/src/Service/ArticleCommentThreadLoader.php b/src/Service/ArticleCommentThreadLoader.php index 3a226db..342c0c3 100644 --- a/src/Service/ArticleCommentThreadLoader.php +++ b/src/Service/ArticleCommentThreadLoader.php @@ -33,6 +33,9 @@ final readonly class ArticleCommentThreadLoader * quoteLinks: array>, * processedContent: array * }|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 { @@ -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>, * processedContent: array * } + * + * @see self::tryLoadFromCacheOnly() for list object enrichments */ public function load(string $coordinate, ?string $articleEventHexId = null): array { @@ -106,7 +111,19 @@ final readonly class ArticleCommentThreadLoader $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 * } */ - private function expandFromDiscussion(array $discussion, float $t0): array + private function expandFromDiscussion(array $discussion, float $t0, ?string $articleEventHexId = null): array { $list = $discussion['thread'] ?? []; $quotes = $discussion['quotes'] ?? []; @@ -139,6 +156,8 @@ final readonly class ArticleCommentThreadLoader 'quote_events' => \count($quotes), ]); + $this->enrichThreadListForDisplay($list, $articleEventHexId); + $commentLinks = []; $quoteLinks = []; $processedContent = []; @@ -202,4 +221,115 @@ final readonly class ArticleCommentThreadLoader $linkBucket[$idKey] = $links; } } + + /** + * Adds reply blurb / body split and capped thread depth (0–3) on each thread event for Twig/CSS. + * + * @param array $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 $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 $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; + } } diff --git a/templates/components/Organisms/Comments.html.twig b/templates/components/Organisms/Comments.html.twig index 51308c8..cd956cb 100644 --- a/templates/components/Organisms/Comments.html.twig +++ b/templates/components/Organisms/Comments.html.twig @@ -2,42 +2,55 @@ {% if ctx and ctx.can_publish|default(false) and ctx.rows|default([])|length > 0 %} {% for row in ctx.rows %} {% if row.mode|default('') == 'article' %} -
-
-

Reply to this article

-
-
- - -
-
- -
-

-
+
+
+
+

Reply to this note on Nostr (kind 1111).

+ +
+
+
+
+ + +
+
+ +
+

+
+
{% endif %} @@ -49,7 +62,8 @@ {% set cid = item.id|default('') %} {% set cpk = item.pubkey|default('') %} {% set cts = item.created_at|default(null) %} -
+ {% set cdepth = item.unfold_depth|default(0) %} +
+ {% if item.unfold_reply_blurb|default('')|trim != '' %} +
+ +
+ {% endif %}
- +
{% if cid != '' and commentLinks[cid] is defined and commentLinks[cid]|length > 0 %}