diff --git a/assets/controllers/article_comments_controller.js b/assets/controllers/article_comments_controller.js index 336e3e6..5d1ddd0 100644 --- a/assets/controllers/article_comments_controller.js +++ b/assets/controllers/article_comments_controller.js @@ -1,7 +1,16 @@ import { Controller } from '@hotwired/stimulus'; /** - * Fetches the comment thread HTML after the article shell has rendered (no relay I/O on first paint). + * Two-phase comment loading: + * + * Phase 1 — fires ?cached=1 immediately, shows whatever is in the server-side cache + * without touching any Nostr relays (< 100 ms on a warm cache). + * + * Phase 2 — fires the full URL in parallel, which does relay I/O. When it resolves + * it replaces the Phase-1 content. If Phase 2 finishes first (e.g. the + * cached response was held up by DNS), Phase 1 is silently discarded. + * + * Result: readers always see something quickly; fresh relay data appears when ready. */ export default class extends Controller { static values = { @@ -13,8 +22,7 @@ export default class extends Controller { connect() { this.partialReloads = 0; - // Stable reference across reconnects: rebinding each connect() would strand old listeners - // because removeEventListener must use the same function reference that was passed to add. + // Stable reference across reconnects: rebinding each connect() would strand old listeners. this.boundOnAuth ??= this.onAuthChanged.bind(this); window.removeEventListener('unfold:auth-changed', this.boundOnAuth); window.addEventListener('unfold:auth-changed', this.boundOnAuth); @@ -22,9 +30,8 @@ export default class extends Controller { return; } if (this.preloadedValue) { - // Article SSR already included comments. Do not re-fetch: a slow or dropped - // request would replace working HTML with a generic error. Re-fetch on auth - // only (reply UI may need fresh permission state). + // Article SSR already included comments (cache hit at render time). Do not re-fetch; + // a slow relay request would only replace working HTML. Auth changes may still reload. return; } void this.load(); @@ -43,13 +50,23 @@ export default class extends Controller { void this.load(); } - buildFetchUrl() { + /** Append ?cb= (and optional extras) to bust HTTP caches. */ + buildFetchUrl(extra = '') { const u = this.urlValue; - const bust = `cb=${Date.now()}`; - return u.includes('?') ? `${u}&${bust}` : `${u}?${bust}`; + const parts = [`cb=${Date.now()}`, extra].filter(Boolean); + const qs = parts.join('&'); + return u.includes('?') ? `${u}&${qs}` : `${u}?${qs}`; } async load() { + // Track whether Phase 2 has already written to the DOM so Phase 1 never clobbers it. + this._fullFetchDone = false; + this.partialReloads = 0; + + // Phase 1: fire a cache-only request in the background — completes in < 100 ms. + void this._showCachedVersion(); + + // Phase 2: full relay fetch — replaces Phase 1 output when it resolves. const t0 = performance.now(); const perAttemptMs = 45_000; const maxAttempts = 3; @@ -57,7 +74,6 @@ export default class extends Controller { const controller = new AbortController(); const timer = window.setTimeout(() => controller.abort(), perAttemptMs); try { - // 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', @@ -68,10 +84,11 @@ export default class extends Controller { throw new Error(`HTTP ${res.status}`); } const html = await res.text(); + window.clearTimeout(timer); if (!this.hasContainerTarget) { - window.clearTimeout(timer); return; } + this._fullFetchDone = true; this.containerTarget.innerHTML = html; const isPartial = /data-comments-partial="1"/.test(html); if (isPartial && this.partialReloads < 2) { @@ -83,12 +100,10 @@ export default class extends Controller { }, 1200); } const ms = Math.round(performance.now() - t0); - if (attempt > 1) { - console.debug(`[article-comments] fragment OK in ${ms}ms (after ${attempt} attempts)`, this.urlValue); - } else { - console.debug(`[article-comments] fragment OK in ${ms}ms`, this.urlValue); - } - window.clearTimeout(timer); + console.debug( + `[article-comments] relay fetch OK in ${ms}ms${attempt > 1 ? ` (attempt ${attempt})` : ''}`, + this.urlValue, + ); return; } catch (err) { window.clearTimeout(timer); @@ -101,12 +116,35 @@ export default class extends Controller { continue; } const ms = Math.round(performance.now() - t0); - console.warn(`[article-comments] fragment failed after ${ms}ms`, this.urlValue, err); - if (this.hasContainerTarget) { + console.warn(`[article-comments] relay fetch failed after ${ms}ms`, this.urlValue, err); + // Only show the error if Phase 1 hasn't already displayed something useful. + if (this.hasContainerTarget && !this._fullFetchDone) { this.containerTarget.innerHTML = '

Comments could not be loaded.

'; } } } } + + /** Phase 1: return the server's cached copy immediately, without doing any relay I/O. */ + async _showCachedVersion() { + try { + const res = await fetch(this.buildFetchUrl('cached=1'), { + cache: 'no-store', + credentials: 'same-origin', + headers: { Accept: 'text/html', 'X-Requested-With': 'XMLHttpRequest' }, + }); + if (!res.ok || !this.hasContainerTarget || this._fullFetchDone) { + return; + } + const html = await res.text(); + // Re-check: Phase 2 may have landed while we were awaiting the body. + if (!this.hasContainerTarget || this._fullFetchDone) { + return; + } + this.containerTarget.innerHTML = html; + } catch { + // Ignore; Phase 2 will fill the container regardless. + } + } } diff --git a/assets/styles/app.css b/assets/styles/app.css index 53dd98e..77f1374 100644 --- a/assets/styles/app.css +++ b/assets/styles/app.css @@ -451,17 +451,17 @@ svg.icon { transform: scale(1.06); } -.featured-list--picture-grid .featured-tile__picture-img[src*="favicon-96x96"] { - object-fit: contain; - padding: 2rem; - box-sizing: border-box; +/* Suppress hover zoom when using the branded fallback — the faded portrait shouldn't animate */ +.featured-list--picture-grid .featured-tile--picture-block:has(.card-header--no-cover) .featured-tile__picture-img { transform: none; + transition: none; } .featured-list--picture-grid .featured-tile__picture-scrim { position: absolute; inset: 0; pointer-events: none; + z-index: 2; background: linear-gradient( to top, color-mix(in srgb, #0a0a0a 88%, transparent) 0%, @@ -476,7 +476,7 @@ svg.icon { right: 0; bottom: 0; padding: 0.65rem 0.85rem 0.75rem; - z-index: 1; + z-index: 3; display: flex; flex-direction: column; gap: 0.28rem; @@ -736,12 +736,36 @@ svg.icon { text-underline-offset: 2px; } -/* List cards: same site-logo treatment when the hero is the default mark */ -.article-list .card-header img[src*="favicon-96x96"] { - object-fit: contain; - padding: 1.25rem; - box-sizing: border-box; - background: var(--color-bg-light); +/* Fallback hero: portrait painting at low opacity with a diagonal stripe overlay. + Applied to any card container (.card-header, .featured-tile__media, etc.) when + the article has no cover image. The painting is cropped to the face — same + object-position as .header__logo-banner. */ +.card-header--no-cover { + position: relative; + overflow: hidden; + background-color: var(--color-bg-light); +} + +.card-header--no-cover img { + opacity: 0.18; + object-fit: cover; + object-position: 50% 14%; +} + +.card-header--no-cover::after { + content: ''; + position: absolute; + inset: 0; + background: + repeating-linear-gradient( + -45deg, + transparent 0, + transparent 5px, + rgba(0, 0, 0, 0.028) 5px, + rgba(0, 0, 0, 0.028) 6px + ); + pointer-events: none; + z-index: 1; } /* Optional category label above cover (see Molecules/Card) */ diff --git a/assets/styles/article.css b/assets/styles/article.css index 7da3768..e563d61 100644 --- a/assets/styles/article.css +++ b/assets/styles/article.css @@ -268,6 +268,13 @@ gap: 0.55rem; } +/* Empty-state label when the relay fetch returned zero comments */ +.comments__empty { + font-size: 0.9rem; + color: var(--color-text-mid); + margin: 0.5rem 0; +} + .comments .card.comment, .comments-quotes__list .card.comment { margin-left: 0; diff --git a/src/Controller/ArticleController.php b/src/Controller/ArticleController.php index 37a2cb5..df56627 100644 --- a/src/Controller/ArticleController.php +++ b/src/Controller/ArticleController.php @@ -61,16 +61,36 @@ class ArticleController extends AbstractController $articleTitle = substr($articleTitle, 0, 200); } + $headers = [ + 'Content-Type' => 'text/html; charset=UTF-8', + 'Cache-Control' => 'private, no-store', + ]; + + // Phase-1 fast path: return whatever is in the filesystem cache without touching relays. + // The JS fires this in parallel with the full relay request so readers see cached comments + // immediately (< 100 ms) while the relay fetch continues in the background. + if ($request->query->getBoolean('cached')) { + $cached = $loader->tryLoadFromCacheOnly($coordinate, $articleEventId); + if ($cached === null) { + // Cache miss — return an empty shell; the full relay fetch is already in flight. + // The article template already shows "Loading comments…" as the initial DOM state, + // so there is no need to repeat it here. + return new Response('
', Response::HTTP_OK, $headers); + } + try { + $data = $this->enrichCommentDataWithReplyContext($cached, $coordinate, $articleEventId, $articleTitle); + + return $this->render('components/Organisms/Comments.html.twig', $data, new Response('', Response::HTTP_OK, $headers)); + } catch (\Throwable) { + return new Response('
', Response::HTTP_OK, $headers); + } + } + $logger->info('http.fragment.comments_start', [ 'coordinate' => $coordinate, 'article_event_hex' => $articleEventId, ]); - $headers = [ - 'Content-Type' => 'text/html; charset=UTF-8', - 'Cache-Control' => 'private, max-age=60', - ]; - try { $data = $loader->load($coordinate, $articleEventId); $data = $this->enrichCommentDataWithReplyContext( diff --git a/src/Service/MagazineContentService.php b/src/Service/MagazineContentService.php index 4bd9ea2..49a4e68 100644 --- a/src/Service/MagazineContentService.php +++ b/src/Service/MagazineContentService.php @@ -200,7 +200,11 @@ final class MagazineContentService continue; } $parts = explode(':', (string) $seq[1], 3); - if (\count($parts) < 2) { + if (\count($parts) < 3) { + continue; + } + // Only longform article authors are featured authors; skip sub-index (30040) references. + if (!\in_array((int) $parts[0], KindsEnum::longformKindValues(), true)) { continue; } $pk = strtolower((string) $parts[1]); @@ -370,7 +374,7 @@ final class MagazineContentService * missing_total: int, * entries: listlogger->warning('nostr.article_discussion.sequential_fallback', [ 'relays' => $forSeq, ]); + // Use a shorter per-relay timeout for the web sequential fallback so one slow + // relay does not hold up the HTTP response for 3 × 12 s = 36 s. + // CLI prewarm still uses the full configured timeout via the normal path. + $seqTimeoutSec = min(6, $this->relayFanout->getRelayRequestTimeoutSec()); $response = $this->relayFanout->sendSequential( $this->relayListFactory->relaySetFromDistinctUrlList($forSeq), - $requestMessage + $requestMessage, + $seqTimeoutSec ); } } diff --git a/src/Service/NostrRelayFanoutTransport.php b/src/Service/NostrRelayFanoutTransport.php index a7d08a9..acd3cb7 100644 --- a/src/Service/NostrRelayFanoutTransport.php +++ b/src/Service/NostrRelayFanoutTransport.php @@ -35,6 +35,11 @@ final readonly class NostrRelayFanoutTransport ) { } + public function getRelayRequestTimeoutSec(): int + { + return $this->relayRequestFactory->getRelayRequestTimeoutSec(); + } + /** * @param list $relayUrls * @@ -58,9 +63,9 @@ final readonly class NostrRelayFanoutTransport * * @return array Same shape as {@see Request::send()} */ - public function sendSequential(RelaySet $relaySet, RequestMessage $requestMessage): array + public function sendSequential(RelaySet $relaySet, RequestMessage $requestMessage, ?int $overrideTimeoutSec = null): array { - $request = $this->relayRequestFactory->createTimedRequest($relaySet, $requestMessage); + $request = $this->relayRequestFactory->createTimedRequest($relaySet, $requestMessage, $overrideTimeoutSec); return $request->send(); } @@ -167,6 +172,7 @@ final readonly class NostrRelayFanoutTransport * One line per relay after {@see Request::send()}: errors vs message-type counts (EVENT, EOSE, …). * * @param array $response + * @param int|null $overrideTimeoutSec when set, overrides the configured per-relay WebSocket timeout */ public function logWireResponseSummary(string $context, array $response): void { diff --git a/src/Service/NostrRelayRequestFactory.php b/src/Service/NostrRelayRequestFactory.php index 5466fea..ba59629 100644 --- a/src/Service/NostrRelayRequestFactory.php +++ b/src/Service/NostrRelayRequestFactory.php @@ -26,12 +26,14 @@ final readonly class NostrRelayRequestFactory /** * {@see Request::setTimeout()} drives per-relay WebSocket I/O for {@see Request::send()}. + * + * @param int|null $overrideTimeoutSec when set, uses this instead of the configured default */ - public function createTimedRequest(RelaySet $relaySet, RequestMessage $requestMessage): Request + public function createTimedRequest(RelaySet $relaySet, RequestMessage $requestMessage, ?int $overrideTimeoutSec = null): Request { $request = new Request($relaySet, $requestMessage); - return $request->setTimeout($this->relayRequestTimeoutSec); + return $request->setTimeout($overrideTimeoutSec ?? $this->relayRequestTimeoutSec); } /** diff --git a/src/Twig/ArticleCardCoverExtension.php b/src/Twig/ArticleCardCoverExtension.php index 3bafab2..b95ef05 100644 --- a/src/Twig/ArticleCardCoverExtension.php +++ b/src/Twig/ArticleCardCoverExtension.php @@ -19,9 +19,9 @@ final class ArticleCardCoverExtension extends AbstractExtension { /** * Used when the article has no image and the author has no (or no usable) NIP-01 {@see picture} URL. - * Same asset as the header mark so empty hero slots read as the site, not a blank gray field. + * The portrait painting is shown at low opacity with a CSS pattern overlay (see `.card-header--no-cover`). */ - private const DEFAULT_PACKAGE_IMAGE = 'icons/favicon-96x96.png'; + private const DEFAULT_PACKAGE_IMAGE = 'laeserin_logo.png'; private const OG_FALLBACK_PACKAGE_IMAGE = 'og-image.jpg'; diff --git a/templates/components/Molecules/Card.html.twig b/templates/components/Molecules/Card.html.twig index 264d667..7a02f0f 100644 --- a/templates/components/Molecules/Card.html.twig +++ b/templates/components/Molecules/Card.html.twig @@ -12,7 +12,8 @@ {% endif %} -