diff --git a/assets/controllers/article_comments_controller.js b/assets/controllers/article_comments_controller.js index dec992c..42e3a30 100644 --- a/assets/controllers/article_comments_controller.js +++ b/assets/controllers/article_comments_controller.js @@ -6,6 +6,7 @@ import { Controller } from '@hotwired/stimulus'; export default class extends Controller { static values = { url: String, + preloaded: { type: Boolean, default: false }, }; static targets = ['container']; @@ -14,6 +15,17 @@ export default class extends Controller { if (!this.hasContainerTarget || !this.urlValue) { return; } + if (this.preloadedValue) { + const run = () => { + void this.load(); + }; + if (typeof requestIdleCallback !== 'undefined') { + requestIdleCallback(run, { timeout: 15_000 }); + } else { + setTimeout(run, 2_000); + } + return; + } void this.load(); } diff --git a/assets/styles/app.css b/assets/styles/app.css index f5ddc5a..ba181f1 100644 --- a/assets/styles/app.css +++ b/assets/styles/app.css @@ -848,31 +848,6 @@ a:focus-visible { outline-offset: 2px; } -.home-subscribe { - margin-bottom: 1.75rem; - padding: 1rem 0 0; - border-bottom: 1px solid var(--color-border, rgba(0, 0, 0, 0.08)); -} - -.home-subscribe__title { - font-size: 1.15rem; - margin: 0 0 0.35rem; -} - -.home-subscribe__hint { - margin: 0 0 0.75rem; - font-size: 0.9rem; - color: var(--color-text); - opacity: 0.85; -} - -.home-subscribe__actions { - display: flex; - flex-wrap: wrap; - gap: 0.5rem 0.6rem; - margin-bottom: 1.25rem; -} - @media (max-width: 600px) { .header__logo .brand { font-size: clamp(0.95rem, 4.8vw, 1.25rem); diff --git a/assets/styles/layout.css b/assets/styles/layout.css index 4f9beba..d58eb24 100644 --- a/assets/styles/layout.css +++ b/assets/styles/layout.css @@ -204,15 +204,76 @@ dt { footer { background-color: var(--color-footer-bg); color: var(--color-footer-text); - text-align: center; - padding: 1em 0; + padding: 1.25rem 1rem 1.5rem; position: relative; width: 100%; border-top: 1px solid var(--color-border); } +.site-footer { + max-width: 1200px; + margin: 0 auto; + display: flex; + flex-direction: column; + align-items: stretch; + gap: 1.75rem; + text-align: left; +} + +.site-footer__syndication-title { + font-size: 1.1rem; + font-weight: 600; + margin: 0 0 0.35rem; +} + +.site-footer__syndication-hint { + margin: 0 0 0.75rem; + font-size: 0.9rem; + color: var(--color-text); + opacity: 0.9; + max-width: 40rem; +} + +.site-footer__syndication-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem 0.6rem; +} + +.site-footer__main { + text-align: center; +} + +.site-footer__legal { + margin: 1rem 0 0; + font-size: 0.95rem; +} + +@media (min-width: 900px) { + .site-footer { + flex-direction: row; + align-items: flex-start; + justify-content: space-between; + gap: 2rem 3rem; + } + + .site-footer__syndication { + flex: 0 1 50%; + } + + .site-footer__main { + flex: 0 1 auto; + min-width: min(20rem, 100%); + text-align: right; + } + + .site-footer__legal { + text-align: right; + } +} + footer .footer-links { - margin: 24px 0; + margin: 0 0 0.5rem; } .footer-links a { diff --git a/config/services.yaml b/config/services.yaml index 0eb5071..86ebdce 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -37,6 +37,9 @@ services: $articleRelayUrls: '%article_relays%' $profileRelayUrls: '%profile_relays%' $projectDir: '%kernel.project_dir%' + App\Service\ArticleCommentThreadLoader: + arguments: + $appCachePool: '@cache.app' App\Twig\FooterLinksExtension: arguments: $footerLinksPath: '%footer_links%' diff --git a/src/Controller/ArticleController.php b/src/Controller/ArticleController.php index 0b63952..44ade75 100644 --- a/src/Controller/ArticleController.php +++ b/src/Controller/ArticleController.php @@ -35,8 +35,8 @@ class ArticleController extends AbstractController { // {@see NostrClient::getArticleDiscussion} runs per-relay work in parallel CLI workers; allow headroom // for all processes + Symfony (45s was too low and caused an uncatchable max-execution fatal → HTTP 500). - @set_time_limit(120); - @ini_set('max_execution_time', '120'); + @set_time_limit(300); + @ini_set('max_execution_time', '300'); $t0 = microtime(true); $coordinate = $request->query->getString('coordinate'); @@ -56,35 +56,6 @@ class ArticleController extends AbstractController if (strlen($articleTitle) > 200) { $articleTitle = substr($articleTitle, 0, 200); } - $coordparts = explode(':', $coordinate, 3); - $articleKind = isset($coordparts[0]) && ctype_digit($coordparts[0]) ? (int) $coordparts[0] : 30023; - $articleAuthorPubkey = $coordparts[1] ?? ''; - - $articleReplyTags = null; - if ($articleAuthorPubkey !== '' && 64 === \strlen($articleAuthorPubkey) && ctype_xdigit($articleAuthorPubkey)) { - $articleReplyTags = Nip22CommentTags::forReplyToArticle($coordinate, $articleAuthorPubkey); - } - - $parentIdForNaddr = str_repeat('0', 64); - $articleParentId = $articleEventId ?? $parentIdForNaddr; - if ($articleEventId !== null && 64 === \strlen($articleEventId) && ctype_xdigit($articleEventId)) { - $articleParentId = $articleEventId; - } else { - $articleParentId = $parentIdForNaddr; - } - - $threadReplyRows = []; - $userMayReply = $this->isGranted('ROLE_USER'); - if ($userMayReply && $articleReplyTags !== null) { - $threadReplyRows[] = [ - 'mode' => 'article', - 'blurbLabel' => $articleTitle !== '' ? $articleTitle : 'Article', - 'parentKind' => $articleKind, - 'parentId' => $articleParentId, - 'authorPubkey' => $articleAuthorPubkey, - 'expectedTags' => $articleReplyTags, - ]; - } $logger->info('http.fragment.comments_start', [ 'coordinate' => $coordinate, @@ -98,69 +69,17 @@ class ArticleController extends AbstractController try { $data = $loader->load($coordinate, $articleEventId); - if ($userMayReply && $articleReplyTags !== null) { - /** @var array $list */ - $list = $data['list'] ?? []; - foreach ($list as $row) { - if (!\is_object($row)) { - continue; - } - $k = (int) ($row->kind ?? 0); - if ($k !== KindsEnum::COMMENTS->value) { - continue; - } - $cid = (string) ($row->id ?? ''); - $cpk = (string) ($row->pubkey ?? ''); - if ($cid === '' || 64 !== \strlen($cid) || !ctype_xdigit($cid)) { - continue; - } - if ($cpk === '' || 64 !== \strlen($cpk) || !ctype_xdigit($cpk)) { - continue; - } - $rawTags = json_decode(json_encode($row->tags ?? []), true); - if (!\is_array($rawTags)) { - $rawTags = []; - } - $snippet = trim((string) ($row->content ?? '')); - if (strlen($snippet) > 120) { - $snippet = substr($snippet, 0, 117).'…'; - } - if ($snippet === '') { - $snippet = 'Comment'; - } - try { - $expectedTags = Nip22CommentTags::forReplyToComment($cid, $cpk, $k, $rawTags); - } catch (\Throwable) { - continue; - } - $threadReplyRows[] = [ - 'mode' => 'comment', - 'blurbLabel' => $snippet, - 'parentKind' => $k, - 'parentId' => $cid, - 'authorPubkey' => $cpk, - 'expectedTags' => $expectedTags, - ]; - } - } + $data = $this->enrichCommentDataWithReplyContext( + $data, + $coordinate, + $articleEventId, + $articleTitle + ); $logger->info('http.fragment.comments_after_load', [ 'elapsed_ms' => (int) round((microtime(true) - $t0) * 1000), ]); $tRender = microtime(true); - $fragmentQuery = ['coordinate' => $coordinate, 'title' => $articleTitle]; - if ($articleEventId !== null) { - $fragmentQuery['e'] = $articleEventId; - } - $data['comment_reply_context'] = [ - 'can_publish' => $userMayReply, - 'coordinate' => $coordinate, - 'article_event_id' => $articleEventId, - 'parent_kind' => $articleKind, - 'rows' => $threadReplyRows, - 'fragment_url' => $this->generateUrl('article_comments_fragment', $fragmentQuery), - ]; - $response = $this->render('components/Organisms/Comments.html.twig', $data, new Response( '', Response::HTTP_OK, @@ -183,6 +102,127 @@ class ArticleController extends AbstractController } } + /** + * Adds `comment_reply_context` for the reply composer (same data as the HTML fragment, used for full-page SSR when cache hits). + * + * @param array{ + * list: array, + * quotes: array, + * commentLinks: array>, + * quoteLinks: array>, + * processedContent: array + * } $data + * + * @return array{ + * list: array, + * quotes: array, + * commentLinks: array>, + * quoteLinks: array>, + * processedContent: array, + * comment_reply_context: array{ + * can_publish: bool, + * coordinate: string, + * article_event_id: ?string, + * parent_kind: int, + * rows: array>, + * fragment_url: string + * } + * } + */ + private function enrichCommentDataWithReplyContext( + array $data, + string $coordinate, + ?string $articleEventId, + string $articleTitle + ): array { + $coordparts = explode(':', $coordinate, 3); + $articleKind = isset($coordparts[0]) && ctype_digit($coordparts[0]) ? (int) $coordparts[0] : 30023; + $articleAuthorPubkey = $coordparts[1] ?? ''; + + $articleReplyTags = null; + if ($articleAuthorPubkey !== '' && 64 === \strlen($articleAuthorPubkey) && ctype_xdigit($articleAuthorPubkey)) { + $articleReplyTags = Nip22CommentTags::forReplyToArticle($coordinate, $articleAuthorPubkey); + } + + $parentIdForNaddr = str_repeat('0', 64); + if ($articleEventId !== null && 64 === \strlen($articleEventId) && ctype_xdigit($articleEventId)) { + $articleParentId = $articleEventId; + } else { + $articleParentId = $parentIdForNaddr; + } + + $threadReplyRows = []; + $userMayReply = $this->isGranted('ROLE_USER'); + if ($userMayReply && $articleReplyTags !== null) { + $threadReplyRows[] = [ + 'mode' => 'article', + 'blurbLabel' => $articleTitle !== '' ? $articleTitle : 'Article', + 'parentKind' => $articleKind, + 'parentId' => $articleParentId, + 'authorPubkey' => $articleAuthorPubkey, + 'expectedTags' => $articleReplyTags, + ]; + /** @var array $list */ + $list = $data['list'] ?? []; + foreach ($list as $row) { + if (!\is_object($row)) { + continue; + } + $k = (int) ($row->kind ?? 0); + if ($k !== KindsEnum::COMMENTS->value) { + continue; + } + $cid = (string) ($row->id ?? ''); + $cpk = (string) ($row->pubkey ?? ''); + if ($cid === '' || 64 !== \strlen($cid) || !ctype_xdigit($cid)) { + continue; + } + if ($cpk === '' || 64 !== \strlen($cpk) || !ctype_xdigit($cpk)) { + continue; + } + $rawTags = json_decode(json_encode($row->tags ?? []), true); + if (!\is_array($rawTags)) { + $rawTags = []; + } + $snippet = trim((string) ($row->content ?? '')); + if (strlen($snippet) > 120) { + $snippet = substr($snippet, 0, 117).'…'; + } + if ($snippet === '') { + $snippet = 'Comment'; + } + try { + $expectedTags = Nip22CommentTags::forReplyToComment($cid, $cpk, $k, $rawTags); + } catch (\Throwable) { + continue; + } + $threadReplyRows[] = [ + 'mode' => 'comment', + 'blurbLabel' => $snippet, + 'parentKind' => $k, + 'parentId' => $cid, + 'authorPubkey' => $cpk, + 'expectedTags' => $expectedTags, + ]; + } + } + + $fragmentQuery = ['coordinate' => $coordinate, 'title' => $articleTitle]; + if ($articleEventId !== null) { + $fragmentQuery['e'] = $articleEventId; + } + $data['comment_reply_context'] = [ + 'can_publish' => $userMayReply, + 'coordinate' => $coordinate, + 'article_event_id' => $articleEventId, + 'parent_kind' => $articleKind, + 'rows' => $threadReplyRows, + 'fragment_url' => $this->generateUrl('article_comments_fragment', $fragmentQuery), + ]; + + return $data; + } + private static function isValidNostrCoordinate(string $coordinate): bool { $parts = explode(':', $coordinate, 3); @@ -243,7 +283,8 @@ class ArticleController extends AbstractController EntityManagerInterface $entityManager, CacheService $cacheService, CacheItemPoolInterface $articlesCache, - Converter $converter + Converter $converter, + ArticleCommentThreadLoader $commentThreadLoader ): Response { @@ -282,12 +323,34 @@ class ArticleController extends AbstractController $npub = $key->convertPublicKeyToBech32($article->getPubkey()); $author = $cacheService->getMetadata($npub); + $kind = $article->getKind()?->value ?? 30023; + $pubkey = (string) $article->getPubkey(); + $articleSlug = (string) ($article->getSlug() ?? $slug); + $coordinate = $kind.':'.$pubkey.':'.$articleSlug; + $eid = $article->getEventId(); + $eid = ($eid !== null && $eid !== '' && self::isValidHexEventId($eid)) ? $eid : null; + $articleTitle = (string) ($article->getTitle() ?? ''); + + $commentsData = null; + $commentsPreloaded = false; + $cached = $commentThreadLoader->tryLoadFromCacheOnly($coordinate, $eid); + if (null !== $cached) { + $commentsData = $this->enrichCommentDataWithReplyContext( + $cached, + $coordinate, + $eid, + $articleTitle + ); + $commentsPreloaded = true; + } return $this->render('pages/article.html.twig', [ 'article' => $article, 'author' => $author, 'npub' => $npub, 'content' => $cacheItem->get(), + 'comments_data' => $commentsData, + 'comments_preloaded' => $commentsPreloaded, ]); } @@ -450,6 +513,7 @@ class ArticleController extends AbstractController 'article' => $article, 'content' => $content, 'author' => $user->getMetadata(), + 'comments_preloaded' => false, ]); } diff --git a/src/Controller/DefaultController.php b/src/Controller/DefaultController.php index d822bbc..611ea6c 100644 --- a/src/Controller/DefaultController.php +++ b/src/Controller/DefaultController.php @@ -21,17 +21,8 @@ class DefaultController extends AbstractController #[Route('/', name: 'home')] public function index(): Response { - $categoriesForFeed = []; - foreach ($this->magazineContent->getCategorySlugsFromStore() as $slug) { - $categoriesForFeed[] = [ - 'slug' => $slug, - 'title' => $this->magazineContent->getCategoryDisplayTitle($slug), - ]; - } - return $this->render('home.html.twig', [ 'indices' => $this->magazineContent->getHomeCategoryIndexTags(), - 'categories_for_feed' => $categoriesForFeed, ]); } diff --git a/src/Service/ArticleCommentThreadLoader.php b/src/Service/ArticleCommentThreadLoader.php index ee09227..3a226db 100644 --- a/src/Service/ArticleCommentThreadLoader.php +++ b/src/Service/ArticleCommentThreadLoader.php @@ -4,6 +4,8 @@ declare(strict_types=1); namespace App\Service; +use Psr\Cache\CacheItemPoolInterface; +use Psr\Cache\InvalidArgumentException; use Psr\Log\LoggerInterface; use Symfony\Contracts\Cache\CacheInterface; use Symfony\Contracts\Cache\ItemInterface; @@ -13,14 +15,52 @@ use Symfony\Contracts\Cache\ItemInterface; */ final readonly class ArticleCommentThreadLoader { + /** PSR-6 pool backing {@see $cache}; used for true cache-only reads (SSR) without invoking Nostr. */ public function __construct( private NostrClient $nostrClient, private NostrLinkParser $nostrLinkParser, private CacheInterface $cache, + private CacheItemPoolInterface $appCachePool, private LoggerInterface $logger, ) { } + /** + * @return array{ + * list: array, + * quotes: array, + * commentLinks: array>, + * quoteLinks: array>, + * processedContent: array + * }|null + */ + public function tryLoadFromCacheOnly(string $coordinate, ?string $articleEventHexId = null): ?array + { + $key = $this->cacheKeyForThread($coordinate, $articleEventHexId); + try { + $item = $this->appCachePool->getItem($key); + } catch (InvalidArgumentException) { + return null; + } + if (!$item->isHit()) { + return null; + } + $discussion = $item->get(); + if (!\is_array($discussion)) { + return null; + } + if (($discussion['thread'] ?? []) === [] && ($discussion['quotes'] ?? []) === []) { + $this->logger->info('comments.loader.cache_hit_empty', ['coordinate' => $coordinate]); + } else { + $this->logger->info('comments.loader.cache_hit_only', [ + 'coordinate' => $coordinate, + 'thread' => \count($discussion['thread'] ?? []), + ]); + } + + return $this->expandFromDiscussion($discussion, microtime(true)); + } + /** * @return array{ * list: array, @@ -33,8 +73,7 @@ final readonly class ArticleCommentThreadLoader public function load(string $coordinate, ?string $articleEventHexId = null): array { $t0 = microtime(true); - $aggrSuffix = $this->nostrClient->getNostrLandAggrReaderCacheSuffix(); - $cacheKey = 'comments_v5_'.hash('sha256', $coordinate."\0".($articleEventHexId ?? '')."\0".$aggrSuffix); + $cacheKey = $this->cacheKeyForThread($coordinate, $articleEventHexId); $this->logger->info('comments.loader.start', [ 'cache_key_suffix' => substr($cacheKey, -16), 'coordinate' => $coordinate, @@ -43,7 +82,8 @@ final readonly class ArticleCommentThreadLoader try { $discussion = $this->cache->get($cacheKey, function (ItemInterface $item) use ($coordinate, $articleEventHexId, $t0): array { - $item->expiresAfter(120); + // Prewarm + HTTP should share the same key; 2m expiry caused cold misses during normal use. + $item->expiresAfter(86400); $this->logger->info('comments.loader.cache_miss', [ 'elapsed_since_load_start_ms' => (int) round((microtime(true) - $t0) * 1000), ]); @@ -66,6 +106,31 @@ final readonly class ArticleCommentThreadLoader $discussion = ['thread' => [], 'quotes' => []]; } + return $this->expandFromDiscussion($discussion, $t0); + } + + /** + * Same key for CLI prewarm, anonymous, and logged-in readers so cached threads are shared. + * (Relay selection for misses may still add aggr for signed-in users in {@see NostrClient::getArticleDiscussion}.) + */ + private function cacheKeyForThread(string $coordinate, ?string $articleEventHexId): string + { + return 'comments_v5_'.hash('sha256', $coordinate."\0".($articleEventHexId ?? '')); + } + + /** + * @param array{thread: array, quotes: array} $discussion + * + * @return array{ + * list: array, + * quotes: array, + * commentLinks: array>, + * quoteLinks: array>, + * processedContent: array + * } + */ + private function expandFromDiscussion(array $discussion, float $t0): array + { $list = $discussion['thread'] ?? []; $quotes = $discussion['quotes'] ?? []; $this->logger->info('comments.loader.cache_resolved', [ diff --git a/src/Twig/Components/Footer.php b/src/Twig/Components/Footer.php index 58bd34d..c51ce1f 100644 --- a/src/Twig/Components/Footer.php +++ b/src/Twig/Components/Footer.php @@ -4,11 +4,28 @@ declare(strict_types=1); namespace App\Twig\Components; +use App\Service\MagazineContentService; use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; #[AsTwigComponent] -class Footer { - public function __construct() +class Footer +{ + /** @var list */ + public array $categoriesForFeed = []; + + public function __construct( + private readonly MagazineContentService $magazineContent, + ) { + } + + public function mount(): void { + $this->categoriesForFeed = []; + foreach ($this->magazineContent->getCategorySlugsFromStore() as $slug) { + $this->categoriesForFeed[] = [ + 'slug' => $slug, + 'title' => $this->magazineContent->getCategoryDisplayTitle($slug), + ]; + } } } diff --git a/templates/components/Footer.html.twig b/templates/components/Footer.html.twig index d0b4b0b..795534b 100644 --- a/templates/components/Footer.html.twig +++ b/templates/components/Footer.html.twig @@ -1,13 +1,29 @@ -