diff --git a/config/packages/cache.yaml b/config/packages/cache.yaml
index f7d2f2e..169282b 100644
--- a/config/packages/cache.yaml
+++ b/config/packages/cache.yaml
@@ -19,7 +19,7 @@ framework:
articles.cache:
adapter: cache.adapter.redis
provider: Redis
- default_lifetime: 3600
+ default_lifetime: 0
npub.cache:
adapter: cache.adapter.redis
provider: Redis
diff --git a/src/Controller/ArticleController.php b/src/Controller/ArticleController.php
index 882df4c..c7f60a0 100644
--- a/src/Controller/ArticleController.php
+++ b/src/Controller/ArticleController.php
@@ -97,12 +97,14 @@ class ArticleController extends AbstractController
$article = $articles[0];
- $cacheKey = 'article_' . $article->getEventId();
- $cacheItem = $articlesCache->getItem($cacheKey);
- if (!$cacheItem->isHit()) {
- $cacheItem->set($converter->convertToHTML($article->getContent()));
- $articlesCache->save($cacheItem);
- }
+ $parsed = $converter->convertToHTML($article->getContent());
+
+// $cacheKey = 'article_' . $article->getEventId();
+// $cacheItem = $articlesCache->getItem($cacheKey);
+// if (!$cacheItem->isHit()) {
+// $cacheItem->set($converter->convertToHTML($article->getContent()));
+// $articlesCache->save($cacheItem);
+// }
$key = new Key();
$npub = $key->convertPublicKeyToBech32($article->getPubkey());
@@ -126,7 +128,7 @@ class ArticleController extends AbstractController
'article' => $article,
'author' => $author,
'npub' => $npub,
- 'content' => $cacheItem->get(),
+ 'content' => $parsed, //$cacheItem->get(),
'canEdit' => $canEdit,
'canonical' => $canonical
]);
diff --git a/src/Controller/AuthorController.php b/src/Controller/AuthorController.php
index b8c7d8e..a861c91 100644
--- a/src/Controller/AuthorController.php
+++ b/src/Controller/AuthorController.php
@@ -94,29 +94,6 @@ class AuthorController extends AbstractController
]);
}
- /**
- * @throws Exception
- */
- #[Route('/p/{npub}/about', name: 'author-about', requirements: ['npub' => '^npub1.*'])]
- public function about($npub, RedisCacheService $redisCacheService): Response
- {
- $keys = new Key();
- $pubkey = $keys->convertToHex($npub);
-
- // Get metadata with raw event for debugging
- $profileData = $redisCacheService->getMetadataWithRawEvent($npub);
- $author = $profileData['metadata'];
- $rawEvent = $profileData['rawEvent'];
-
- return $this->render('pages/author-about.html.twig', [
- 'author' => $author,
- 'npub' => $npub,
- 'pubkey' => $pubkey,
- 'rawEvent' => $rawEvent,
- 'is_author_profile' => true,
- ]);
- }
-
/**
* @throws Exception
* @throws ExceptionInterface
diff --git a/src/Service/NostrClient.php b/src/Service/NostrClient.php
index ea7a013..afe931e 100644
--- a/src/Service/NostrClient.php
+++ b/src/Service/NostrClient.php
@@ -108,6 +108,7 @@ class NostrClient
{
$relaySet = $this->defaultRelaySet;
$relaySet->addRelay(new Relay('wss://profiles.nostr1.com')); // profile aggregator
+ $relaySet->addRelay(new Relay('wss://purplepag.es')); // profile aggregator
$this->logger->info('Getting metadata for pubkey ' . $pubkey );
$request = $this->createNostrRequest(
kinds: [KindsEnum::METADATA],
@@ -131,45 +132,6 @@ class NostrClient
return $events[0];
}
- public function getNpubLongForm($npub): void
- {
- $subscription = new Subscription();
- $subscriptionId = $subscription->setId();
- $filter = new Filter();
- $filter->setKinds([KindsEnum::LONGFORM]);
- $filter->setAuthors([$npub]);
- $filter->setSince(strtotime('-6 months')); // too much?
- $requestMessage = new RequestMessage($subscriptionId, [$filter]);
-
- // if user is logged in, use their settings
- /* @var $user */
- $user = $this->tokenStorage->getToken()?->getUser();
- $relays = $this->defaultRelaySet;
- if ($user && $user->getRelays()) {
- $relays = new RelaySet();
- foreach ($user->getRelays() as $relayArr) {
- if ($relayArr[2] == 'write') {
- $relays->addRelay(new Relay($relayArr[1]));
- }
- }
- }
-
- $request = new Request($relays, $requestMessage);
-
- $response = $request->send();
- // response is an n-dimensional array, where n is the number of relays in the set
- // check that response has events in the results
- foreach ($response as $relayRes) {
- $filtered = array_filter($relayRes, function ($item) {
- return $item->type === 'EVENT';
- });
- if (count($filtered) > 0) {
- $this->saveLongFormContent($filtered);
- }
- }
- // TODO handle relays that require auth
- }
-
public function publishEvent(Event $event, array $relays): array
{
$eventMessage = new EventMessage($event);
@@ -323,6 +285,47 @@ class NostrClient
return $events[0];
}
+ /**
+ * Get multiple events by their IDs
+ *
+ * @param array $eventIds Array of event IDs
+ * @param array $relays Optional array of relay URLs to query
+ * @return array Array of events indexed by ID
+ * @throws \Exception
+ */
+ public function getEventsByIds(array $eventIds, array $relays = []): array
+ {
+ if (empty($eventIds)) {
+ return [];
+ }
+
+ $this->logger->info('Getting events by IDs', ['event_ids' => $eventIds, 'relays' => $relays]);
+
+ // Use provided relays or default if empty
+ $relaySet = empty($relays) ? $this->defaultRelaySet : $this->createRelaySet($relays);
+
+ // Create request using the helper method
+ $request = $this->createNostrRequest(
+ kinds: [],
+ filters: ['ids' => $eventIds],
+ relaySet: $relaySet
+ );
+
+ // Process the response
+ $events = $this->processResponse($request->send(), function($event) {
+ $this->logger->debug('Received event', ['event' => $event]);
+ return $event;
+ });
+
+ // Index events by ID
+ $eventsMap = [];
+ foreach ($events as $event) {
+ $eventsMap[$event->id] = $event;
+ }
+
+ return $eventsMap;
+ }
+
/**
* Fetch event by naddr
*
diff --git a/src/Service/RedisCacheService.php b/src/Service/RedisCacheService.php
index 9f6d725..7e95805 100644
--- a/src/Service/RedisCacheService.php
+++ b/src/Service/RedisCacheService.php
@@ -7,6 +7,7 @@ use App\Entity\Event;
use App\Enum\KindsEnum;
use App\Util\NostrKeyUtil;
use Doctrine\ORM\EntityManagerInterface;
+use Exception;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException;
use Psr\Log\LoggerInterface;
@@ -55,7 +56,7 @@ readonly class RedisCacheService
$rawEvent = $this->fetchRawUserEvent($pubkey);
return $this->parseUserMetadata($rawEvent, $pubkey);
});
- } catch (InvalidArgumentException $e) {
+ } catch (Exception|InvalidArgumentException $e) {
$this->logger->error('Error getting user data.', ['exception' => $e]);
}
// If content is still default, delete cache to retry next time
@@ -73,6 +74,7 @@ readonly class RedisCacheService
/**
* Fetch raw user event from Nostr client, with error fallback.
* @param string $pubkey Hex-encoded public key
+ * @throws Exception
*/
private function fetchRawUserEvent(string $pubkey): \stdClass
{
@@ -80,12 +82,8 @@ readonly class RedisCacheService
return $this->nostrClient->getPubkeyMetadata($pubkey);
} catch (\Exception $e) {
$this->logger->error('Error getting user data.', ['exception' => $e]);
- $rawEvent = new \stdClass();
- $rawEvent->content = json_encode([
- 'name' => substr($pubkey, 0, 8) . '…' . substr($pubkey, -4)
- ]);
- $rawEvent->tags = [];
- return $rawEvent;
+ // Rethrow exception to be caught in getMetadata
+ throw $e;
}
}
@@ -131,43 +129,6 @@ readonly class RedisCacheService
return $contentData;
}
- /**
- * Get metadata with raw event for debugging purposes.
- *
- * @param string $pubkey Hex-encoded public key
- * @return array{metadata: \stdClass, rawEvent: \stdClass}
- * @throws InvalidArgumentException
- */
- public function getMetadataWithRawEvent(string $pubkey): array
- {
- if (!NostrKeyUtil::isHexPubkey($pubkey)) {
- throw new \InvalidArgumentException('getMetadataWithRawEvent expects hex pubkey');
- }
- $cacheKey = '0_with_raw_' . $pubkey;
- try {
- return $this->npubCache->get($cacheKey, function (ItemInterface $item) use ($pubkey) {
- $item->expiresAfter(3600); // 1 hour, adjust as needed
- $rawEvent = $this->fetchRawUserEvent($pubkey);
- $contentData = $this->parseUserMetadata($rawEvent, $pubkey);
- return [
- 'metadata' => $contentData,
- 'rawEvent' => $rawEvent
- ];
- });
- } catch (InvalidArgumentException $e) {
- $this->logger->error('Error getting user data with raw event.', ['exception' => $e]);
- $content = new \stdClass();
- $content->name = substr($pubkey, 0, 8) . '…' . substr($pubkey, -4);
- $rawEvent = new \stdClass();
- $rawEvent->content = json_encode($content);
- $rawEvent->tags = [];
- return [
- 'metadata' => $content,
- 'rawEvent' => $rawEvent
- ];
- }
- }
-
/**
* Fetch metadata for multiple pubkeys at once using Redis getItems.
* Falls back to getMetadata for cache misses.
diff --git a/src/Util/CommonMark/Converter.php b/src/Util/CommonMark/Converter.php
index c35de76..8866964 100644
--- a/src/Util/CommonMark/Converter.php
+++ b/src/Util/CommonMark/Converter.php
@@ -24,6 +24,12 @@ use League\CommonMark\Extension\TableOfContents\TableOfContentsExtension;
use League\CommonMark\MarkdownConverter;
use League\CommonMark\Renderer\HtmlDecorator;
use Twig\Environment as TwigEnvironment;
+use nostriphant\NIP19\Bech32;
+use nostriphant\NIP19\Data\NAddr;
+use nostriphant\NIP19\Data\NEvent;
+use nostriphant\NIP19\Data\Note;
+use nostriphant\NIP19\Data\NProfile;
+use nostriphant\NIP19\Data\NPub;
readonly class Converter
{
@@ -39,6 +45,9 @@ readonly class Converter
*/
public function convertToHTML(string $markdown): string
{
+ // Preprocess nostr: links for batching
+ $markdown = $this->preprocessNostrLinks($markdown);
+
// Check if the article has more than three headings
// Match all headings (from level 1 to 6)
preg_match_all('/^#+\s.*$/m', $markdown, $matches);
@@ -59,7 +68,7 @@ readonly class Converter
],
'embed' => [
'adapter' => new OscaroteroEmbedAdapter(), // See the "Adapter" documentation below
- 'allowed_domains' => ['youtube.com', 'twitter.com', 'github.com'],
+ 'allowed_domains' => ['youtube.com', 'x.com', 'github.com', 'fountain.fm'],
'fallback' => 'link'
],
];
@@ -69,8 +78,6 @@ readonly class Converter
$environment->addExtension(new FootnoteExtension());
$environment->addExtension(new TableExtension());
$environment->addExtension(new StrikethroughExtension());
- // create a custom extension, that handles nostr mentions
- $environment->addExtension(new NostrSchemeExtension($this->redisCacheService, $this->nostrClient, $this->twig, $this->nostrKeyUtil));
$environment->addExtension(new SmartPunctExtension());
$environment->addExtension(new EmbedExtension());
$environment->addRenderer(Embed::class, new HtmlDecorator(new EmbedRenderer(), 'div', ['class' => 'embedded-content']));
@@ -88,4 +95,140 @@ readonly class Converter
return $converter->convert($content);
}
+ private function preprocessNostrLinks(string $markdown): string
+ {
+ // Find all nostr: links
+ preg_match_all('/nostr:(?:npub1|nprofile1|note1|nevent1|naddr1)[^\\s<>()\\[\\]{}"\'`.,;:!?]*/', $markdown, $matches);
+
+ if (empty($matches[0])) {
+ return $markdown;
+ }
+
+ $links = array_unique($matches[0]);
+ $replacements = [];
+
+ // Collect data for batching
+ $pubkeys = [];
+ $eventIds = [];
+
+ foreach ($links as $link) {
+ $bechEncoded = substr($link, 6); // Remove "nostr:"
+ try {
+ $decoded = new Bech32($bechEncoded);
+ switch ($decoded->type) {
+ case 'npub':
+ /** @var NPub $object */
+ $object = $decoded->data;
+ $hex = $this->nostrKeyUtil->npubToHex($bechEncoded);
+ $pubkeys[$hex] = $bechEncoded;
+ break;
+ case 'nprofile':
+ /** @var NProfile $object */
+ $object = $decoded->data;
+ $pubkeys[$object->pubkey] = $bechEncoded;
+ break;
+ case 'note':
+ /** @var Note $object */
+ $object = $decoded->data;
+ $eventIds[$object->data] = $bechEncoded;
+ break;
+ case 'nevent':
+ /** @var NEvent $object */
+ $object = $decoded->data;
+ $eventIds[$object->id] = $bechEncoded;
+ break;
+ case 'naddr':
+ // For naddr, we might need to fetch the event, but for now, handle as simple link
+ break;
+ }
+ } catch (\Exception $e) {
+ // Invalid link, skip
+ continue;
+ }
+ }
+
+ // Fetch metadata in batch (actually, getMetadata is cached, so just prepare)
+ $metadata = [];
+ foreach (array_keys($pubkeys) as $hex) {
+ try {
+ $metadata[$hex] = $this->redisCacheService->getMetadata($hex);
+ } catch (\Exception $e) {
+ $metadata[$hex] = null;
+ }
+ }
+
+ // Fetch events in batch
+ $events = [];
+ if (!empty($eventIds)) {
+ try {
+ $events = $this->nostrClient->getEventsByIds(array_keys($eventIds));
+ } catch (\Exception $e) {
+ // If batch fails, events remain empty
+ }
+ }
+
+ // Now, render each link
+ foreach ($links as $link) {
+ $bechEncoded = substr($link, 6);
+ try {
+ $decoded = new Bech32($bechEncoded);
+ $html = $this->renderNostrLink($decoded, $bechEncoded, $metadata, $events);
+ $replacements[$link] = $html;
+ } catch (\Exception $e) {
+ // Keep original link if error
+ $replacements[$link] = $link;
+ }
+ }
+
+ // Replace in markdown
+ return str_replace(array_keys($replacements), array_values($replacements), $markdown);
+ }
+
+ private function renderNostrLink(Bech32 $decoded, string $bechEncoded, array $metadata, array $events): string
+ {
+ switch ($decoded->type) {
+ case 'npub':
+ $hex = $this->nostrKeyUtil->npubToHex($bechEncoded);
+ $profile = $metadata[$hex] ?? null;
+ if ($profile && isset($profile->name)) {
+ return '@' . htmlspecialchars($profile->name) . '';
+ } else {
+ return '@' . substr($bechEncoded, 5, 8) . '…';
+ }
+ case 'nprofile':
+ $object = $decoded->data;
+ return '@' . substr($bechEncoded, 9, 8) . '…';
+ case 'note':
+ $object = $decoded->data;
+ $event = $events[$object->data] ?? null;
+ if ($event && $event->kind === 20) {
+ $pictureCardHtml = $this->twig->render('/event/_kind20_picture.html.twig', [
+ 'event' => $event,
+ 'embed' => true
+ ]);
+ return $pictureCardHtml;
+ } else {
+ return '' . $bechEncoded . '';
+ }
+ case 'nevent':
+ $object = $decoded->data;
+ $event = $events[$object->id] ?? null;
+ if ($event) {
+ $authorMetadata = $metadata[$event->pubkey] ?? null;
+ $eventCardHtml = $this->twig->render('components/event_card.html.twig', [
+ 'event' => $event,
+ 'author' => $authorMetadata,
+ 'nevent' => $bechEncoded
+ ]);
+ return $eventCardHtml;
+ } else {
+ return '' . $bechEncoded . '';
+ }
+ case 'naddr':
+ return '' . $bechEncoded . '';
+ default:
+ return $bechEncoded;
+ }
+ }
+
}
diff --git a/templates/pages/article.html.twig b/templates/pages/article.html.twig
index 713c8a3..848e428 100644
--- a/templates/pages/article.html.twig
+++ b/templates/pages/article.html.twig
@@ -16,6 +16,10 @@
+ {{ article.topics|json_encode(constant('JSON_PRETTY_PRINT')) }}
+
+
{{ address }}
- {{ author.lud16 }}
- {% endif %}
- {{ lnurl }}
- {{ author.lud06 }}
- {% endif %}
- {{ pubkey }}
- {{ npub }}
- {{ rawEvent.id ?? 'N/A' }}
-
- {{ rawEvent.created_at ?? 'N/A' }} ({{ rawEvent.created_at is defined ? rawEvent.created_at|date('Y-m-d H:i:s') : 'N/A' }})
-
- {{ rawEvent.tags is defined ? rawEvent.tags|json_encode(constant('JSON_PRETTY_PRINT')) : '[]' }}
-
- {{ rawEvent.content ?? '{}' }}
-
- {{ rawEvent.sig ?? 'N/A' }}
-
- {{ rawEvent|json_encode(constant('JSON_PRETTY_PRINT')) }}
-