clone of github.com/decent-newsroom/newsroom
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

337 lines
13 KiB

<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\Article;
use App\Entity\Event;
use App\Enum\KindsEnum;
use App\Message\FetchAuthorArticlesMessage;
use App\Service\RedisCacheService;
use App\Service\RedisViewStore;
use App\Service\Search\ArticleSearchInterface;
use App\ReadModel\RedisView\RedisViewFactory;
use App\Util\NostrKeyUtil;
use Doctrine\ORM\EntityManagerInterface;
use Exception;
use FOS\ElasticaBundle\Finder\FinderInterface;
use Psr\Cache\InvalidArgumentException;
use Psr\Log\LoggerInterface;
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
{
/**
* Reading List Index
*/
#[Route('/p/{npub}/lists', name: 'author-reading-lists')]
public function readingLists($npub,
EntityManagerInterface $em,
NostrKeyUtil $keyUtil,
LoggerInterface $logger): Response
{
// Convert npub to hex pubkey
$pubkey = $keyUtil->npubToHex($npub);
$logger->info(sprintf('Reading list: pubkey=%s', $pubkey));
// Find reading lists by pubkey, kind 30040 directly from database
$repo = $em->getRepository(Event::class);
$lists = $repo->findBy(['pubkey' => $pubkey, 'kind' => KindsEnum::PUBLICATION_INDEX], ['created_at' => 'DESC']);
// Filter to ensure they have a 'type:reading-list' tag
$filteredLists = [];
$seenSlugs = [];
foreach ($lists as $ev) {
if (!$ev instanceof Event) continue;
$tags = $ev->getTags();
$isReadingList = false;
$title = null; $slug = null; $summary = null;
foreach ($tags as $t) {
if (is_array($t)) {
if (($t[0] ?? null) === 'type' && ($t[1] ?? null) === 'reading-list') { $isReadingList = true; }
if (($t[0] ?? null) === 'title') { $title = (string)$t[1]; }
if (($t[0] ?? null) === 'summary') { $summary = (string)$t[1]; }
if (($t[0] ?? null) === 'd') { $slug = (string)$t[1]; }
}
}
if ($isReadingList) {
// Collapse by slug: keep only newest per slug
$keySlug = $slug ?: ('__no_slug__:' . $ev->getId());
if (isset($seenSlugs[$slug ?? $keySlug])) {
continue;
}
$seenSlugs[$slug ?? $keySlug] = true;
$filteredLists[] = $ev;
}
}
return $this->render('profile/author-lists.html.twig', [
'lists' => $filteredLists,
'npub' => $npub,
]);
}
/**
* List
* @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.*'])]
public function media($npub, RedisCacheService $redisCacheService, NostrKeyUtil $keyUtil): Response
{
$pubkey = $keyUtil->npubToHex($npub);
$author = $redisCacheService->getMetadata($pubkey);
// Use paginated cached media events - fetches 200 from relays, serves first 24
$paginatedData = $redisCacheService->getMediaEventsPaginated($pubkey, 1, 24);
$mediaEvents = $paginatedData['events'];
// Encode event IDs as note1... for each event
foreach ($mediaEvents as $event) {
$nip19 = new Nip19Helper();
$event->noteId = $nip19->encodeNote($event->id);
}
return $this->render('profile/author-media.html.twig', [
'author' => $author,
'npub' => $npub,
'pubkey' => $pubkey,
'pictureEvents' => $mediaEvents,
'hasMore' => $paginatedData['hasMore'],
'total' => $paginatedData['total'],
'is_author_profile' => true,
]);
}
/**
* AJAX endpoint to load more media events
* @throws Exception
*/
#[Route('/p/{npub}/media/load-more', name: 'author-media-load-more', requirements: ['npub' => '^npub1.*'])]
public function mediaLoadMore($npub, Request $request, RedisCacheService $redisCacheService): Response
{
$page = $request->query->getInt('page', 2); // Default to page 2
// Get paginated data from cache - 24 items per page
$paginatedData = $redisCacheService->getMediaEventsPaginated($npub, $page, 24);
$mediaEvents = $paginatedData['events'];
// Encode event IDs as note1... for each event
foreach ($mediaEvents as $event) {
$nip19 = new Nip19Helper();
$event->noteId = $nip19->encodeNote($event->id);
}
return $this->json([
'events' => array_map(function($event) {
return [
'id' => $event->id,
'noteId' => $event->noteId,
'content' => $event->content ?? '',
'created_at' => $event->created_at,
'kind' => $event->kind,
'tags' => $event->tags ?? [],
];
}, $mediaEvents),
'hasMore' => $paginatedData['hasMore'],
'page' => $paginatedData['page'],
'total' => $paginatedData['total'],
]);
}
/**
* Author profile and articles
* @throws Exception
* @throws ExceptionInterface
* @throws InvalidArgumentException
*/
#[Route('/p/{npub}', name: 'author-profile', requirements: ['npub' => '^npub1.*'])]
#[Route('/p/{npub}/articles', name: 'author-articles', requirements: ['npub' => '^npub1.*'])]
public function index($npub, RedisCacheService $redisCacheService, FinderInterface $finder,
MessageBusInterface $messageBus, RedisViewStore $viewStore,
RedisViewFactory $viewFactory, ArticleSearchInterface $articleSearch): Response
{
$keys = new Key();
$pubkey = $keys->convertToHex($npub);
$author = $redisCacheService->getMetadata($pubkey);
// Try to get cached view first
$cachedArticles = $viewStore->fetchUserArticles($pubkey);
$fromCache = false;
if ($cachedArticles !== null) {
// Redis view data already matches template - just extract articles
$articles = [];
foreach ($cachedArticles as $baseObject) {
if (isset($baseObject['article'])) {
$articles[] = (object) $baseObject['article'];
}
}
$fromCache = true;
} else {
// Cache miss - query using search service
$articles = $articleSearch->findByPubkey($pubkey, 100, 0);
// Build and cache Redis views for next time
if (!empty($articles)) {
try {
$baseObjects = [];
foreach ($articles as $article) {
if ($article instanceof Article) {
$baseObjects[] = $viewFactory->articleBaseObject($article, $author);
}
}
if (!empty($baseObjects)) {
$viewStore->storeUserArticles($pubkey, $baseObjects);
}
} catch (\Exception $e) {
// Log but don't fail the request
error_log('Failed to cache user articles view: ' . $e->getMessage());
}
}
}
// Get latest createdAt for dispatching fetch message
if (!empty($articles)) {
// Handle both Article entities and cached arrays
if (is_array($articles[0])) {
$latest = isset($articles[0]['article']['publishedAt'])
? strtotime($articles[0]['article']['publishedAt'])
: time();
} else {
$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('profile/author.html.twig', [
'author' => $author,
'npub' => $npub,
'pubkey' => $pubkey,
'articles' => $articles,
'is_author_profile' => true,
'from_cache' => $fromCache,
]);
}
/**
* Redirect from /p/{pubkey} to /p/{npub}
* @throws Exception
*/
#[Route('/p/{pubkey}', name: 'author-redirect')]
public function authorRedirect($pubkey): Response
{
$keys = new Key();
$npub = $keys->convertPublicKeyToBech32($pubkey);
return $this->redirectToRoute('author-profile', ['npub' => $npub]);
}
/**
* 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
{
$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
]);
}
}