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 %}
+