From dcdb3bee1e1c7a6aea1736ea436653f70e4ff962 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Thu, 23 Apr 2026 15:54:43 +0200 Subject: [PATCH] refine replies and reply-to --- .../article_comments_controller.js | 11 +++- .../controllers/comment_reply_controller.js | 61 +++++++++++++++---- assets/controllers/login_controller.js | 2 +- assets/styles/article.css | 15 ++--- src/Service/ArticleCommentThreadLoader.php | 25 +++++++- .../components/Organisms/Comments.html.twig | 2 +- 6 files changed, 92 insertions(+), 24 deletions(-) diff --git a/assets/controllers/article_comments_controller.js b/assets/controllers/article_comments_controller.js index cb1dc5a..ccffddf 100644 --- a/assets/controllers/article_comments_controller.js +++ b/assets/controllers/article_comments_controller.js @@ -37,6 +37,12 @@ export default class extends Controller { void this.load(); } + buildFetchUrl() { + const u = this.urlValue; + const bust = `cb=${Date.now()}`; + return u.includes('?') ? `${u}&${bust}` : `${u}?${bust}`; + } + async load() { const t0 = performance.now(); const perAttemptMs = 45_000; @@ -45,8 +51,11 @@ export default class extends Controller { const controller = new AbortController(); const timer = window.setTimeout(() => controller.abort(), perAttemptMs); try { - const res = await fetch(this.urlValue, { + // Avoid a stale 60s-cached "guest" fragment right after login (see comments fragment headers). + const res = await fetch(this.buildFetchUrl(), { signal: controller.signal, + cache: 'no-store', + credentials: 'same-origin', headers: { Accept: 'text/html', 'X-Requested-With': 'XMLHttpRequest' }, }); if (!res.ok) { diff --git a/assets/controllers/comment_reply_controller.js b/assets/controllers/comment_reply_controller.js index 345e346..91bd886 100644 --- a/assets/controllers/comment_reply_controller.js +++ b/assets/controllers/comment_reply_controller.js @@ -71,7 +71,8 @@ export default class extends Controller { // `nostr-tools` entry pulls @noble/curves (bare spec → breaks in AssetMapper). NIP-19 only needs bech32 helpers. const { naddrEncode, neventEncode } = await import('nostr-tools/nip19'); const link = this.buildParentBech32(naddrEncode, neventEncode); - const blurb = `> Replying to **${this.blurbLabelValue}** — [view parent](nostr:${link})\n\n`; + // NIP-22 quote line: must still mention nostr:… for server validation; UI strips this (see formatReplyBlurbForDisplay). + const blurb = `> Replying to **${this.blurbLabelValue}** (nostr:${link})\n\n`; const unsigned = { kind: 1111, created_at: Math.floor(Date.now() / 1000), @@ -126,8 +127,10 @@ export default class extends Controller { this.toggleBtnTarget.setAttribute('aria-expanded', 'false'); } } - if (this.refreshAfterValue && this.fragmentUrlValue) { - this.refreshThread(); + if (this.refreshAfterValue) { + const publishedId = + typeof data.id === 'string' && data.id ? data.id.toLowerCase() : ''; + void this.refreshThread(publishedId); } } @@ -156,7 +159,13 @@ export default class extends Controller { }); } - refreshThread() { + /** + * Reload the section HTML from the article comments fragment. After publishing, relays can lag; + * if `expectedEventIdHex` is set, re-fetch with backoff until the new note appears (or a cap is hit). + * + * @param {string} [expectedEventIdHex] lowercase 64-char hex + */ + async refreshThread(expectedEventIdHex = '') { const wrap = this.element.closest('[data-article-comments-wrapper]'); const url = wrap?.getAttribute('data-article-comments-url-value') || @@ -168,16 +177,42 @@ export default class extends Controller { 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) => { + const wantId = + expectedEventIdHex && /^[0-9a-f]{64}$/.test(expectedEventIdHex) ? expectedEventIdHex : ''; + const maxRounds = wantId ? 14 : 1; + for (let round = 0; round < maxRounds; round += 1) { + if (round > 0) { + const delay = Math.min(1400, 200 * 2 ** (round - 1)); + await new Promise((r) => setTimeout(r, delay)); + } + const bust = `cb=${Date.now()}`; + const u = url.includes('?') ? `${url}&${bust}` : `${url}?${bust}`; + try { + const res = await fetch(u, { + cache: 'no-store', + credentials: 'same-origin', + headers: { Accept: 'text/html', 'X-Requested-With': 'XMLHttpRequest' }, + }); + if (!res.ok) { + throw new Error(String(res.status)); + } + const html = await res.text(); container.innerHTML = html; - }) - .catch(() => { - window.location.reload(); - }); + if (!wantId) { + return; + } + if (container.querySelector(`[data-event-id="${wantId}"]`)) { + return; + } + } catch { + if (round === maxRounds - 1) { + window.location.reload(); + } + } + } + if (wantId) { + window.location.reload(); + } } /** diff --git a/assets/controllers/login_controller.js b/assets/controllers/login_controller.js index c1a1268..24f9e48 100644 --- a/assets/controllers/login_controller.js +++ b/assets/controllers/login_controller.js @@ -30,7 +30,7 @@ export default class extends Controller { return 'Authentication Successful'; }) if (!!result) { - this.component.render(); + await 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 fd0a160..ef5329d 100644 --- a/assets/styles/article.css +++ b/assets/styles/article.css @@ -146,13 +146,14 @@ blockquote p { } .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; + padding: 0.2rem 0.45rem 0.2rem 0.5rem; + margin: 0 0 0 0.2rem; + border-left: 2px solid color-mix(in srgb, var(--color-border) 50%, transparent); + background: color-mix(in srgb, var(--color-bg-light) 55%, transparent); + border-radius: 0 3px 3px 0; + font-size: 0.78em; + line-height: 1.35; + color: var(--color-text-mid); } .comment__reply-blurb blockquote, diff --git a/src/Service/ArticleCommentThreadLoader.php b/src/Service/ArticleCommentThreadLoader.php index 6592d6b..4c4fa11 100644 --- a/src/Service/ArticleCommentThreadLoader.php +++ b/src/Service/ArticleCommentThreadLoader.php @@ -313,12 +313,35 @@ final readonly class ArticleCommentThreadLoader } } } - $ev->unfold_reply_blurb = $blurb; + $ev->unfold_reply_blurb = $this->formatReplyBlurbForDisplay($blurb); $ev->unfold_body = $split['body']; $ev->unfold_depth = $id === '' || !ctype_xdigit($id) ? 0 : $this->threadDepthCapped($id, $parentOf, 3); } } + /** + * NIP-22 storage often includes a markdown link to the parent; hide that in the UI and show plain “replying to …” text. + */ + private function formatReplyBlurbForDisplay(?string $blurb): ?string + { + if ($blurb === null) { + return null; + } + $s = trim($blurb); + if ($s === '') { + return null; + } + $s = preg_replace('/\s*\[[^\]]+\]\(nostr:[^)]+\)/u', '', $s) ?? $s; + $s = preg_replace('/\s*\(nostr:[^)]+\)/u', '', $s) ?? $s; + $s = rtrim($s, " \t"); + $s = preg_replace('/\s*—\s*$/u', '', $s) ?? $s; + $s = rtrim($s, " \t"); + $s = preg_replace('/\*\*([^*]+)\*\*/u', '$1', $s) ?? $s; + $s = trim($s); + + return $s === '' ? null : $s; + } + /** * @return array{blurb: string|null, body: string} */ diff --git a/templates/components/Organisms/Comments.html.twig b/templates/components/Organisms/Comments.html.twig index 00f0341..4496677 100644 --- a/templates/components/Organisms/Comments.html.twig +++ b/templates/components/Organisms/Comments.html.twig @@ -65,7 +65,7 @@ {% set cts = item.created_at|default(null) %} {% set cdepth = item.unfold_depth|default(0) %} {% set is_nip18_repost = item.kind is defined and (item.kind == 6 or item.kind == 16) %} -
+