Browse Source

Reorganize fetching data

imwald
Nuša Pukšič 8 months ago
parent
commit
a58e9f7bb8
  1. 3
      config/packages/fos_elastica.yaml
  2. 4
      config/services.yaml
  3. 8
      public/service-worker.js
  4. 5
      src/Command/DatabaseCleanupCommand.php
  5. 67
      src/Command/DeduplicateArticlesCommand.php
  6. 29
      src/Command/NostrEventFromYamlDefinitionCommand.php
  7. 32
      src/Controller/AuthorController.php
  8. 191
      src/Controller/DefaultController.php
  9. 19
      src/Entity/Article.php
  10. 8
      src/Entity/Event.php
  11. 81
      src/Service/NostrClient.php
  12. 20
      src/Twig/Components/Molecules/UserFromNpub.php
  13. 25
      src/Twig/Components/Organisms/FeaturedList.php
  14. 38
      src/Twig/Filters.php
  15. 9
      templates/pages/author.html.twig

3
config/packages/fos_elastica.yaml

@ -13,7 +13,8 @@ fos_elastica: @@ -13,7 +13,8 @@ fos_elastica:
title: ~
summary: ~
content: ~
slug: ~
slug:
type: keyword
topics: ~
persistence:
driver: orm

4
config/services.yaml

@ -71,3 +71,7 @@ services: @@ -71,3 +71,7 @@ services:
App\Command\IndexArticlesCommand:
arguments:
$itemPersister: '@fos_elastica.object_persister.articles'
App\Command\NostrEventFromYamlDefinitionCommand:
arguments:
$itemPersister: '@fos_elastica.object_persister.articles'

8
public/service-worker.js

@ -54,7 +54,13 @@ self.addEventListener('fetch', (event) => { @@ -54,7 +54,13 @@ self.addEventListener('fetch', (event) => {
// Skip cache for dynamic routes
const isDynamic = request.url.includes('/cat/') ;
if (isDynamic) {
// Exclude dynamic paths
const isExcluded =
request.url.startsWith('/login') ||
request.url.startsWith('/logout') ||
request.url.startsWith('/_components/');
if (isDynamic || isExcluded) {
return; // Don't intercept
}

5
src/Command/DatabaseCleanupCommand.php

@ -13,7 +13,10 @@ use Symfony\Component\Console\Input\InputInterface; @@ -13,7 +13,10 @@ use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand(name: 'db:cleanup', description: 'Remove articles with do_not_index rating')]
class DatabaseCleanupCommand extends Command
class
DatabaseCleanupCommand extends Command
{
public function __construct(private readonly EntityManagerInterface $entityManager)
{

67
src/Command/DeduplicateArticlesCommand.php

@ -0,0 +1,67 @@ @@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Entity\Article;
use App\Enum\IndexStatusEnum;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand(name: 'articles:deduplicate', description: 'Mark duplicates with DO_NOT_INDEX.')]
class DeduplicateArticlesCommand extends Command
{
private const BATCH_SIZE = 500;
public function __construct(private EntityManagerInterface $em)
{
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$repo = $this->em->getRepository(Article::class);
$slugIndex = [];
$page = 0;
// Process articles in batches
while (true) {
// Fetch a batch of articles
$articles = $repo->findBy([], ['createdAt' => 'DESC'], self::BATCH_SIZE, $page * self::BATCH_SIZE);
if (empty($articles)) {
break;
}
foreach ($articles as $article) {
$slug = $article->getSlug();
// If this slug hasn't been seen, store the slug
if (!in_array($slug, $slugIndex)) {
$slugIndex[] = $slug;
continue;
}
// The articles are sorted, so the first one should be kept
// Mark current article as DO_NOT_INDEX
$article->setIndexStatus(IndexStatusEnum::DO_NOT_INDEX);
}
// Flush the batch and clear memory to avoid overload
$this->em->flush();
$this->em->clear(); // Clear the entity manager to free up memory
$output->writeln("Processed batch " . ($page + 1));
$page++;
}
$output->writeln('Article deduplication complete.');
return Command::SUCCESS;
}
}

29
src/Command/NostrEventFromYamlDefinitionCommand.php

@ -4,6 +4,9 @@ declare(strict_types=1); @@ -4,6 +4,9 @@ declare(strict_types=1);
namespace App\Command;
use App\Entity\Article;
use Doctrine\ORM\EntityManagerInterface;
use FOS\ElasticaBundle\Persister\ObjectPersisterInterface;
use swentel\nostr\Event\Event;
use swentel\nostr\Sign\Sign;
use Symfony\Component\Console\Attribute\AsCommand;
@ -21,7 +24,9 @@ class NostrEventFromYamlDefinitionCommand extends Command @@ -21,7 +24,9 @@ class NostrEventFromYamlDefinitionCommand extends Command
{
private string $nsec;
public function __construct(private readonly CacheInterface $redisCache, ParameterBagInterface $bag)
public function __construct(private readonly CacheInterface $redisCache, ParameterBagInterface $bag,
private readonly ObjectPersisterInterface $itemPersister,
private readonly EntityManagerInterface $entityManager)
{
$this->nsec = $bag->get('nsec');
parent::__construct();
@ -49,6 +54,8 @@ class NostrEventFromYamlDefinitionCommand extends Command @@ -49,6 +54,8 @@ class NostrEventFromYamlDefinitionCommand extends Command
return Command::SUCCESS;
}
$articleSlugsList = [];
foreach ($finder as $file) {
$filePath = $file->getRealPath();
$output->writeln("<info>Processing file: $filePath</info>");
@ -60,6 +67,13 @@ class NostrEventFromYamlDefinitionCommand extends Command @@ -60,6 +67,13 @@ class NostrEventFromYamlDefinitionCommand extends Command
$event->setKind(30040);
$tags = $yamlContent['tags'];
$event->setTags($tags);
$items = array_filter($tags, function ($tag) {
return ($tag[0] === 'a');
});
foreach ($items as $one) {
$parts = explode(':', $one[1]);
$articleSlugsList[] = end($parts);
}
$signer = new Sign();
$signer->signEvent($event, $this->nsec);
@ -81,6 +95,19 @@ class NostrEventFromYamlDefinitionCommand extends Command @@ -81,6 +95,19 @@ class NostrEventFromYamlDefinitionCommand extends Command
}
}
// look up all articles in the db and push to index whatever you find
$articles = $this->entityManager->getRepository(Article::class)->createQueryBuilder('a')
->where('a.slug IN (:slugs)')
->setParameter('slugs', $articleSlugsList)
->getQuery()
->getResult();
// to elastic
if (count($articles) > 0 ) {
$this->itemPersister->insertMany($articles); // Insert or skip existing
$output->writeln('<info>Added to index.</info>');
}
$output->writeln('<info>Conversion complete.</info>');
return Command::SUCCESS;
}

32
src/Controller/AuthorController.php

@ -14,6 +14,8 @@ use swentel\nostr\Key\Key; @@ -14,6 +14,8 @@ use swentel\nostr\Key\Key;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
class AuthorController extends AbstractController
{
@ -22,14 +24,37 @@ class AuthorController extends AbstractController @@ -22,14 +24,37 @@ class AuthorController extends AbstractController
* @throws InvalidArgumentException
*/
#[Route('/p/{npub}', name: 'author-profile', requirements: ['npub' => '^npub1.*'])]
public function index($npub, EntityManagerInterface $entityManager, NostrClient $client): Response
public function index($npub, CacheInterface $redisCache, EntityManagerInterface $entityManager, NostrClient $client): Response
{
$keys = new Key();
$pubkey = $keys->convertToHex($npub);
$relays = [];
try {
$cacheKey = '0_' . $pubkey;
$author = $redisCache->get($cacheKey, function (ItemInterface $item) use ($pubkey, $client) {
$item->expiresAfter(3600); // 1 hour, adjust as needed
$meta = $client->getNpubMetadata($pubkey);
return (array) json_decode($meta->content ?? '{}');
});
} catch (InvalidArgumentException | \Exception $e) {
// nothing to do
}
$author = json_decode($meta->content ?? '{}');
try {
$cacheKey = '10002_' . $pubkey;
$relays = $redisCache->get($cacheKey, function (ItemInterface $item) use ($pubkey, $client) {
$item->expiresAfter(3600); // 1 hour, adjust as needed
return $client->getNpubRelays($pubkey);
});
} catch (InvalidArgumentException | \Exception $e) {
// nothing to do
}
$list = $client->getLongFormContentForPubkey($pubkey);
@ -53,7 +78,8 @@ class AuthorController extends AbstractController @@ -53,7 +78,8 @@ class AuthorController extends AbstractController
'articles' => $articles,
'nzine' => null,
'nzines' => null,
'idx' => null
'idx' => null,
'relays' => $relays
]);
}

191
src/Controller/DefaultController.php

@ -4,20 +4,24 @@ declare(strict_types=1); @@ -4,20 +4,24 @@ declare(strict_types=1);
namespace App\Controller;
use App\Factory\ArticleFactory;
use App\Service\NostrClient;
use Elastica\Query;
use Elastica\Query\MatchQuery;
use Elastica\Query\Terms;
use FOS\ElasticaBundle\Finder\FinderInterface;
use Psr\Cache\InvalidArgumentException;
use swentel\nostr\Event\Event;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Serializer\Encoder\JsonEncoder;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
use Symfony\Component\Serializer\Serializer;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
class DefaultController extends AbstractController
{
public function __construct(
private readonly FinderInterface $esFinder,
private readonly CacheInterface $redisCache)
{
}
@ -27,7 +31,7 @@ class DefaultController extends AbstractController @@ -27,7 +31,7 @@ class DefaultController extends AbstractController
* @throws InvalidArgumentException
*/
#[Route('/', name: 'home')]
public function index(FinderInterface $finder): Response
public function index(): Response
{
// get newsroom index, loop over categories, pick top three from each and display in sections
$mag = $this->redisCache->get('magazine-newsroom-magazine-by-newsroom', function (){
@ -40,7 +44,7 @@ class DefaultController extends AbstractController @@ -40,7 +44,7 @@ class DefaultController extends AbstractController
});
return $this->render('home.html.twig', [
'indices' => $cats
'indices' => array_values($cats)
]);
}
@ -49,175 +53,70 @@ class DefaultController extends AbstractController @@ -49,175 +53,70 @@ class DefaultController extends AbstractController
* @throws InvalidArgumentException
*/
#[Route('/cat/{slug}', name: 'magazine-category')]
public function magCategory($slug, CacheInterface $redisCache, FinderInterface $finder): Response
public function magCategory($slug, CacheInterface $redisCache,
NostrClient $client, ArticleFactory $articleFactory,
FinderInterface $finder): Response
{
$catIndex = $redisCache->get('magazine-' . $slug, function (){
throw new \Exception('Not found');
});
$articles = [];
$list = [];
$slugs = [];
$returnedSlugs = [];
foreach ($catIndex->getTags() as $tag) {
if ($tag[0] === 'a') {
$parts = explode(':', $tag[1]);
if (count($parts) === 3) {
$fieldQuery = new MatchQuery();
$fieldQuery->setFieldQuery('slug', $parts[2]);
$res = $finder->find($fieldQuery);
$articles[] = $res[0];
$slugs[] = $parts[2];
}
}
}
if (!empty($slugs)) {
return $this->render('pages/category.html.twig', [
'list' => array_slice($articles, 0, 9)
]);
}
$query = new Terms('slug', array_values($slugs));
$articles = $finder->find($query);
/**
* @throws InvalidArgumentException
*/
#[Route('/business', name: 'business')]
public function business(CacheInterface $redisCache): Response
{
$articles = $redisCache->get('main_category_business', function (ItemInterface $item): array {
$item->expiresAfter(36000);
$search = [
'finance business',
'trading stock commodity',
's&p500 gold oil',
'currency bitcoin',
'international military incident'
];
return $this->getArticles($search);
});
// Create a map of slug => item to remove duplicates
$slugMap = [];
return $this->render('pages/category.html.twig', [
'list' => array_slice($articles, 0, 9)
]);
}
/**
* @throws InvalidArgumentException
*/
#[Route('/technology', name: 'technology')]
public function technology(CacheInterface $redisCache): Response
{
$articles = $redisCache->get('main_category_technology', function (ItemInterface $item): array {
$item->expiresAfter(36000);
$search = [
'technology innovation',
'ai llm chatgpt claude agent',
'blockchain mining cryptography',
'cypherpunk nostr',
'server hosting'
];
return $this->getArticles($search);
});
foreach ($articles as $item) {
// $item = $articleFactory->createFromLongFormContentEvent($article);
$slug = $item->getSlug();
return $this->render('pages/category.html.twig', [
'list' => array_slice($articles, 0, 9)
]);
if ($slug !== '' && !isset($slugMap[$slug])) {
$slugMap[$slug] = $item;
$returnedSlugs[] = $slug;
}
/**
* @throws InvalidArgumentException
*/
#[Route('/world', name: 'world')]
public function world(CacheInterface $redisCache): Response
{
$articles = $redisCache->get('main_category_world', function (ItemInterface $item): array {
$item->expiresAfter(36000);
$search = [
'politics policy president',
'agreement law resolution',
'tariffs taxes trade',
'international military incident'
];
return $this->getArticles($search);
});
return $this->render('pages/category.html.twig', [
'list' => array_slice($articles, 0, 9)
]);
}
/**
* @throws InvalidArgumentException
*/
#[Route('/lifestyle', name: 'lifestyle')]
public function lifestyle(CacheInterface $redisCache): Response
{
$articles = $redisCache->get('main_category_lifestyle', function (ItemInterface $item): array {
$item->expiresAfter(36000);
$search = [
'touch grass',
'health healthy',
'lifestyle wellness diet sunshine'
];
return $this->getArticles($search);
});
return $this->render('pages/category.html.twig', [
'list' => array_slice($articles, 0, 9)
]);
if (!empty($res)) {
foreach ($res as $result) {
if (!isset($slugMap[$result->getSlug()])) {
$slugMap[$result->getSlug()] = $result;
}
/**
* @throws InvalidArgumentException
*/
#[Route('/art', name: 'art')]
public function art(CacheInterface $redisCache): Response
{
$articles = $redisCache->get('main_category_art', function (ItemInterface $item): array {
$item->expiresAfter(36000);
$search = [
'photo photography',
'travel',
'art painting'
];
return $this->getArticles($search);
});
return $this->render('pages/category.html.twig', [
'list' => array_slice($articles, 0, 9)
]);
}
/**
* @param $search
* @return array
*/
public function getArticles($search): array
{
$articles = [];
foreach ($search as $q) {
$articles = array_merge($articles, $this->esFinder->find($q, 10));
}
// sort articles by created at date
usort($articles, function ($a, $b) {
return $b->getCreatedAt() <=> $a->getCreatedAt();
});
// deduplicate by slugs
$deduplicated = [];
foreach ($articles as $item) {
if (!key_exists((string)$item->getSlug(), $deduplicated)) {
$deduplicated[(string)$item->getSlug()] = $item;
// Reorder by the original $slugs
$results = [];
foreach ($slugs as $slug) {
if (isset($slugMap[$slug])) {
$results[] = $slugMap[$slug];
}
// keep 10
if (count($deduplicated) > 9) {
break;
}
$list = array_values($results);
}
return $deduplicated;
// if any are missing, look in index
return $this->render('pages/category.html.twig', [
'list' => $list,
'index' => $catIndex
]);
}
}

19
src/Entity/Article.php

@ -23,7 +23,7 @@ class Article @@ -23,7 +23,7 @@ class Article
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(length: 225, nullable: true)]
private ?int $id = null;
private null|int|string $id = null;
#[ORM\Column(type: Types::JSON, nullable: true)]
private $raw = null;
@ -80,12 +80,12 @@ class Article @@ -80,12 +80,12 @@ class Article
#[ORM\Column(type: Types::INTEGER, nullable: true)]
private ?int $ratingPositive = null;
public function getId(): ?int
public function getId(): null|int|string
{
return $this->id;
}
public function setId(int $id): static
public function setId(int|string $id): static
{
$this->id = $id;
@ -133,8 +133,14 @@ class Article @@ -133,8 +133,14 @@ class Article
return $this->kind;
}
public function setKind(?KindsEnum $kind): static
public function setKind(null|KindsEnum|int $kind): static
{
if (is_int($kind)) {
$kind = KindsEnum::tryFrom($kind);
if ($kind === null) {
throw new \InvalidArgumentException("Invalid kind value: $kind");
}
}
$this->kind = $kind;
return $this;
@ -181,8 +187,11 @@ class Article @@ -181,8 +187,11 @@ class Article
return $this->createdAt;
}
public function setCreatedAt(\DateTimeImmutable $createdAt): static
public function setCreatedAt(\DateTimeImmutable|int $createdAt): static
{
if (is_int($createdAt)) {
$createdAt = (new \DateTimeImmutable())->setTimestamp($createdAt);
}
$this->createdAt = $createdAt;
return $this;

8
src/Entity/Event.php

@ -125,8 +125,8 @@ class Event @@ -125,8 +125,8 @@ class Event
public function getSummary(): ?string
{
foreach ($this->getTags() as $tag) {
if (array_key_first($tag) === 'summary') {
return $tag['summary'];
if ($tag[0] === 'summary') {
return $tag[1];
}
}
return null;
@ -135,8 +135,8 @@ class Event @@ -135,8 +135,8 @@ class Event
public function getSlug(): ?string
{
foreach ($this->getTags() as $tag) {
if (array_key_first($tag) === 'd') {
return $tag['d'];
if ($tag[0] === 'd') {
return $tag[1];
}
}

81
src/Service/NostrClient.php

@ -20,11 +20,7 @@ use swentel\nostr\Request\Request; @@ -20,11 +20,7 @@ use swentel\nostr\Request\Request;
use swentel\nostr\Subscription\Subscription;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Serializer\Encoder\JsonEncoder;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
use Symfony\Component\Serializer\Serializer;
use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Contracts\Cache\CacheInterface;
class NostrClient
{
@ -35,14 +31,13 @@ class NostrClient @@ -35,14 +31,13 @@ class NostrClient
private readonly ArticleFactory $articleFactory,
private readonly SerializerInterface $serializer,
private readonly TokenStorageInterface $tokenStorage,
private readonly CacheInterface $cacheApp,
private readonly LoggerInterface $logger)
{
// TODO configure read and write relays for logged in users from their 10002 events
$this->defaultRelaySet = new RelaySet();
// $this->defaultRelaySet->addRelay(new Relay('wss://relay.damus.io')); // public relay
$this->defaultRelaySet->addRelay(new Relay('wss://relay.damus.io')); // public relay
// $this->defaultRelaySet->addRelay(new Relay('wss://relay.primal.net')); // public relay
$this->defaultRelaySet->addRelay(new Relay('wss://nos.lol')); // public relay
// $this->defaultRelaySet->addRelay(new Relay('wss://nos.lol')); // public relay
// $this->defaultRelaySet->addRelay(new Relay('wss://relay.snort.social')); // public relay
$this->defaultRelaySet->addRelay(new Relay('wss://theforest.nostr1.com')); // public relay
// $this->defaultRelaySet->addRelay(new Relay('wss://purplepag.es')); // public relay
@ -341,11 +336,8 @@ class NostrClient @@ -341,11 +336,8 @@ class NostrClient
}
}
/**
*
* @return array
*/
public function getNpubRelays($pubkey)
public function getNpubRelays($pubkey): array
{
$subscription = new Subscription();
$subscriptionId = $subscription->setId();
@ -353,13 +345,7 @@ class NostrClient @@ -353,13 +345,7 @@ class NostrClient
$filter->setKinds([KindsEnum::RELAY_LIST]);
$filter->setAuthors([$pubkey]);
$requestMessage = new RequestMessage($subscriptionId, [$filter]);
// TODO make relays configurable
$relays = new RelaySet();
$relays->addRelay(new Relay('wss://purplepag.es')); // default metadata aggregator
$relays->addRelay(new Relay('wss://nos.lol')); // default public
$relays->addRelay(new Relay('wss://theforest.nostr1.com')); // default public
$request = new Request($relays, $requestMessage);
$request = new Request($this->defaultRelaySet, $requestMessage);
$response = $request->send();
@ -372,8 +358,13 @@ class NostrClient @@ -372,8 +358,13 @@ class NostrClient
$relays = [];
foreach ($event->tags as $tag) {
if ($tag[0] === 'r') {
$this->logger->info('Relay: ' . $tag[1]);
// if not already listed
if (!in_array($tag[1], $relays)) {
// is wss:
// not localhost
if (!in_array($tag[1], $relays)
&& str_starts_with('wss:',$tag[1])
&& !str_contains('localhost',$tag[1])) {
$relays[] = $tag[1];
}
}
@ -440,12 +431,9 @@ class NostrClient @@ -440,12 +431,9 @@ class NostrClient
public function getLongFormContentForPubkey(string $pubkey)
{
$articles = [];
// get npub relays, then look for articles
$relayList = $this->getNpubRelays($pubkey);
$relaySet = $this->defaultRelaySet;
foreach ($relayList as $r) {
// if (str_starts_with($r, 'wss:')) $relaySet->addRelay(new Relay($r));
}
// look for last months long-form notes
$subscription = new Subscription();
$subscriptionId = $subscription->setId();
@ -464,14 +452,8 @@ class NostrClient @@ -464,14 +452,8 @@ class NostrClient
if (is_array($item)) continue;
switch ($item->type) {
case 'EVENT':
$eventStr = json_encode($item->event);
// remap to the Event class
$encoders = [new JsonEncoder()];
$normalizers = [new ObjectNormalizer()];
$serializer = new Serializer($normalizers, $encoders);
/** @var \App\Entity\Event $event */
$event = $serializer->deserialize($eventStr, \App\Entity\Event::class, 'json');
$articles[] = $event;
$article = $this->articleFactory->createFromLongFormContentEvent($item->event);
$articles[] = $article;
break;
case 'AUTH':
// throw new UnauthorizedHttpException('', 'Relay requires authentication');
@ -485,4 +467,37 @@ class NostrClient @@ -485,4 +467,37 @@ class NostrClient
}
return $articles;
}
public function getArticles(array $slugs): array
{
$articles = [];
$subscription = new Subscription();
$subscriptionId = $subscription->setId();
$filter = new Filter();
$filter->setKinds([KindsEnum::LONGFORM]);
$filter->setTag('#d', $slugs);
$requestMessage = new RequestMessage($subscriptionId, [$filter]);
$request = new Request($this->defaultRelaySet, $requestMessage);
$response = $request->send();
// response is an array of arrays
foreach ($response as $value) {
foreach ($value as $item) {
switch ($item->type) {
case 'EVENT':
$articles[] = $item->event;
break;
case 'AUTH':
throw new UnauthorizedHttpException('', 'Relay requires authentication');
case 'ERROR':
case 'NOTICE':
$this->logger->error('An error while getting articles.', $item);
default:
// nothing to do here
}
}
}
return $articles;
}
}

20
src/Twig/Components/Molecules/UserFromNpub.php

@ -5,6 +5,8 @@ namespace App\Twig\Components\Molecules; @@ -5,6 +5,8 @@ namespace App\Twig\Components\Molecules;
use App\Service\NostrClient;
use Psr\Cache\InvalidArgumentException;
use swentel\nostr\Key\Key;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[AsTwigComponent]
@ -14,20 +16,30 @@ final class UserFromNpub @@ -14,20 +16,30 @@ final class UserFromNpub
public string $npub;
public $user = null;
public function __construct(private readonly NostrClient $nostrClient)
public function __construct(
private readonly CacheInterface $redisCache,
private readonly NostrClient $nostrClient)
{
}
public function mount(string $pubkey): void
{
$keys = new Key();
$this->pubkey = $pubkey;
$this->npub = $keys->convertPublicKeyToBech32($pubkey);
try {
$meta = $this->nostrClient->getNpubMetadata($this->pubkey);
$this->user = (array) json_decode($meta->content);
} catch (InvalidArgumentException|\Exception) {
$cacheKey = '0_' . $this->pubkey;
$this->user = $this->redisCache->get($cacheKey, function (ItemInterface $item) use ($pubkey) {
$item->expiresAfter(3600); // 1 hour, adjust as needed
$meta = $this->nostrClient->getNpubMetadata($pubkey);
return (array) json_decode($meta->content);
});
} catch (InvalidArgumentException | \Exception $e) {
// nothing to do
}
}

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

@ -2,10 +2,17 @@ @@ -2,10 +2,17 @@
namespace App\Twig\Components\Organisms;
use App\Entity\Article;
use App\Factory\ArticleFactory;
use App\Service\NostrClient;
use Elastica\Query\MatchQuery;
use Elastica\Query\Terms;
use FOS\ElasticaBundle\Finder\FinderInterface;
use Psr\Cache\InvalidArgumentException;
use swentel\nostr\Event\Event;
use Symfony\Component\Serializer\Encoder\JsonEncoder;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
use Symfony\Component\Serializer\Serializer;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
@ -16,12 +23,15 @@ final class FeaturedList @@ -16,12 +23,15 @@ final class FeaturedList
public string $title;
public array $list = [];
public function __construct(private readonly CacheInterface $redisCache, private readonly FinderInterface $finder)
public function __construct(
private readonly CacheInterface $redisCache,
private readonly FinderInterface $finder)
{
}
/**
* @throws InvalidArgumentException
* @throws \Exception
*/
public function mount($category): void
{
@ -31,6 +41,7 @@ final class FeaturedList @@ -31,6 +41,7 @@ final class FeaturedList
throw new \Exception('Not found');
});
$slugs = [];
foreach ($catIndex->getTags() as $tag) {
if ($tag[0] === 'title') {
$this->title = $tag[1];
@ -38,15 +49,13 @@ final class FeaturedList @@ -38,15 +49,13 @@ final class FeaturedList
if ($tag[0] === 'a') {
$parts = explode(':', $tag[1]);
if (count($parts) === 3) {
$fieldQuery = new MatchQuery();
$fieldQuery->setFieldQuery('slug', $parts[2]);
$res = $this->finder->find($fieldQuery);
$this->list[] = $res[0];
$slugs[] = $parts[2];
}
}
if (count($this->list) > 3) {
break;
}
}
$query = new Terms('slug', array_values($slugs));
$res = $this->finder->find($query);
$this->list = array_slice($res, 0, 4);
}
}

38
src/Twig/Filters.php

@ -14,6 +14,8 @@ class Filters extends AbstractExtension @@ -14,6 +14,8 @@ class Filters extends AbstractExtension
{
return [
new TwigFilter('shortenNpub', [$this, 'shortenNpub']),
new TwigFilter('linkify', [$this, 'linkify'], ['is_safe' => ['html']]),
new TwigFilter('mentionify', [$this, 'mentionify'], ['is_safe' => ['html']])
];
}
@ -21,4 +23,40 @@ class Filters extends AbstractExtension @@ -21,4 +23,40 @@ class Filters extends AbstractExtension
{
return substr($npub, 0, 8) . '…' . substr($npub, -4);
}
public function linkify(string $text): string
{
return preg_replace_callback(
'#\b((https?://|www\.)[^\s<]+)#i',
function ($matches) {
$url = $matches[0];
$href = str_starts_with($url, 'http') ? $url : 'https://' . $url;
return sprintf(
'<a href="%s" target="_blank" rel="noopener noreferrer">%s</a>',
htmlspecialchars($href, ENT_QUOTES, 'UTF-8'),
htmlspecialchars($url, ENT_QUOTES, 'UTF-8')
);
},
$text
);
}
public function mentionify(string $text): string
{
return preg_replace_callback(
'/@(?<npub>npub1[0-9a-z]{10,})/i',
function ($matches) {
$npub = $matches['npub'];
$short = substr($npub, 0, 8) . '...' . substr($npub, -4);
return sprintf(
'<a href="/p/%s" class="mention-link">@%s</a>',
htmlspecialchars($npub, ENT_QUOTES, 'UTF-8'),
htmlspecialchars($short, ENT_QUOTES, 'UTF-8')
);
},
$text
);
}
}

9
templates/pages/author.html.twig

@ -9,10 +9,17 @@ @@ -9,10 +9,17 @@
<h1><twig:atoms:NameOrNpub :author="author"></twig:atoms:NameOrNpub></h1>
{% if author.about is defined %}
<p class="lede">
{{ author.about }}
{{ author.about|linkify|mentionify }}
</p>
{% endif %}
{% if relays|length > 0 %}
{% for rel in relays %}
<p>{{ rel }}</p>
{% endfor %}
{% endif %}
{# {% if app.user and app.user.userIdentifier is same as npub %}#}
{# <div id="invoice-component" class="p-4 bg-gray-100 rounded-lg">#}
{# <h3 class="text-xl font-semibold mb-4">Purchase Search Credits</h3>#}

Loading…
Cancel
Save