From b0398bd77a33eaac02d19d24cd33e31d2eaecceb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nu=C5=A1a=20Puk=C5=A1i=C4=8D?= Date: Wed, 15 Oct 2025 19:57:50 +0200 Subject: [PATCH] Lists --- src/Controller/AuthorController.php | 94 +++++++++++++++++++++++- src/Controller/ReadingListController.php | 73 +----------------- src/Twig/Filters.php | 8 ++ src/Util/CommonMark/Converter.php | 2 +- templates/reading_list/index.html.twig | 4 +- 5 files changed, 103 insertions(+), 78 deletions(-) diff --git a/src/Controller/AuthorController.php b/src/Controller/AuthorController.php index a861c91..109ed48 100644 --- a/src/Controller/AuthorController.php +++ b/src/Controller/AuthorController.php @@ -5,8 +5,9 @@ declare(strict_types=1); namespace App\Controller; use App\Entity\Article; +use App\Entity\Event; +use App\Enum\KindsEnum; use App\Message\FetchAuthorArticlesMessage; -use App\Repository\ArticleRepository; use App\Service\NostrClient; use App\Service\RedisCacheService; use App\Util\NostrKeyUtil; @@ -14,10 +15,10 @@ use Doctrine\ORM\EntityManagerInterface; use Elastica\Query\BoolQuery; use Elastica\Collapse; use Elastica\Query\Term; -use Elastica\Query\Terms; use Exception; use FOS\ElasticaBundle\Finder\FinderInterface; use Psr\Cache\InvalidArgumentException; +use Psr\Log\LoggerInterface; use swentel\nostr\Key\Key; use swentel\nostr\Nip19\Nip19Helper; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -30,9 +31,87 @@ use Symfony\Component\Serializer\SerializerInterface; class AuthorController extends AbstractController { + /** + * Lists * @throws Exception */ + #[Route('/p/{npub}/list/{slug}', name: 'reading-list')] + public function readingList($npub, $slug, + EntityManagerInterface $em, + NostrKeyUtil $keyUtil, + LoggerInterface $logger): Response + { + // Convert npub to hex pubkey + $pubkey = $keyUtil->npubToHex($npub); + $logger->info(sprintf('Reading list: pubkey=%s, slug=%s', $pubkey, $slug)); + + // Find reading list by pubkey+slug, kind 30040 directly from database + $repo = $em->getRepository(Event::class); + $lists = $repo->findBy(['pubkey' => $pubkey, 'kind' => KindsEnum::PUBLICATION_INDEX], ['created_at' => 'DESC']); + // Filter by slug + $list = null; + foreach ($lists as $ev) { + if (!$ev instanceof Event) continue; + + $eventSlug = $ev->getSlug(); + + if ($eventSlug === $slug) { + $list = $ev; + break; // Found the latest one + } + } + + if (!$list) { + throw $this->createNotFoundException('Reading list not found'); + } + + // fetch articles listed in the list's a tags + $coordinates = []; // Store full coordinates (kind:author:slug) + // Extract category metadata and article coordinates + foreach ($list->getTags() as $tag) { + if ($tag[0] === 'a') { + $coordinates[] = $tag[1]; // Store the full coordinate + } + } + + $articles = []; + if (count($coordinates) > 0) { + $articleRepo = $em->getRepository(Article::class); + + // Query database directly for each coordinate + foreach ($coordinates as $coord) { + $parts = explode(':', $coord, 3); + if (count($parts) === 3) { + [$kind, $author, $articleSlug] = $parts; + + // Find the most recent event matching this coordinate + $events = $articleRepo->findBy([ + 'slug' => $articleSlug, + 'pubkey' => $author + ], ['createdAt' => 'DESC']); + + // Filter by slug and get the latest + foreach ($events as $event) { + if ($event->getSlug() === $articleSlug) { + $articles[] = $event; + break; // Take the first match (most recent if ordered) + } + } + } + } + } + + return $this->render('pages/list.html.twig', [ + 'list' => $list, + 'articles' => $articles, + ]); + } + + /** + * Multimedia + * @throws Exception|InvalidArgumentException + */ #[Route('/p/{npub}/media', name: 'author-media', requirements: ['npub' => '^npub1.*'])] public function media($npub, NostrClient $nostrClient, RedisCacheService $redisCacheService, NostrKeyUtil $keyUtil): Response { @@ -61,6 +140,7 @@ class AuthorController extends AbstractController /** * AJAX endpoint to load more media events + * @throws Exception */ #[Route('/p/{npub}/media/load-more', name: 'author-media-load-more', requirements: ['npub' => '^npub1.*'])] public function mediaLoadMore($npub, Request $request, RedisCacheService $redisCacheService): Response @@ -95,6 +175,7 @@ class AuthorController extends AbstractController } /** + * Author profile and articles * @throws Exception * @throws ExceptionInterface * @throws InvalidArgumentException @@ -139,6 +220,7 @@ class AuthorController extends AbstractController } /** + * Redirect from /p/{pubkey} to /p/{npub} * @throws Exception */ #[Route('/p/{pubkey}', name: 'author-redirect')] @@ -149,7 +231,13 @@ class AuthorController extends AbstractController return $this->redirectToRoute('author-profile', ['npub' => $npub]); } - #[Route('/articles/render', name: 'render_articles', methods: ['POST'], options: ['csrf_protection' => false])] + /** + * AJAX endpoint to render articles from JSON input + * @param Request $request + * @param SerializerInterface $serializer + * @return Response + */ + #[Route('/articles/render', name: 'render_articles', options: ['csrf_protection' => false], methods: ['POST'])] public function renderArticles(Request $request, SerializerInterface $serializer): Response { diff --git a/src/Controller/ReadingListController.php b/src/Controller/ReadingListController.php index 7404158..a13644f 100644 --- a/src/Controller/ReadingListController.php +++ b/src/Controller/ReadingListController.php @@ -7,6 +7,7 @@ namespace App\Controller; use App\Entity\Article; use App\Entity\Event; use App\Enum\KindsEnum; +use App\Util\NostrKeyUtil; use Doctrine\ORM\EntityManagerInterface; use Psr\Log\LoggerInterface; use swentel\nostr\Key\Key; @@ -103,76 +104,4 @@ class ReadingListController extends AbstractController 'addedArticle' => $addedArticle, ]); } - - /** - * - */ - #[Route('/p/{pubkey}/list/{slug}', name: 'reading-list')] - public function readingList($pubkey, $slug, - EntityManagerInterface $em, - LoggerInterface $logger): Response - { - $logger->info(sprintf('Reading list: pubkey=%s, slug=%s', $pubkey, $slug)); - - // Find reading list by pubkey+slug, kind 30040 directly from database - $repo = $em->getRepository(Event::class); - $lists = $repo->findBy(['pubkey' => $pubkey, 'kind' => KindsEnum::PUBLICATION_INDEX], ['created_at' => 'DESC']); - // Filter by slug - $list = null; - foreach ($lists as $ev) { - if (!$ev instanceof Event) continue; - - $eventSlug = $ev->getSlug(); - - if ($eventSlug === $slug) { - $list = $ev; - break; // Found the latest one - } - } - - if (!$list) { - throw $this->createNotFoundException('Reading list not found'); - } - - // fetch articles listed in the list's a tags - $coordinates = []; // Store full coordinates (kind:author:slug) - // Extract category metadata and article coordinates - foreach ($list->getTags() as $tag) { - if ($tag[0] === 'a') { - $coordinates[] = $tag[1]; // Store the full coordinate - } - } - - $articles = []; - if (count($coordinates) > 0) { - $articleRepo = $em->getRepository(Article::class); - - // Query database directly for each coordinate - foreach ($coordinates as $coord) { - $parts = explode(':', $coord, 3); - if (count($parts) === 3) { - [$kind, $author, $articleSlug] = $parts; - - // Find the most recent event matching this coordinate - $events = $articleRepo->findBy([ - 'slug' => $articleSlug, - 'pubkey' => $author - ], ['createdAt' => 'DESC']); - - // Filter by slug and get the latest - foreach ($events as $event) { - if ($event->getSlug() === $articleSlug) { - $articles[] = $event; - break; // Take the first match (most recent if ordered) - } - } - } - } - } - - return $this->render('pages/list.html.twig', [ - 'list' => $list, - 'articles' => $articles, - ]); - } } diff --git a/src/Twig/Filters.php b/src/Twig/Filters.php index d0d229a..eb544d3 100644 --- a/src/Twig/Filters.php +++ b/src/Twig/Filters.php @@ -9,6 +9,7 @@ use App\Entity\Event as EventEntity; use BitWasp\Bech32\Exception\Bech32Exception; use Exception; use swentel\nostr\Event\Event; +use swentel\nostr\Key\Key; use swentel\nostr\Nip19\Nip19Helper; use Symfony\Component\Serializer\Encoder\JsonEncoder; use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer; @@ -27,6 +28,7 @@ class Filters extends AbstractExtension new TwigFilter('mentionify', [$this, 'mentionify'], ['is_safe' => ['html']]), new TwigFilter('nEncode', [$this, 'nEncode']), new TwigFilter('naddrEncode', [$this, 'naddrEncode']), + new TwigFilter('toNpub', [$this, 'toNpub']), ]; } @@ -95,4 +97,10 @@ class Filters extends AbstractExtension return $nip19->encodeNote($article->getEventId()); } } + + public function toNpub(string $hexPubKey): string + { + $key = new Key(); + return $key->convertPublicKeyToBech32($hexPubKey); + } } diff --git a/src/Util/CommonMark/Converter.php b/src/Util/CommonMark/Converter.php index 97c13a9..361ceb9 100644 --- a/src/Util/CommonMark/Converter.php +++ b/src/Util/CommonMark/Converter.php @@ -66,7 +66,7 @@ readonly class Converter ], 'embed' => [ 'adapter' => new OscaroteroEmbedAdapter(), // See the "Adapter" documentation below - 'allowed_domains' => ['youtube.com', 'x.com', 'github.com', 'fountain.fm', 'blossom.primal.net', 'i.nostr.build'], + 'allowed_domains' => ['youtube.com', 'x.com', 'github.com', 'fountain.fm', 'blossom.primal.net', 'i.nostr.build', 'video.nostr.build'], // If null, all domains are allowed 'fallback' => 'link' ], ]; diff --git a/templates/reading_list/index.html.twig b/templates/reading_list/index.html.twig index 7fb78fd..d6fb7b4 100644 --- a/templates/reading_list/index.html.twig +++ b/templates/reading_list/index.html.twig @@ -25,9 +25,9 @@
Open Composer {% if item.slug %} - View + View - +