Browse Source

bug-fixes

imwald
Silberengel 1 week ago
parent
commit
ad7384347c
  1. 1
      config/services.yaml
  2. 57
      src/Dto/FeaturedArticleCard.php
  3. 42
      src/Repository/ArticleRepository.php
  4. 134
      src/Service/ArticleCommentThreadLoader.php
  5. 14
      src/Service/MagazineContentService.php
  6. 19
      src/Twig/Components/Organisms/FeaturedList.php

1
config/services.yaml

@ -23,6 +23,7 @@ services: @@ -23,6 +23,7 @@ services:
resource: '../src/'
exclude:
- '../src/DependencyInjection/'
- '../src/Dto/'
- '../src/Entity/'
- '../src/Kernel.php'

57
src/Dto/FeaturedArticleCard.php

@ -0,0 +1,57 @@ @@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Dto;
/**
* Minimal article row for home/category list cards (avoids loading long-form `content` from the DB).
*/
final readonly class FeaturedArticleCard
{
public function __construct(
private ?int $id,
private ?string $slug,
private ?string $title,
private ?string $summary,
private ?string $image,
private ?\DateTimeImmutable $createdAt,
private ?string $pubkey,
) {
}
public function getId(): ?int
{
return $this->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;
}
}

42
src/Repository/ArticleRepository.php

@ -2,9 +2,11 @@ @@ -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 @@ -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<FeaturedArticleCard>
*/
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<array<string, mixed>> $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;
}
/**

134
src/Service/ArticleCommentThreadLoader.php

@ -12,9 +12,14 @@ use Symfony\Contracts\Cache\ItemInterface; @@ -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 @@ -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 @@ -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<string, true> $threadIdSet
* The article’s root event id is never returned (blurbs/depth are about comments in the fetched list only).
*
* @param array<string, true> $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];
}
/**

14
src/Service/MagazineContentService.php

@ -9,6 +9,7 @@ use App\Entity\Event; @@ -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 @@ -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 @@ -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<array<int, string>> */
return $request->attributes->get('_magazine_home_a_tags');
}
$tags = $this->categoryATagsFromStoredRoot();
if ($request !== null) {
$request->attributes->set('_magazine_home_a_tags', $tags);
}
return $tags;
}
/**

19
src/Twig/Components/Organisms/FeaturedList.php

@ -2,6 +2,7 @@ @@ -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 @@ -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 @@ -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 @@ -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;
}
}

Loading…
Cancel
Save