Browse Source

Lists

imwald
Nuša Pukšič 3 months ago
parent
commit
b0398bd77a
  1. 94
      src/Controller/AuthorController.php
  2. 73
      src/Controller/ReadingListController.php
  3. 8
      src/Twig/Filters.php
  4. 2
      src/Util/CommonMark/Converter.php
  5. 4
      templates/reading_list/index.html.twig

94
src/Controller/AuthorController.php

@ -5,8 +5,9 @@ declare(strict_types=1);
namespace App\Controller; namespace App\Controller;
use App\Entity\Article; use App\Entity\Article;
use App\Entity\Event;
use App\Enum\KindsEnum;
use App\Message\FetchAuthorArticlesMessage; use App\Message\FetchAuthorArticlesMessage;
use App\Repository\ArticleRepository;
use App\Service\NostrClient; use App\Service\NostrClient;
use App\Service\RedisCacheService; use App\Service\RedisCacheService;
use App\Util\NostrKeyUtil; use App\Util\NostrKeyUtil;
@ -14,10 +15,10 @@ use Doctrine\ORM\EntityManagerInterface;
use Elastica\Query\BoolQuery; use Elastica\Query\BoolQuery;
use Elastica\Collapse; use Elastica\Collapse;
use Elastica\Query\Term; use Elastica\Query\Term;
use Elastica\Query\Terms;
use Exception; use Exception;
use FOS\ElasticaBundle\Finder\FinderInterface; use FOS\ElasticaBundle\Finder\FinderInterface;
use Psr\Cache\InvalidArgumentException; use Psr\Cache\InvalidArgumentException;
use Psr\Log\LoggerInterface;
use swentel\nostr\Key\Key; use swentel\nostr\Key\Key;
use swentel\nostr\Nip19\Nip19Helper; use swentel\nostr\Nip19\Nip19Helper;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@ -30,9 +31,87 @@ use Symfony\Component\Serializer\SerializerInterface;
class AuthorController extends AbstractController class AuthorController extends AbstractController
{ {
/** /**
* Lists
* @throws Exception * @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.*'])] #[Route('/p/{npub}/media', name: 'author-media', requirements: ['npub' => '^npub1.*'])]
public function media($npub, NostrClient $nostrClient, RedisCacheService $redisCacheService, NostrKeyUtil $keyUtil): Response 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 * AJAX endpoint to load more media events
* @throws Exception
*/ */
#[Route('/p/{npub}/media/load-more', name: 'author-media-load-more', requirements: ['npub' => '^npub1.*'])] #[Route('/p/{npub}/media/load-more', name: 'author-media-load-more', requirements: ['npub' => '^npub1.*'])]
public function mediaLoadMore($npub, Request $request, RedisCacheService $redisCacheService): Response public function mediaLoadMore($npub, Request $request, RedisCacheService $redisCacheService): Response
@ -95,6 +175,7 @@ class AuthorController extends AbstractController
} }
/** /**
* Author profile and articles
* @throws Exception * @throws Exception
* @throws ExceptionInterface * @throws ExceptionInterface
* @throws InvalidArgumentException * @throws InvalidArgumentException
@ -139,6 +220,7 @@ class AuthorController extends AbstractController
} }
/** /**
* Redirect from /p/{pubkey} to /p/{npub}
* @throws Exception * @throws Exception
*/ */
#[Route('/p/{pubkey}', name: 'author-redirect')] #[Route('/p/{pubkey}', name: 'author-redirect')]
@ -149,7 +231,13 @@ class AuthorController extends AbstractController
return $this->redirectToRoute('author-profile', ['npub' => $npub]); 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 public function renderArticles(Request $request, SerializerInterface $serializer): Response
{ {

73
src/Controller/ReadingListController.php

@ -7,6 +7,7 @@ namespace App\Controller;
use App\Entity\Article; use App\Entity\Article;
use App\Entity\Event; use App\Entity\Event;
use App\Enum\KindsEnum; use App\Enum\KindsEnum;
use App\Util\NostrKeyUtil;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use swentel\nostr\Key\Key; use swentel\nostr\Key\Key;
@ -103,76 +104,4 @@ class ReadingListController extends AbstractController
'addedArticle' => $addedArticle, '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,
]);
}
} }

8
src/Twig/Filters.php

@ -9,6 +9,7 @@ use App\Entity\Event as EventEntity;
use BitWasp\Bech32\Exception\Bech32Exception; use BitWasp\Bech32\Exception\Bech32Exception;
use Exception; use Exception;
use swentel\nostr\Event\Event; use swentel\nostr\Event\Event;
use swentel\nostr\Key\Key;
use swentel\nostr\Nip19\Nip19Helper; use swentel\nostr\Nip19\Nip19Helper;
use Symfony\Component\Serializer\Encoder\JsonEncoder; use Symfony\Component\Serializer\Encoder\JsonEncoder;
use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer; use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer;
@ -27,6 +28,7 @@ class Filters extends AbstractExtension
new TwigFilter('mentionify', [$this, 'mentionify'], ['is_safe' => ['html']]), new TwigFilter('mentionify', [$this, 'mentionify'], ['is_safe' => ['html']]),
new TwigFilter('nEncode', [$this, 'nEncode']), new TwigFilter('nEncode', [$this, 'nEncode']),
new TwigFilter('naddrEncode', [$this, 'naddrEncode']), new TwigFilter('naddrEncode', [$this, 'naddrEncode']),
new TwigFilter('toNpub', [$this, 'toNpub']),
]; ];
} }
@ -95,4 +97,10 @@ class Filters extends AbstractExtension
return $nip19->encodeNote($article->getEventId()); return $nip19->encodeNote($article->getEventId());
} }
} }
public function toNpub(string $hexPubKey): string
{
$key = new Key();
return $key->convertPublicKeyToBech32($hexPubKey);
}
} }

2
src/Util/CommonMark/Converter.php

@ -66,7 +66,7 @@ readonly class Converter
], ],
'embed' => [ 'embed' => [
'adapter' => new OscaroteroEmbedAdapter(), // See the "Adapter" documentation below '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' 'fallback' => 'link'
], ],
]; ];

4
templates/reading_list/index.html.twig

@ -25,9 +25,9 @@
<div class="d-flex flex-row gap-2"> <div class="d-flex flex-row gap-2">
<a class="btn btn-sm btn-primary" href="{{ path('reading_list_compose') }}">Open Composer</a> <a class="btn btn-sm btn-primary" href="{{ path('reading_list_compose') }}">Open Composer</a>
{% if item.slug %} {% if item.slug %}
<a class="btn btn-sm btn-outline-primary" href="{{ path('reading-list', { slug: item.slug, pubkey: item.pubkey }) }}">View</a> <a class="btn btn-sm btn-outline-primary" href="{{ path('reading-list', { slug: item.slug, npub: item.pubkey|toNpub }) }}">View</a>
<span data-controller="copy-to-clipboard"> <span data-controller="copy-to-clipboard">
<span class="hidden" data-copy-to-clipboard-target="textToCopy">{{ absolute_url(path('reading-list', { slug: item.slug, pubkey: item.pubkey })) }}</span> <span class="hidden" data-copy-to-clipboard-target="textToCopy">{{ absolute_url(path('reading-list', { slug: item.slug, npub: item.pubkey|toNpub })) }}</span>
<button class="btn btn-sm btn-secondary" <button class="btn btn-sm btn-secondary"
data-copy-to-clipboard-target="copyButton" data-copy-to-clipboard-target="copyButton"
data-action="click->copy-to-clipboard#copyToClipboard">Copy link</button> data-action="click->copy-to-clipboard#copyToClipboard">Copy link</button>

Loading…
Cancel
Save