diff --git a/config/services.yaml b/config/services.yaml index 5b32f32..2ef8a56 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -23,6 +23,7 @@ services: resource: '../src/' exclude: - '../src/DependencyInjection/' + - '../src/Dto/' - '../src/Entity/' - '../src/Kernel.php' diff --git a/src/Dto/FeaturedArticleCard.php b/src/Dto/FeaturedArticleCard.php new file mode 100644 index 0000000..27ebad1 --- /dev/null +++ b/src/Dto/FeaturedArticleCard.php @@ -0,0 +1,57 @@ +id; + } + + public function getSlug(): ?string + { + return $this->slug; + } + + public function getTitle(): ?string + { + return $this->title; + } + + public function getSummary(): ?string + { + return $this->summary; + } + + public function getImage(): ?string + { + return $this->image; + } + + public function getCreatedAt(): ?\DateTimeImmutable + { + return $this->createdAt; + } + + public function getPubkey(): ?string + { + return $this->pubkey; + } +} diff --git a/src/Repository/ArticleRepository.php b/src/Repository/ArticleRepository.php index 3f5b67e..86813ea 100644 --- a/src/Repository/ArticleRepository.php +++ b/src/Repository/ArticleRepository.php @@ -2,9 +2,11 @@ namespace App\Repository; +use App\Dto\FeaturedArticleCard; use App\Entity\Article; use App\Enum\EventStatusEnum; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; +use Doctrine\DBAL\ArrayParameterType; use Doctrine\DBAL\Exception; use Doctrine\Persistence\ManagerRegistry; @@ -53,20 +55,42 @@ class ArticleRepository extends ServiceEntityRepository } /** - * Find articles by multiple slugs + * List-card fields only: avoids loading `content` / `raw` (can be very large) for home/category featured rows. + * + * @return list */ - public function findBySlugsCriteria(array $slugs): array + public function findFeaturedCardsBySlugs(array $slugs): array { - if (empty($slugs)) { + if ($slugs === []) { return []; } - return $this->createQueryBuilder('a') - ->where('a.slug IN (:slugs)') - ->setParameter('slugs', $slugs) - ->orderBy('a.createdAt', 'DESC') - ->getQuery() - ->getResult(); + $conn = $this->getEntityManager()->getConnection(); + $qb = $conn->createQueryBuilder(); + $qb + ->select('a.id', 'a.slug', 'a.title', 'a.summary', 'a.image', 'a.created_at', 'a.pubkey') + ->from('article', 'a') + ->where($qb->expr()->in('a.slug', ':slugs')) + ->setParameter('slugs', $slugs, ArrayParameterType::STRING) + ->orderBy('a.created_at', 'DESC'); + + /** @var list> $rows */ + $rows = $qb->executeQuery()->fetchAllAssociative(); + $out = []; + foreach ($rows as $row) { + $ca = $row['created_at'] ?? null; + $out[] = new FeaturedArticleCard( + isset($row['id']) ? (int) $row['id'] : null, + isset($row['slug']) ? (string) $row['slug'] : null, + isset($row['title']) ? (string) $row['title'] : null, + isset($row['summary']) ? (string) $row['summary'] : null, + isset($row['image']) ? (string) $row['image'] : null, + $ca !== null && $ca !== '' ? new \DateTimeImmutable((string) $ca) : null, + isset($row['pubkey']) ? (string) $row['pubkey'] : null, + ); + } + + return $out; } /** diff --git a/src/Service/ArticleCommentThreadLoader.php b/src/Service/ArticleCommentThreadLoader.php index 342c0c3..3e169a7 100644 --- a/src/Service/ArticleCommentThreadLoader.php +++ b/src/Service/ArticleCommentThreadLoader.php @@ -12,9 +12,14 @@ use Symfony\Contracts\Cache\ItemInterface; /** * Loads Nostr article discussion: NIP-22 (1111) + legacy kind 1 replies, plus quotes/reposts (q / a tags). + * + * Reply blurbs mirror the jumble client: resolve the parent from `e` / `E` tags (NIP-10, `reply` marker, + * last-of-sequence), then show a short preview of the parent’s body (see jumble `ParentNotePreview`). Inline + * NIP-22 blockquotes with `nostr:` in the child still take precedence when present. */ final readonly class ArticleCommentThreadLoader { + private const PARENT_REPLY_TEXT_PREVIEW_MAX = 200; /** PSR-6 pool backing {@see $cache}; used for true cache-only reads (SSR) without invoking Nostr. */ public function __construct( private NostrClient $nostrClient, @@ -231,31 +236,51 @@ final readonly class ArticleCommentThreadLoader { $threadIdSet = []; foreach ($list as $ev) { - $hid = isset($ev->id) ? (string) $ev->id : ''; - if ($hid !== '') { + $hid = isset($ev->id) ? strtolower((string) $ev->id) : ''; + if (64 === \strlen($hid) && ctype_xdigit($hid)) { $threadIdSet[$hid] = true; } } + $idToEvent = []; + foreach ($list as $ev) { + $hid = isset($ev->id) ? strtolower((string) $ev->id) : ''; + if (64 === \strlen($hid) && ctype_xdigit($hid)) { + $idToEvent[$hid] = $ev; + } + } + $parentOf = []; foreach ($list as $ev) { - $id = isset($ev->id) ? (string) $ev->id : ''; - if ($id === '') { + $id = isset($ev->id) ? strtolower((string) $ev->id) : ''; + if (64 !== \strlen($id) || !ctype_xdigit($id)) { continue; } - $p = $this->resolveParentCommentId($ev, $threadIdSet, $articleEventHexId); + $p = $this->resolveInThreadParentId($ev, $threadIdSet, $articleEventHexId); if ($p !== null) { $parentOf[$id] = $p; } } foreach ($list as $ev) { - $id = isset($ev->id) ? (string) $ev->id : ''; + $id = isset($ev->id) ? strtolower((string) $ev->id) : ''; $raw = isset($ev->content) ? (string) $ev->content : ''; $split = $this->splitNip22ReplyBlurb($raw); - $ev->unfold_reply_blurb = $split['blurb']; + $blurb = $split['blurb']; + if (($blurb === null || trim($blurb) === '') && $id !== '' && isset($parentOf[$id])) { + $pid = $parentOf[$id]; + if (isset($idToEvent[$pid])) { + $parent = $idToEvent[$pid]; + $pRaw = isset($parent->content) ? (string) $parent->content : ''; + $preview = $this->parentEventTextPreviewForBlurb($pRaw); + if ($preview !== '') { + $blurb = '> *'.'Replying to thread'.'* — '."\n> ".$preview; + } + } + } + $ev->unfold_reply_blurb = $blurb; $ev->unfold_body = $split['body']; - $ev->unfold_depth = $id === '' ? 0 : $this->threadDepthCapped($id, $parentOf, 3); + $ev->unfold_depth = $id === '' || !ctype_xdigit($id) ? 0 : $this->threadDepthCapped($id, $parentOf, 3); } } @@ -281,38 +306,103 @@ final readonly class ArticleCommentThreadLoader } /** - * 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. + * Truncated single-line text from a parent’s content (strips a leading NIP-22 quote block when present), + * similar in spirit to Jumble’s {@see ParentNotePreview} + compact ContentPreview. + */ + private function parentEventTextPreviewForBlurb(string $raw): string + { + $split = $this->splitNip22ReplyBlurb($raw); + $use = (string) $split['body']; + if (trim($use) === '' && $raw !== '') { + $use = $raw; + } + $one = trim((string) (preg_replace('/\s+/', ' ', $use) ?? '')); + if ($one === '') { + return ''; + } + if (mb_strlen($one) > self::PARENT_REPLY_TEXT_PREVIEW_MAX) { + return mb_substr($one, 0, self::PARENT_REPLY_TEXT_PREVIEW_MAX).'…'; + } + + return $one; + } + + /** + * In-thread parent id for a reply, mirroring jumble’s {@code getParentETag} / kind-1111 branch: prefer + * {@code e}/{@code E} with marker {@code reply} when that id is another event in the loaded thread, else + * the last in-thread id when several {@code e}/{@code E} apply (NIP-10), else the only in-thread id. * - * @param array $threadIdSet + * The article’s root event id is never returned (blurbs/depth are about comments in the fetched list only). + * + * @param array $threadIdSet lower-hex id keys + * + * @return string|null lower-hex parent id, or null */ - private function resolveParentCommentId(object $event, array $threadIdSet, ?string $articleEventHexId): ?string + private function resolveInThreadParentId(object $event, array $threadIdSet, ?string $articleEventHexId): ?string { - $selfId = isset($event->id) ? (string) $event->id : ''; - $last = null; + $selfId = isset($event->id) ? strtolower((string) $event->id) : ''; + if (64 !== \strlen($selfId) || !ctype_xdigit($selfId)) { + $selfId = ''; + } + $article = ($articleEventHexId !== null && $articleEventHexId !== '' && 64 === \strlen($articleEventHexId) && ctype_xdigit($articleEventHexId)) + ? strtolower($articleEventHexId) : null; + + $isThreadTag = static function (string $n): bool { + return $n === 'e' || $n === 'E'; + }; + $validInThread = function (string $pid) use ($selfId, $article, $threadIdSet): bool { + if (64 !== \strlen($pid) || !ctype_xdigit($pid)) { + return false; + } + if ($selfId !== '' && hash_equals($pid, $selfId)) { + return false; + } + if ($article !== null && hash_equals($pid, $article)) { + return false; + } + + return isset($threadIdSet[$pid]); + }; + + // 1) Explicit NIP-10 "reply" marker foreach ($event->tags ?? [] as $tag) { if (!\is_array($tag) || \count($tag) < 2) { continue; } - if ((string) ($tag[0] ?? '') !== 'e') { + if (!$isThreadTag((string) ($tag[0] ?? ''))) { continue; } - $pid = (string) ($tag[1] ?? ''); - if (64 !== \strlen($pid) || !ctype_xdigit($pid)) { + if (($tag[3] ?? '') !== 'reply') { continue; } - if ($selfId !== '' && hash_equals($pid, $selfId)) { + $pid = strtolower((string) ($tag[1] ?? '')); + if ($validInThread($pid)) { + return $pid; + } + } + + // 2) All in-thread references in tag order; last wins when multiple (cf. jumble getParentETagCommentOrDiscussion) + $candidates = []; + foreach ($event->tags ?? [] as $tag) { + if (!\is_array($tag) || \count($tag) < 2) { continue; } - if ($articleEventHexId !== null && $articleEventHexId !== '' && hash_equals($pid, $articleEventHexId)) { + if (!$isThreadTag((string) ($tag[0] ?? ''))) { continue; } - if (isset($threadIdSet[$pid])) { - $last = $pid; + $pid = strtolower((string) ($tag[1] ?? '')); + if ($validInThread($pid)) { + $candidates[] = $pid; } } + if ($candidates === []) { + return null; + } + if (\count($candidates) >= 2) { + return $candidates[\count($candidates) - 1]; + } - return $last; + return $candidates[0]; } /** diff --git a/src/Service/MagazineContentService.php b/src/Service/MagazineContentService.php index c779d19..89fd941 100644 --- a/src/Service/MagazineContentService.php +++ b/src/Service/MagazineContentService.php @@ -9,6 +9,7 @@ use App\Entity\Event; use App\Enum\EventStatusEnum; use App\Repository\ArticleRepository; use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; +use Symfony\Component\HttpFoundation\RequestStack; /** * Magazine index for templates. Reads {@see MagazineIndexStore} only on HTTP; relay refresh and DB @@ -21,6 +22,7 @@ final class MagazineContentService private readonly ParameterBagInterface $params, private readonly ArticleRepository $articleRepository, private readonly NostrClient $nostrClient, + private readonly RequestStack $requestStack, ) { } @@ -42,7 +44,17 @@ final class MagazineContentService */ public function getHomeCategoryAIndexTagsFromStoreOnly(): array { - return $this->categoryATagsFromStoredRoot(); + $request = $this->requestStack->getCurrentRequest(); + if ($request !== null && $request->attributes->has('_magazine_home_a_tags')) { + /** @var list> */ + return $request->attributes->get('_magazine_home_a_tags'); + } + $tags = $this->categoryATagsFromStoredRoot(); + if ($request !== null) { + $request->attributes->set('_magazine_home_a_tags', $tags); + } + + return $tags; } /** diff --git a/src/Twig/Components/Organisms/FeaturedList.php b/src/Twig/Components/Organisms/FeaturedList.php index adac34d..cec04b9 100644 --- a/src/Twig/Components/Organisms/FeaturedList.php +++ b/src/Twig/Components/Organisms/FeaturedList.php @@ -2,6 +2,7 @@ namespace App\Twig\Components\Organisms; +use App\Dto\FeaturedArticleCard; use App\Repository\ArticleRepository; use App\Service\MagazineIndexStore; use Psr\Cache\InvalidArgumentException; @@ -66,7 +67,7 @@ final class FeaturedList return; } - $articles = $this->articleRepository->findBySlugsCriteria($slugs); + $articles = $this->articleRepository->findFeaturedCardsBySlugs($slugs); $slugMap = []; foreach ($articles as $article) { @@ -74,7 +75,7 @@ final class FeaturedList if ($articleSlug !== '') { if (!isset($slugMap[$articleSlug])) { $slugMap[$articleSlug] = $article; - } elseif ($article->getCreatedAt() > $slugMap[$articleSlug]->getCreatedAt()) { + } elseif (self::isNewer($article, $slugMap[$articleSlug])) { $slugMap[$articleSlug] = $article; } } @@ -90,4 +91,18 @@ final class FeaturedList $this->list = array_slice($orderedList, 0, 4); } + + private static function isNewer(FeaturedArticleCard $a, FeaturedArticleCard $b): bool + { + $ca = $a->getCreatedAt(); + $cb = $b->getCreatedAt(); + if ($ca === null) { + return false; + } + if ($cb === null) { + return true; + } + + return $ca > $cb; + } }