Browse Source

refine replies and reply-to

imwald
Silberengel 7 days ago
parent
commit
dcdb3bee1e
  1. 11
      assets/controllers/article_comments_controller.js
  2. 55
      assets/controllers/comment_reply_controller.js
  3. 2
      assets/controllers/login_controller.js
  4. 15
      assets/styles/article.css
  5. 25
      src/Service/ArticleCommentThreadLoader.php
  6. 2
      templates/components/Organisms/Comments.html.twig

11
assets/controllers/article_comments_controller.js

@ -37,6 +37,12 @@ export default class extends Controller {
void this.load(); void this.load();
} }
buildFetchUrl() {
const u = this.urlValue;
const bust = `cb=${Date.now()}`;
return u.includes('?') ? `${u}&${bust}` : `${u}?${bust}`;
}
async load() { async load() {
const t0 = performance.now(); const t0 = performance.now();
const perAttemptMs = 45_000; const perAttemptMs = 45_000;
@ -45,8 +51,11 @@ export default class extends Controller {
const controller = new AbortController(); const controller = new AbortController();
const timer = window.setTimeout(() => controller.abort(), perAttemptMs); const timer = window.setTimeout(() => controller.abort(), perAttemptMs);
try { 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, signal: controller.signal,
cache: 'no-store',
credentials: 'same-origin',
headers: { Accept: 'text/html', 'X-Requested-With': 'XMLHttpRequest' }, headers: { Accept: 'text/html', 'X-Requested-With': 'XMLHttpRequest' },
}); });
if (!res.ok) { if (!res.ok) {

55
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. // `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 { naddrEncode, neventEncode } = await import('nostr-tools/nip19');
const link = this.buildParentBech32(naddrEncode, neventEncode); 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 = { const unsigned = {
kind: 1111, kind: 1111,
created_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000),
@ -126,8 +127,10 @@ export default class extends Controller {
this.toggleBtnTarget.setAttribute('aria-expanded', 'false'); this.toggleBtnTarget.setAttribute('aria-expanded', 'false');
} }
} }
if (this.refreshAfterValue && this.fragmentUrlValue) { if (this.refreshAfterValue) {
this.refreshThread(); 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 wrap = this.element.closest('[data-article-comments-wrapper]');
const url = const url =
wrap?.getAttribute('data-article-comments-url-value') || wrap?.getAttribute('data-article-comments-url-value') ||
@ -168,16 +177,42 @@ export default class extends Controller {
window.location.reload(); window.location.reload();
return; return;
} }
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 bust = `cb=${Date.now()}`;
const u = url.includes('?') ? `${url}&${bust}` : `${url}?${bust}`; const u = url.includes('?') ? `${url}&${bust}` : `${url}?${bust}`;
void fetch(u, { headers: { Accept: 'text/html', 'X-Requested-With': 'XMLHttpRequest' } }) try {
.then((r) => (r.ok ? r.text() : Promise.reject(new Error(String(r.status))))) const res = await fetch(u, {
.then((html) => { 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; container.innerHTML = html;
}) if (!wantId) {
.catch(() => { return;
}
if (container.querySelector(`[data-event-id="${wantId}"]`)) {
return;
}
} catch {
if (round === maxRounds - 1) {
window.location.reload(); window.location.reload();
}); }
}
}
if (wantId) {
window.location.reload();
}
} }
/** /**

2
assets/controllers/login_controller.js

@ -30,7 +30,7 @@ export default class extends Controller {
return 'Authentication Successful'; return 'Authentication Successful';
}) })
if (!!result) { if (!!result) {
this.component.render(); await this.component.render();
window.dispatchEvent( window.dispatchEvent(
new CustomEvent('unfold:auth-changed', { detail: { loggedIn: true } }) new CustomEvent('unfold:auth-changed', { detail: { loggedIn: true } })
); );

15
assets/styles/article.css

@ -146,13 +146,14 @@ blockquote p {
} }
.comment__reply-blurb { .comment__reply-blurb {
padding: 0.5rem 0.75rem 0.35rem; padding: 0.2rem 0.45rem 0.2rem 0.5rem;
margin: 0 0 0 0.25rem; margin: 0 0 0 0.2rem;
border-left: 3px solid var(--color-border, rgba(128, 128, 128, 0.45)); border-left: 2px solid color-mix(in srgb, var(--color-border) 50%, transparent);
background: var(--color-bg-light, rgba(0, 0, 0, 0.12)); background: color-mix(in srgb, var(--color-bg-light) 55%, transparent);
border-radius: 0 4px 4px 0; border-radius: 0 3px 3px 0;
font-size: 0.95em; font-size: 0.78em;
line-height: 1.45; line-height: 1.35;
color: var(--color-text-mid);
} }
.comment__reply-blurb blockquote, .comment__reply-blurb blockquote,

25
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_body = $split['body'];
$ev->unfold_depth = $id === '' || !ctype_xdigit($id) ? 0 : $this->threadDepthCapped($id, $parentOf, 3); $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} * @return array{blurb: string|null, body: string}
*/ */

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

@ -65,7 +65,7 @@
{% set cts = item.created_at|default(null) %} {% set cts = item.created_at|default(null) %}
{% set cdepth = item.unfold_depth|default(0) %} {% set cdepth = item.unfold_depth|default(0) %}
{% set is_nip18_repost = item.kind is defined and (item.kind == 6 or item.kind == 16) %} {% set is_nip18_repost = item.kind is defined and (item.kind == 6 or item.kind == 16) %}
<div class="card comment comment--depth-{{ cdepth }}"> <div class="card comment comment--depth-{{ cdepth }}"{% if cid != '' %} data-event-id="{{ cid|e('html_attr') }}"{% endif %}>
<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 %}

Loading…
Cancel
Save