Browse Source

Async articles

imwald
Nuša Pukšič 3 months ago
parent
commit
42bde008f1
  1. 56
      assets/controllers/author_articles_controller.js
  2. 1
      config/packages/messenger.yaml
  3. 72
      src/Controller/AuthorController.php
  4. 25
      src/Message/FetchAuthorArticlesMessage.php
  5. 83
      src/MessageHandler/FetchAuthorArticlesHandler.php
  6. 15
      src/Service/NostrClient.php
  7. 6
      templates/articles.html.twig
  8. 2
      templates/pages/author.html.twig

56
assets/controllers/author_articles_controller.js

@ -0,0 +1,56 @@ @@ -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);
}
}
}
}

1
config/packages/messenger.yaml

@ -13,6 +13,7 @@ framework: @@ -13,6 +13,7 @@ framework:
routing:
# Route your messages to the transports
'App\Message\FetchCommentsMessage': async
'App\Message\FetchAuthorArticlesMessage': async
# when@test:
# framework:

72
src/Controller/AuthorController.php

@ -4,18 +4,29 @@ declare(strict_types=1); @@ -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 @@ -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 @@ -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
]);
}
}

25
src/Message/FetchAuthorArticlesMessage.php

@ -0,0 +1,25 @@ @@ -0,0 +1,25 @@
<?php
namespace App\Message;
class FetchAuthorArticlesMessage
{
private string $pubkey;
private int $since;
public function __construct(string $pubkey, int $since)
{
$this->pubkey = $pubkey;
$this->since = $since;
}
public function getPubkey(): string
{
return $this->pubkey;
}
public function getSince(): int
{
return $this->since;
}
}

83
src/MessageHandler/FetchAuthorArticlesHandler.php

@ -0,0 +1,83 @@ @@ -0,0 +1,83 @@
<?php
namespace App\MessageHandler;
use App\Message\FetchAuthorArticlesMessage;
use App\Service\NostrClient;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
use Symfony\Component\Serializer\SerializerInterface;
#[AsMessageHandler]
class FetchAuthorArticlesHandler
{
public function __construct(
private readonly NostrClient $nostrClient,
private readonly LoggerInterface $logger,
private readonly HubInterface $hub,
private readonly SerializerInterface $serializer
) {}
public function __invoke(FetchAuthorArticlesMessage $message): void
{
$pubkey = $message->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()
]);
}
}
}

15
src/Service/NostrClient.php

@ -527,7 +527,7 @@ class NostrClient @@ -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 @@ -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
);

6
templates/articles.html.twig

@ -0,0 +1,6 @@ @@ -0,0 +1,6 @@
{% for article in articles %}
{% if article.slug is not empty and article.title is not empty %}
<twig:Molecules:Card :article="article" :is_author_profile="true"></twig:Molecules:Card>
{% endif %}
{% endfor %}

2
templates/pages/author.html.twig

@ -13,7 +13,7 @@ @@ -13,7 +13,7 @@
{% endif %}
</div>
<div class="w-container">
<div class="w-container" data-controller="author-articles" data-author-articles-pubkey-value="{{ pubkey }}" data-author-articles-hub-url-value="{{ mercure_public_hub_url }}">
{% if articles|length > 0 %}
<twig:Organisms:CardList :list="articles" class="article-list"></twig:Organisms:CardList>
{% else %}

Loading…
Cancel
Save