diff --git a/assets/controllers/author_articles_controller.js b/assets/controllers/author_articles_controller.js new file mode 100644 index 0000000..974aa75 --- /dev/null +++ b/assets/controllers/author_articles_controller.js @@ -0,0 +1,56 @@ +import { Controller } from '@hotwired/stimulus'; + +/* stimulusFetch: 'lazy' */ +export default class extends Controller { + static values = { + pubkey: String + }; + + connect() { + const pubkey = this.pubkeyValue; + const topic = `/articles/${pubkey}`; + const hubUrl = window.MercureHubUrl || (document.querySelector('meta[name="mercure-hub"]')?.content); + console.log('[articles-mercure] connect', { pubkey, topic, hubUrl }); + if (!hubUrl) return; + const url = new URL(hubUrl); + url.searchParams.append('topic', topic); + this.eventSource = new EventSource(url.toString()); + this.eventSource.onopen = () => { + console.log('[articles-mercure] EventSource opened', url.toString()); + }; + + + this.eventSource.onmessage = this.handleMessage.bind(this); + } + + disconnect() { + if (this.eventSource) { + this.eventSource.close(); + } + } + + async handleMessage(event) { + const data = JSON.parse(event.data); + console.log(data); + if (data.articles && data.articles.length > 0) { + // Fetch the rendered HTML from the server + try { + const response = await fetch('/articles/render', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ articles: data.articles }), + }); + const html = await response.text(); + // Prepend the new articles HTML to the article list + const articleList = this.element.querySelector('.article-list'); + if (articleList) { + articleList.insertAdjacentHTML('afterbegin', html); + } + } catch (error) { + console.error('Error fetching rendered articles:', error); + } + } + } +} diff --git a/config/packages/messenger.yaml b/config/packages/messenger.yaml index b4a43b1..5ef0427 100644 --- a/config/packages/messenger.yaml +++ b/config/packages/messenger.yaml @@ -13,6 +13,7 @@ framework: routing: # Route your messages to the transports 'App\Message\FetchCommentsMessage': async + 'App\Message\FetchAuthorArticlesMessage': async # when@test: # framework: diff --git a/src/Controller/AuthorController.php b/src/Controller/AuthorController.php index 7c1b597..b8c7d8e 100644 --- a/src/Controller/AuthorController.php +++ b/src/Controller/AuthorController.php @@ -4,18 +4,29 @@ declare(strict_types=1); namespace App\Controller; +use App\Entity\Article; +use App\Message\FetchAuthorArticlesMessage; +use App\Repository\ArticleRepository; use App\Service\NostrClient; use App\Service\RedisCacheService; use App\Util\NostrKeyUtil; +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 swentel\nostr\Key\Key; use swentel\nostr\Nip19\Nip19Helper; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Messenger\Exception\ExceptionInterface; use Symfony\Component\Routing\Attribute\Route; +use Symfony\Component\Messenger\MessageBusInterface; +use Symfony\Component\Serializer\SerializerInterface; class AuthorController extends AbstractController { @@ -108,40 +119,43 @@ class AuthorController extends AbstractController /** * @throws Exception + * @throws ExceptionInterface + * @throws InvalidArgumentException */ #[Route('/p/{npub}', name: 'author-profile', requirements: ['npub' => '^npub1.*'])] - public function index($npub, NostrClient $nostrClient, RedisCacheService $redisCacheService, FinderInterface $finder): Response + public function index($npub, RedisCacheService $redisCacheService, FinderInterface $finder, + MessageBusInterface $messageBus): Response { $keys = new Key(); $pubkey = $keys->convertToHex($npub); $author = $redisCacheService->getMetadata($pubkey); - // Retrieve long-form content for the author - try { - $list = $nostrClient->getLongFormContentForPubkey($npub); - } catch (Exception $e) { - $list = []; - } - // Also look for articles in the Elastica index - $query = new Terms('pubkey', [$pubkey]); - $list = array_merge($list, $finder->find($query, 25)); - - // Sort articles by date - usort($list, function ($a, $b) { - return $b->getCreatedAt() <=> $a->getCreatedAt(); - }); - - $articles = []; - // Deduplicate by slugs - foreach ($list as $item) { - if (!key_exists((string) $item->getSlug(), $articles)) { - $articles[(string) $item->getSlug()] = $item; - } + + // Get articles using Elasticsearch with collapse on slug + $boolQuery = new BoolQuery(); + $boolQuery->addMust(new Term(['pubkey' => $pubkey])); + $query = new \Elastica\Query($boolQuery); + $query->setSort(['createdAt' => ['order' => 'desc']]); + $collapse = new Collapse(); + $collapse->setFieldname('slug'); + $query->setCollapse($collapse); + $articles = $finder->find($query); + + // Get latest createdAt for dispatching fetch message + if (!empty($articles)) { + $latest = $articles[0]->getCreatedAt()->getTimestamp(); + // Dispatch async message to fetch new articles since latest + 1 + $messageBus->dispatch(new FetchAuthorArticlesMessage($pubkey, $latest + 1)); + } else { + // No articles, fetch all + $messageBus->dispatch(new FetchAuthorArticlesMessage($pubkey, 0)); } + return $this->render('pages/author.html.twig', [ 'author' => $author, 'npub' => $npub, + 'pubkey' => $pubkey, 'articles' => $articles, 'is_author_profile' => true, ]); @@ -157,4 +171,18 @@ class AuthorController extends AbstractController $npub = $keys->convertPublicKeyToBech32($pubkey); return $this->redirectToRoute('author-profile', ['npub' => $npub]); } + + #[Route('/articles/render', name: 'render_articles', methods: ['POST'], options: ['csrf_protection' => false])] + public function renderArticles(Request $request, SerializerInterface $serializer): Response + { + + $data = json_decode($request->getContent(), true); + $articlesJson = json_encode($data['articles'] ?? []); + $articles = $serializer->deserialize($articlesJson, Article::class.'[]', 'json'); + + // Render the articles using the template + return $this->render('articles.html.twig', [ + 'articles' => $articles + ]); + } } diff --git a/src/Message/FetchAuthorArticlesMessage.php b/src/Message/FetchAuthorArticlesMessage.php new file mode 100644 index 0000000..3ec88f3 --- /dev/null +++ b/src/Message/FetchAuthorArticlesMessage.php @@ -0,0 +1,25 @@ +pubkey = $pubkey; + $this->since = $since; + } + + public function getPubkey(): string + { + return $this->pubkey; + } + + public function getSince(): int + { + return $this->since; + } +} diff --git a/src/MessageHandler/FetchAuthorArticlesHandler.php b/src/MessageHandler/FetchAuthorArticlesHandler.php new file mode 100644 index 0000000..521efd2 --- /dev/null +++ b/src/MessageHandler/FetchAuthorArticlesHandler.php @@ -0,0 +1,83 @@ +getPubkey(); + $since = $message->getSince(); + + $this->logger->info('Fetching new articles for author since timestamp', [ + 'pubkey' => $pubkey, + 'since' => $since + ]); + + try { + $articles = $this->nostrClient->getLongFormContentForPubkey($pubkey, $since); + $this->logger->info('Fetched and saved new articles for author', [ + 'pubkey' => $pubkey, + 'count' => count($articles) + ]); + + // Deduplicate by slug (keep latest) + // Sort articles by created_at descending + usort($articles, function($a, $b) { + return $b->getCreatedAt()->getTimestamp() <=> $a->getCreatedAt()->getTimestamp(); + }); + $uniqueArticles = []; + foreach ($articles as $article) { + $uniqueArticles[$article->getSlug()] = $article; + } + // Only keep articles with id !== null + $uniqueArticles = array_filter($uniqueArticles, fn($a) => $a->getId() !== null); + // Re-index array + $uniqueArticles = array_values($uniqueArticles); + // Serialize articles to arrays + $articleData = array_map(function($article) { + return [ + 'id' => $article->getId(), + 'slug' => $article->getSlug(), + 'title' => $article->getTitle(), + 'content' => $article->getContent(), + 'summary' => $article->getSummary(), + 'createdAt' => $article->getCreatedAt()->getTimestamp(), + 'pubkey' => $article->getPubkey(), + 'image' => $article->getImage(), + 'topics' => $article->getTopics(), + ]; + }, $uniqueArticles); + + // Publish updates to Mercure + $update = new Update( + '/articles/' . $pubkey, + json_encode(['articles' => $articleData]), + false + ); + $this->logger->info('Publishing articles update for pubkey: ' . $pubkey); + $this->hub->publish($update); + } catch (\Exception $e) { + $this->logger->error('Error fetching new articles for author', [ + 'pubkey' => $pubkey, + 'error' => $e->getMessage() + ]); + } + } +} diff --git a/src/Service/NostrClient.php b/src/Service/NostrClient.php index 7f649f6..f2390d8 100644 --- a/src/Service/NostrClient.php +++ b/src/Service/NostrClient.php @@ -527,7 +527,7 @@ class NostrClient /** * @throws \Exception */ - public function getLongFormContentForPubkey(string $ident): array + public function getLongFormContentForPubkey(string $ident, ?int $since = null): array { // Add user relays to the default set $authorRelays = $this->getTopReputableRelaysForAuthor($ident); @@ -538,12 +538,17 @@ class NostrClient } // Create request using the helper method + $filters = [ + 'authors' => [$ident], + 'limit' => 20 // default limit + ]; + if ($since !== null && $since > 0) { + $filters['since'] = $since; + } + $request = $this->createNostrRequest( kinds: [KindsEnum::LONGFORM], - filters: [ - 'authors' => [$ident], - 'limit' => 10 - ], + filters: $filters, relaySet: $relaySet ); diff --git a/templates/articles.html.twig b/templates/articles.html.twig new file mode 100644 index 0000000..53b3e24 --- /dev/null +++ b/templates/articles.html.twig @@ -0,0 +1,6 @@ +{% for article in articles %} + {% if article.slug is not empty and article.title is not empty %} + + {% endif %} +{% endfor %} + diff --git a/templates/pages/author.html.twig b/templates/pages/author.html.twig index 7e3ae5d..f7b3a14 100644 --- a/templates/pages/author.html.twig +++ b/templates/pages/author.html.twig @@ -13,7 +13,7 @@ {% endif %} -
+
{% if articles|length > 0 %} {% else %}