diff --git a/assets/bootstrap.js b/assets/bootstrap.js
index d4e50c9..711d2d6 100644
--- a/assets/bootstrap.js
+++ b/assets/bootstrap.js
@@ -1,5 +1,11 @@
import { startStimulusApp } from '@symfony/stimulus-bundle';
+import ArticleCommentsController from './controllers/article_comments_controller.js';
const app = startStimulusApp();
-// register any custom, 3rd party controllers here
-// app.register('some_controller_name', SomeImportedController);
+
+// Ensure lazy comment loader is registered (Asset Mapper discovery can miss new files until rebuild).
+try {
+ app.register('article-comments', ArticleCommentsController);
+} catch {
+ /* already registered by the bundle */
+}
diff --git a/assets/controllers/article_comments_controller.js b/assets/controllers/article_comments_controller.js
new file mode 100644
index 0000000..639cf94
--- /dev/null
+++ b/assets/controllers/article_comments_controller.js
@@ -0,0 +1,35 @@
+import { Controller } from '@hotwired/stimulus';
+
+/**
+ * Fetches the comment thread HTML after the article shell has rendered (no relay I/O on first paint).
+ */
+export default class extends Controller {
+ static values = {
+ url: String,
+ };
+
+ static targets = ['container'];
+
+ connect() {
+ if (!this.hasContainerTarget || !this.urlValue) {
+ return;
+ }
+ void this.load();
+ }
+
+ async load() {
+ try {
+ const res = await fetch(this.urlValue, {
+ headers: { Accept: 'text/html', 'X-Requested-With': 'XMLHttpRequest' },
+ });
+ if (!res.ok) {
+ throw new Error(`HTTP ${res.status}`);
+ }
+ const html = await res.text();
+ this.containerTarget.innerHTML = html;
+ } catch {
+ this.containerTarget.innerHTML =
+ '
Comments could not be loaded.
';
+ }
+ }
+}
diff --git a/assets/styles/app.css b/assets/styles/app.css
index a269bc6..7a70cce 100644
--- a/assets/styles/app.css
+++ b/assets/styles/app.css
@@ -147,7 +147,8 @@ svg.icon {
.featured-list {
display: flex;
flex-direction: row;
- flex-wrap: wrap;
+ flex-wrap: nowrap;
+ align-items: flex-start;
}
@@ -162,6 +163,12 @@ svg.icon {
flex-direction: column !important;
}
+ .featured-list > div:first-child,
+ .featured-list > div:last-child {
+ flex: 1 1 auto;
+ width: 100%;
+ }
+
.featured-list .card-header {
margin-top: 20px;
}
@@ -179,12 +186,15 @@ div:nth-child(odd) .featured-list {
flex-direction: row-reverse;
}
-.featured-list div:first-child {
- flex: 0 0 66%; /* each item takes up 50% width = 2 columns */
+/* Only the two column wrappers — not every .card that happens to be :first-child/:last-child of its parent */
+.featured-list > div:first-child {
+ flex: 0 0 66%;
+ min-width: 0;
}
-.featured-list div:last-child {
- flex: 0 0 34%; /* each item takes up 50% width = 2 columns */
+.featured-list > div:last-child {
+ flex: 0 0 34%;
+ min-width: 0;
}
.featured-list h2.card-title {
@@ -212,11 +222,14 @@ div:nth-child(odd) .featured-list {
display: flex;
flex-direction: row;
justify-content: space-between;
- align-items: baseline;
+ align-items: center;
+ gap: 0.75rem;
+ min-width: 0;
}
.article-list .metadata p {
margin: 0;
+ min-width: 0;
}
.truncate {
@@ -300,6 +313,39 @@ div:nth-child(odd) .featured-list {
.header__logo h1 {
font-weight: normal;
+ margin: 0;
+}
+
+/* Long site name: one line with ellipsis on narrow viewports */
+.brand__title {
+ min-width: 0;
+}
+
+@media (max-width: 1024px) {
+ .header__logo .brand {
+ font-size: clamp(1rem, 4.2vw, 1.45rem);
+ gap: 0.35rem;
+ line-height: 1.2;
+ justify-content: flex-start;
+ text-align: left;
+ }
+
+ .brand__title {
+ flex: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ .header__logo-circle {
+ width: 44px;
+ height: 44px;
+ }
+
+ .hamburger {
+ flex-shrink: 0;
+ margin-left: 0;
+ }
}
/* Fixed square + overflow clips to a true circle. Logo img is out-of-flow so
@@ -546,7 +592,8 @@ footer a {
z-index: 1;
width: 100%;
height: 100%;
- max-width: none;
+ max-width: 100%;
+ max-height: 100%;
object-fit: cover;
object-position: center;
display: block;
@@ -724,8 +771,8 @@ a:focus-visible {
}
@media (max-width: 600px) {
- h1.brand {
- font-size: 2.2rem;
+ .header__logo .brand {
+ font-size: clamp(0.95rem, 4.8vw, 1.25rem);
}
.header__logo-circle {
diff --git a/assets/styles/article.css b/assets/styles/article.css
index ce7a8d9..c8e8bd2 100644
--- a/assets/styles/article.css
+++ b/assets/styles/article.css
@@ -92,3 +92,7 @@ blockquote p {
height: auto;
aspect-ratio: 16/9;
}
+
+.article-comments-async .comments--pending {
+ margin: 1rem 0;
+}
diff --git a/assets/styles/card.css b/assets/styles/card.css
index eacbac7..1c8cbcd 100644
--- a/assets/styles/card.css
+++ b/assets/styles/card.css
@@ -37,13 +37,19 @@ h2.card-title {
.article-list .card {
margin-bottom: 1rem;
+ min-width: 0; /* column flex: do not let cover images set unshrinkable row width */
+}
+
+.card-header {
+ overflow: hidden;
}
.card-header img {
+ display: block;
max-width: 100%;
- height: auto;
- max-height: 200px;
width: 100%;
+ height: auto;
+ max-height: 220px;
object-fit: cover;
}
diff --git a/assets/styles/layout.css b/assets/styles/layout.css
index dff9ba9..b82957f 100644
--- a/assets/styles/layout.css
+++ b/assets/styles/layout.css
@@ -83,7 +83,18 @@ header {
/* Mobile Styles */
@media (max-width: 1024px) {
.header__logo {
- justify-content: space-around;
+ box-sizing: border-box;
+ justify-content: space-between;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.4rem max(0.65rem, env(safe-area-inset-left)) 0.4rem max(0.65rem, env(safe-area-inset-right));
+ }
+
+ .header__brand {
+ flex: 1;
+ min-width: 0;
+ display: block;
+ text-align: left;
}
.header__categories {
@@ -111,6 +122,7 @@ header {
main {
margin-top: 140px;
flex-grow: 1;
+ min-width: 0; /* flex item: allow shrinking below wide images / intrinsic min-content */
padding: 1em;
word-break: break-word;
}
diff --git a/assets/styles/nostr-previews.css b/assets/styles/nostr-previews.css
index 83b7451..8cd20a8 100644
--- a/assets/styles/nostr-previews.css
+++ b/assets/styles/nostr-previews.css
@@ -32,6 +32,11 @@
margin-top: 0.5rem;
}
+.nostr-preview.nostr-preview--inline {
+ margin: 1rem 0;
+ max-width: 100%;
+}
+
.nostr-preview .nostr-preview-link a {
color: var(--color-link);
text-decoration: underline;
diff --git a/src/Controller/ArticleController.php b/src/Controller/ArticleController.php
index 3e91261..3614275 100644
--- a/src/Controller/ArticleController.php
+++ b/src/Controller/ArticleController.php
@@ -5,6 +5,7 @@ namespace App\Controller;
use App\Entity\Article;
use App\Enum\KindsEnum;
use App\Form\EditorType;
+use App\Service\ArticleCommentThreadLoader;
use App\Service\NostrClient;
use App\Service\CacheService;
use App\Util\CommonMark\Converter;
@@ -24,6 +25,49 @@ use Symfony\Component\Workflow\WorkflowInterface;
class ArticleController extends AbstractController
{
+ /**
+ * Lazy-loaded comment thread (HTML fragment for Stimulus). Must not live under /article/{naddr}.
+ */
+ #[Route('/fragment/comments', name: 'article_comments_fragment', methods: ['GET'])]
+ public function commentsFragment(Request $request, ArticleCommentThreadLoader $loader): Response
+ {
+ $coordinate = $request->query->getString('coordinate');
+ if ($coordinate === '' || !self::isValidNostrCoordinate($coordinate)) {
+ return new Response('Invalid coordinate', Response::HTTP_BAD_REQUEST);
+ }
+
+ $headers = [
+ 'Content-Type' => 'text/html; charset=UTF-8',
+ 'Cache-Control' => 'private, max-age=60',
+ ];
+
+ try {
+ $data = $loader->load($coordinate);
+
+ return $this->render('components/Organisms/Comments.html.twig', $data, new Response(
+ '',
+ Response::HTTP_OK,
+ $headers
+ ));
+ } catch (\Throwable) {
+ return new Response('', Response::HTTP_OK, $headers);
+ }
+ }
+
+ private static function isValidNostrCoordinate(string $coordinate): bool
+ {
+ $parts = explode(':', $coordinate, 3);
+ if (\count($parts) !== 3) {
+ return false;
+ }
+ [$kind, $pubkey, $d] = $parts;
+ if ($d === '' || !ctype_digit((string) $kind)) {
+ return false;
+ }
+
+ return strlen($pubkey) === 64 && ctype_xdigit($pubkey);
+ }
+
/**
* @throws \Exception
*/
@@ -41,9 +85,10 @@ class ArticleController extends AbstractController
$slug = $data->identifier;
$relays = $data->relays;
$author = $data->pubkey;
- $kind = $data->kind;
+ $kind = (int) $data->kind;
- if ($kind !== KindsEnum::LONGFORM->value) {
+ $allowedKinds = [KindsEnum::LONGFORM->value, KindsEnum::LONGFORM_DRAFT->value];
+ if (!\in_array($kind, $allowedKinds, true)) {
throw new \Exception('Not a long form article');
}
diff --git a/src/Controller/DefaultController.php b/src/Controller/DefaultController.php
index f2ce236..8e917f4 100644
--- a/src/Controller/DefaultController.php
+++ b/src/Controller/DefaultController.php
@@ -33,7 +33,8 @@ class DefaultController extends AbstractController
{
$npub = $this->params->get('npub');
$dTag = $this->params->get('d_tag');
- $cacheKey = 'magazine-' . $dTag;
+ // Key must match {@see Header} — `magazine_root_` avoids stale `null` entries from the old Header callback.
+ $cacheKey = 'magazine_root_'.$dTag;
$mag = $this->cache->get($cacheKey, function ($item) use ($npub, $dTag) {
$item->expiresAfter(300); // 5 minutes
return $this->nostrClient->getMagazineIndex($npub, $dTag);
@@ -66,10 +67,19 @@ class DefaultController extends AbstractController
{
$npub = $this->params->get('npub');
$cacheKey = 'magazine-' . $slug;
- $catIndex = $this->cache->get($cacheKey, function ($item) use ($npub, $slug) {
- $item->expiresAfter(300); // 5 minutes
- return $this->nostrClient->getMagazineIndex($npub, $slug);
- });
+ try {
+ $catIndex = $this->cache->get($cacheKey, function ($item) use ($npub, $slug) {
+ $item->expiresAfter(300); // 5 minutes
+ $mag = $this->nostrClient->getMagazineIndex($npub, $slug);
+ if ($mag === null) {
+ throw new \RuntimeException('Category index not found for '.$slug);
+ }
+
+ return $mag;
+ });
+ } catch (\Throwable) {
+ $catIndex = null;
+ }
$list = [];
$coordinates = [];
$category = [];
diff --git a/src/Service/ArticleCommentThreadLoader.php b/src/Service/ArticleCommentThreadLoader.php
new file mode 100644
index 0000000..6870c13
--- /dev/null
+++ b/src/Service/ArticleCommentThreadLoader.php
@@ -0,0 +1,72 @@
+, commentLinks: array>, processedContent: array}
+ */
+ public function load(string $coordinate): array
+ {
+ $cacheKey = 'comments_'.hash('sha256', $coordinate);
+
+ try {
+ $list = $this->cache->get($cacheKey, function (ItemInterface $item) use ($coordinate): array {
+ $item->expiresAfter(120);
+ try {
+ return $this->nostrClient->getComments($coordinate);
+ } catch (\Throwable) {
+ return [];
+ }
+ });
+ } catch (\Throwable) {
+ $list = [];
+ }
+
+ $commentLinks = [];
+ $processedContent = [];
+
+ foreach ($list as $comment) {
+ $content = $comment->content ?? '';
+ if ($content === '') {
+ continue;
+ }
+ $id = $comment->id ?? null;
+ if ($id === null || $id === '') {
+ continue;
+ }
+ $idKey = (string) $id;
+ $processedContent[$idKey] = $content;
+ try {
+ $links = $this->nostrLinkParser->parseLinks($content);
+ } catch (\Throwable) {
+ $links = [];
+ }
+ if ($links !== []) {
+ $commentLinks[$idKey] = $links;
+ }
+ }
+
+ return [
+ 'list' => $list,
+ 'commentLinks' => $commentLinks,
+ 'processedContent' => $processedContent,
+ ];
+ }
+}
diff --git a/src/Service/NostrClient.php b/src/Service/NostrClient.php
index 6772e9e..b36f10e 100644
--- a/src/Service/NostrClient.php
+++ b/src/Service/NostrClient.php
@@ -19,58 +19,82 @@ use swentel\nostr\Relay\RelaySet;
use swentel\nostr\Request\Request;
use swentel\nostr\Subscription\Subscription;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
+use Symfony\Contracts\Cache\CacheInterface;
+use Symfony\Contracts\Cache\ItemInterface;
class NostrClient
{
private RelaySet $defaultRelaySet;
- public function __construct(private readonly EntityManagerInterface $entityManager,
- private readonly ManagerRegistry $managerRegistry,
- private readonly ArticleFactory $articleFactory,
- private readonly TokenStorageInterface $tokenStorage,
- private readonly LoggerInterface $logger,
- private readonly string $defaultRelayUrl)
- {
+ public function __construct(
+ private readonly EntityManagerInterface $entityManager,
+ private readonly ManagerRegistry $managerRegistry,
+ private readonly ArticleFactory $articleFactory,
+ private readonly TokenStorageInterface $tokenStorage,
+ private readonly LoggerInterface $logger,
+ private readonly string $defaultRelayUrl,
+ private readonly CacheInterface $relayQueryCache,
+ ) {
$this->defaultRelaySet = new RelaySet();
$this->defaultRelaySet->addRelay(new Relay($this->defaultRelayUrl));
}
/**
- * Creates a RelaySet from a list of relay URLs
+ * Build a fresh relay set: default relay plus optional extras (deduped).
+ * Never reuse {@see $defaultRelaySet} as a mutable base — that used to append relays
+ * onto the singleton forever and multiplied every nostr request latency.
*/
private function createRelaySet(array $relayUrls): RelaySet
{
- $relaySet = $this->defaultRelaySet;
- foreach ($relayUrls as $relayUrl) {
+ $relaySet = new RelaySet();
+ $seen = [];
+ foreach (array_merge([$this->defaultRelayUrl], $relayUrls) as $relayUrl) {
+ if (!\is_string($relayUrl) || $relayUrl === '') {
+ continue;
+ }
+ if (isset($seen[$relayUrl])) {
+ continue;
+ }
+ $seen[$relayUrl] = true;
$relaySet->addRelay(new Relay($relayUrl));
}
+
return $relaySet;
}
/**
- * Get top 3 reputable relays from an author's relay list
+ * Get top 3 reputable relays from an author's relay list (cached; avoids a kind-10002 round trip per page view).
*/
private function getTopReputableRelaysForAuthor(string $pubkey, int $limit = 3): array
{
- try {
- $authorRelays = $this->getNpubRelays($pubkey);
- } catch (\Exception $e) {
- $this->logger->error('Error getting author relays', [
- 'pubkey' => $pubkey,
- 'error' => $e->getMessage()
- ]);
- // fall through
- $authorRelays = [];
- }
- if (empty($authorRelays)) {
- return [$this->defaultRelayUrl]; // Default to theforest if no author relays
- }
+ $cacheKey = 'nostr_author_relays_'.hash('sha256', $pubkey);
- // Can only keep wss relays
- $authorRelays = array_filter($authorRelays, function ($relay) {
- return str_starts_with($relay, 'wss:') && !str_contains($relay, 'localhost');
+ return $this->relayQueryCache->get($cacheKey, function (ItemInterface $item) use ($pubkey, $limit): array {
+ $item->expiresAfter(3600);
+ try {
+ $authorRelays = $this->getNpubRelays($pubkey);
+ } catch (\Exception $e) {
+ $this->logger->error('Error getting author relays', [
+ 'pubkey' => $pubkey,
+ 'error' => $e->getMessage(),
+ ]);
+ $authorRelays = [];
+ }
+ if ($authorRelays === []) {
+ return [$this->defaultRelayUrl];
+ }
+
+ $authorRelays = array_filter($authorRelays, static function ($relay): bool {
+ return \is_string($relay)
+ && str_starts_with($relay, 'wss:')
+ && !str_contains($relay, 'localhost');
+ });
+ if ($authorRelays === []) {
+ return [$this->defaultRelayUrl];
+ }
+
+ return array_values(array_slice($authorRelays, 0, $limit));
});
- return array_slice($authorRelays, 0, $limit);
}
/**
@@ -783,39 +807,93 @@ class NostrClient
// construct a request from the descriptor to fetch the event
/** @var Data $ata */
$data = json_decode($descriptor->decoded);
- // If id is set, search by id and kind
- if (isset($data->id)) {
+ if (!\is_object($data)) {
+ $this->logger->error('Invalid descriptor decoded JSON', ['descriptor' => $descriptor]);
+
+ return null;
+ }
+
+ $byEventId = isset($data->id) && \is_string($data->id) && $data->id !== '';
+
+ if ($byEventId) {
+ // NIP-01: filter by "ids", not "#e" (which matches *tags* named "e").
+ $kind = isset($data->kind) ? (int) $data->kind : 1;
$request = $this->createNostrRequest(
- kinds: [$data->kind],
- filters: ['e' => [$data->id]],
+ kinds: [$kind],
+ filters: ['ids' => [$data->id]],
relaySet: $this->defaultRelaySet
);
} else {
+ // Replaceable address (naddr): must filter on #d like {@see getEventByNaddr()}.
+ // Using key "d" does not call Filter::setTag — relays then return any kind match for the author.
+ $pubkey = (string) ($data->pubkey ?? '');
+ $identifier = (string) ($data->identifier ?? '');
+ if ($pubkey === '' || $identifier === '') {
+ $this->logger->warning('Naddr descriptor missing pubkey or identifier', ['data' => $data]);
+
+ return null;
+ }
+ $kind = (int) ($data->kind ?? KindsEnum::LONGFORM->value);
$request = $this->createNostrRequest(
- kinds: [$data->kind],
- filters: ['authors' => [$data->pubkey], 'd' => [$data->identifier]],
+ kinds: [$kind],
+ filters: [
+ 'authors' => [$pubkey],
+ 'tag' => ['#d', [$identifier]],
+ ],
relaySet: $this->defaultRelaySet
);
}
$events = $this->processResponse($request->send(), function($received) {
$this->logger->info('Getting event', ['item' => $received]);
+
return $received;
});
- if (!empty($events)) {
- // Return the first event found
- return $events[0];
- } else {
+ if (empty($events)) {
$this->logger->warning('No events found for descriptor', ['descriptor' => $descriptor]);
+
return null;
}
+
+ if ($byEventId) {
+ foreach ($events as $event) {
+ if (isset($event->id) && $event->id === $data->id) {
+ return $event;
+ }
+ }
+
+ return $events[0];
+ }
+
+ $wantD = (string) ($data->identifier ?? '');
+ foreach ($events as $event) {
+ if ($this->eventHasDTag($event, $wantD)) {
+ return $event;
+ }
+ }
+
+ return $events[0];
} else {
$this->logger->error('Invalid descriptor format', ['descriptor' => $descriptor]);
return null;
}
}
+ private function eventHasDTag(object $event, string $identifier): bool
+ {
+ foreach ($event->tags ?? [] as $tag) {
+ if (!\is_array($tag) || \count($tag) < 2) {
+ continue;
+ }
+ if (($tag[0] ?? '') === 'd' && (string) ($tag[1] ?? '') === $identifier) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
/**
* Latest kind 30040 index for this author and #d tag, as {@see PublicationEventEntity}
* so callers can use {@see PublicationEventEntity::getTags()} (relay payloads are otherwise stdClass).
diff --git a/src/Service/NostrLinkParser.php b/src/Service/NostrLinkParser.php
index 4ab8c1d..3e3856f 100644
--- a/src/Service/NostrLinkParser.php
+++ b/src/Service/NostrLinkParser.php
@@ -131,6 +131,38 @@ readonly class NostrLinkParser
}
}
+ if (preg_match_all(
+ '~(?type, ['naddr', 'nevent'], true)) {
+ continue;
+ }
+ $links[] = [
+ 'type' => $decoded->type,
+ 'identifier' => $identifier,
+ 'full_match' => 'nostr:'.$identifier,
+ 'position' => $position,
+ 'data' => $decoded->data,
+ 'is_url' => false,
+ ];
+ } catch (\Exception $e) {
+ $this->logger->info('Failed to decode bare Nostr identifier', [
+ 'identifier' => $identifier,
+ 'error' => $e->getMessage(),
+ ]);
+ }
+ }
+ }
+
return $links;
}
diff --git a/src/Twig/Components/Atoms/Content.php b/src/Twig/Components/Atoms/Content.php
index f2ddbe1..63edb97 100644
--- a/src/Twig/Components/Atoms/Content.php
+++ b/src/Twig/Components/Atoms/Content.php
@@ -19,10 +19,14 @@ class Content
*/
public function mount($content): void
{
+ $raw = $content ?? '';
+ if (!\is_string($raw)) {
+ $raw = (string) $raw;
+ }
try {
- $this->parsed = $this->converter->convertToHtml($content);
+ $this->parsed = $this->converter->convertToHtml($raw);
} catch (CommonMarkException) {
- $this->parsed = $content;
+ $this->parsed = $raw;
}
}
}
diff --git a/src/Twig/Components/Header.php b/src/Twig/Components/Header.php
index cb2d972..445216b 100644
--- a/src/Twig/Components/Header.php
+++ b/src/Twig/Components/Header.php
@@ -4,9 +4,11 @@ declare(strict_types=1);
namespace App\Twig\Components;
+use App\Service\NostrClient;
use Psr\Cache\InvalidArgumentException;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Contracts\Cache\CacheInterface;
+use Symfony\Contracts\Cache\ItemInterface;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[AsTwigComponent]
@@ -17,23 +19,31 @@ class Header
/**
* @throws InvalidArgumentException
*/
- public function __construct(private readonly CacheInterface $cache, private readonly ParameterBagInterface $params)
- {
- $dTag = $this->params->get('d_tag');
- $mag = $this->cache->get('magazine-' . $dTag, function (){
- return null;
+ public function __construct(
+ private readonly CacheInterface $cache,
+ private readonly ParameterBagInterface $params,
+ private readonly NostrClient $nostrClient,
+ ) {
+ $dTag = (string) $this->params->get('d_tag');
+ $npub = (string) $this->params->get('npub');
+ // Same key as {@see DefaultController::index()} — must load the real index (not cache `null`).
+ $cacheKey = 'magazine_root_'.$dTag;
+ $mag = $this->cache->get($cacheKey, function (ItemInterface $item) use ($npub, $dTag) {
+ $item->expiresAfter(300);
+
+ return $this->nostrClient->getMagazineIndex($npub, $dTag);
});
- // Handle case when magazine is not found
if ($mag === null) {
$this->cats = [];
+
return;
}
$tags = $mag->getTags();
- $this->cats = array_filter($tags, function($tag) {
- return ($tag[0] === 'a');
+ $this->cats = array_filter($tags, static function ($tag): bool {
+ return ($tag[0] ?? null) === 'a';
});
}
}
diff --git a/src/Twig/Components/Molecules/CategoryLink.php b/src/Twig/Components/Molecules/CategoryLink.php
index 9a8f7fe..f53f847 100644
--- a/src/Twig/Components/Molecules/CategoryLink.php
+++ b/src/Twig/Components/Molecules/CategoryLink.php
@@ -2,7 +2,10 @@
namespace App\Twig\Components\Molecules;
+use App\Service\NostrClient;
+use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Contracts\Cache\CacheInterface;
+use Symfony\Contracts\Cache\ItemInterface;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[AsTwigComponent]
@@ -12,8 +15,11 @@ final class CategoryLink
public string $slug = '';
- public function __construct(private CacheInterface $cache)
- {
+ public function __construct(
+ private readonly CacheInterface $cache,
+ private readonly ParameterBagInterface $params,
+ private readonly NostrClient $nostrClient,
+ ) {
}
public function mount($category): void
@@ -21,25 +27,42 @@ final class CategoryLink
$coord = $category[1] ?? '';
$parts = explode(':', (string) $coord, 3);
$this->slug = $parts[2] ?? '';
- $this->title = $this->slug !== '' ? $this->slug : 'Category';
+ if ($this->slug === '') {
+ $this->title = 'Category';
- try {
- $cat = $this->cache->get('magazine-' . $this->slug, function () {
- throw new \RuntimeException('Not found');
- });
+ return;
+ }
- $tags = method_exists($cat, 'getTags') ? $cat->getTags() : [];
+ $this->title = $this->slug;
+ $npub = (string) $this->params->get('npub');
+ // Same cache key/TTL as DefaultController::magCategory(); load from relay on miss (not read-only).
+ // The cache callback must return data on miss; otherwise the homepage shows raw d-tags.
+ try {
+ $cat = $this->cache->get('magazine-' . $this->slug, function (ItemInterface $item) use ($npub) {
+ $item->expiresAfter(300);
+ $mag = $this->nostrClient->getMagazineIndex($npub, $this->slug);
+ if ($mag === null) {
+ // Do not persist null: FeaturedList would get a cache hit and call getTags() on null.
+ throw new \RuntimeException('Category index not found for '.$this->slug);
+ }
- $titleTags = array_filter($tags, static function ($tag): bool {
- return isset($tag[0]) && $tag[0] === 'title' && isset($tag[1]);
+ return $mag;
});
-
- $first = array_key_first($titleTags);
- if ($first !== null) {
- $this->title = (string) $titleTags[$first][1];
- }
} catch (\Throwable) {
- // Cache miss or unreadable index: keep slug-based fallback title
+ return;
+ }
+
+ if (!\is_object($cat) || !\method_exists($cat, 'getTags')) {
+ return;
+ }
+
+ $tags = $cat->getTags();
+ $titleTags = array_filter($tags, static function ($tag): bool {
+ return isset($tag[0]) && $tag[0] === 'title' && isset($tag[1]);
+ });
+ $first = array_key_first($titleTags);
+ if ($first !== null) {
+ $this->title = (string) $titleTags[$first][1];
}
}
}
diff --git a/src/Twig/Components/Organisms/Comments.php b/src/Twig/Components/Organisms/Comments.php
index 1acd626..3fd83ba 100644
--- a/src/Twig/Components/Organisms/Comments.php
+++ b/src/Twig/Components/Organisms/Comments.php
@@ -2,10 +2,7 @@
namespace App\Twig\Components\Organisms;
-use App\Service\NostrClient;
-use App\Service\NostrLinkParser;
-use Symfony\Contracts\Cache\CacheInterface;
-use Symfony\Contracts\Cache\ItemInterface;
+use App\Service\ArticleCommentThreadLoader;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[AsTwigComponent]
@@ -17,50 +14,15 @@ final class Comments
public array $processedContent = [];
- public function __construct(
- private readonly NostrClient $nostrClient,
- private readonly NostrLinkParser $nostrLinkParser,
- private readonly CacheInterface $cache,
- ) {
- }
-
- /**
- * @throws \Exception
- */
- public function mount($current): void
+ public function __construct(private readonly ArticleCommentThreadLoader $commentThreadLoader)
{
- $cacheKey = 'comments_' . hash('sha256', (string) $current);
-
- $this->list = $this->cache->get($cacheKey, function (ItemInterface $item) use ($current) {
- $item->expiresAfter(120);
-
- return $this->nostrClient->getComments($current);
- });
-
- $this->parseNostrLinks();
}
- /**
- * Parse Nostr links in comments for client-side loading
- */
- private function parseNostrLinks(): void
+ public function mount($current): void
{
- foreach ($this->list as $comment) {
- $content = $comment->content ?? '';
- if (empty($content)) {
- continue;
- }
-
- // Store the original content
- $this->processedContent[$comment->id] = $content;
-
- // Parse the content for Nostr links
- $links = $this->nostrLinkParser->parseLinks($content);
-
- if (!empty($links)) {
- // Save the links for the client-side to fetch
- $this->commentLinks[$comment->id] = $links;
- }
- }
+ $data = $this->commentThreadLoader->load((string) $current);
+ $this->list = $data['list'];
+ $this->commentLinks = $data['commentLinks'];
+ $this->processedContent = $data['processedContent'];
}
}
diff --git a/src/Twig/Components/Organisms/FeaturedList.php b/src/Twig/Components/Organisms/FeaturedList.php
index 6d6def6..c39bd11 100644
--- a/src/Twig/Components/Organisms/FeaturedList.php
+++ b/src/Twig/Components/Organisms/FeaturedList.php
@@ -59,6 +59,10 @@ final class FeaturedList
return;
}
+ if (!\is_object($catIndex) || !\method_exists($catIndex, 'getTags')) {
+ return;
+ }
+
$slugs = [];
foreach ($catIndex->getTags() as $tag) {
if (($tag[0] ?? null) === 'title' && isset($tag[1])) {
diff --git a/src/Util/CommonMark/NostrSchemeExtension/NostrBareBech32Parser.php b/src/Util/CommonMark/NostrSchemeExtension/NostrBareBech32Parser.php
new file mode 100644
index 0000000..88eaa31
--- /dev/null
+++ b/src/Util/CommonMark/NostrSchemeExtension/NostrBareBech32Parser.php
@@ -0,0 +1,74 @@
+getCursor();
+ $fullMatch = $inlineContext->getFullMatch();
+ $bech = ltrim($fullMatch, '@');
+
+ if (!str_starts_with($bech, 'naddr1') && !str_starts_with($bech, 'nevent1')) {
+ return false;
+ }
+
+ try {
+ $decoded = new Bech32($bech);
+ } catch (\Throwable) {
+ return false;
+ }
+
+ if ($decoded->type === 'naddr') {
+ /** @var NAddr $data */
+ $data = $decoded->data;
+ $relays = $data->relays ?? [];
+ // NIP-19 naddr TLVs include author pubkey and kind; normalize like `nevent` if TLVs are missing.
+ $author = $data->pubkey ?? '';
+ $kind = (int) ($data->kind ?? 0);
+ $inlineContext->getContainer()->appendChild(new NostrSchemeData(
+ 'naddr',
+ $bech,
+ \is_array($relays) ? $relays : [],
+ $author,
+ $kind
+ ));
+ } elseif ($decoded->type === 'nevent') {
+ /** @var NEvent $data */
+ $data = $decoded->data;
+ $relays = $data->relays ?? [];
+ $author = $data->author ?? $data->pubkey ?? '';
+ $inlineContext->getContainer()->appendChild(new NostrSchemeData(
+ 'nevent',
+ $bech,
+ \is_array($relays) ? $relays : [],
+ $author,
+ (int) ($data->kind ?? 0)
+ ));
+ } else {
+ return false;
+ }
+
+ $cursor->advanceBy(strlen($fullMatch));
+
+ return true;
+ }
+}
diff --git a/src/Util/CommonMark/NostrSchemeExtension/NostrEventRenderer.php b/src/Util/CommonMark/NostrSchemeExtension/NostrEventRenderer.php
index 020df24..bcc5fd3 100644
--- a/src/Util/CommonMark/NostrSchemeExtension/NostrEventRenderer.php
+++ b/src/Util/CommonMark/NostrSchemeExtension/NostrEventRenderer.php
@@ -6,38 +6,62 @@ use League\CommonMark\Node\Node;
use League\CommonMark\Renderer\ChildNodeRendererInterface;
use League\CommonMark\Renderer\NodeRendererInterface;
use League\CommonMark\Util\HtmlElement;
+use nostriphant\NIP19\Bech32;
class NostrEventRenderer implements NodeRendererInterface
{
-
public function render(Node $node, ChildNodeRendererInterface $childRenderer)
{
if (!($node instanceof NostrSchemeData)) {
- throw new \InvalidArgumentException('Incompatible inline node type: ' . get_class($node));
+ throw new \InvalidArgumentException('Incompatible inline node type: '.get_class($node));
}
- if ($node->getType() === 'nevent') {
- // Construct the local link URL from the special part
- $url = '/e/' . $node->getSpecial();
- } else if ($node->getType() === 'naddr') {
- // dump($node);
- // Construct the local link URL from the special part
- $url = '/article/' . $node->getSpecial();
+ $type = $node->getType();
+ if ($type === 'nevent' || $type === 'naddr') {
+ return $this->renderPreviewOrFallback($node, $type);
}
- if (isset($url)) {
- // Create the anchor element
- return new HtmlElement('a', ['href' => $url], '@' . $this->labelFromKey($node->getSpecial()));
+ return false;
+ }
+
+ private function renderPreviewOrFallback(NostrSchemeData $node, string $type): HtmlElement
+ {
+ $bech = $node->getSpecial();
+ try {
+ $decoded = new Bech32($bech);
+ $payload = json_decode(json_encode($decoded->data), true, 512, JSON_THROW_ON_ERROR);
+ $decodedJson = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
+ } catch (\Throwable) {
+ $url = 'nevent' === $type ? '/e/'.$bech : '/article/'.$bech;
+
+ return new HtmlElement('a', ['href' => $url, 'class' => 'nostr-link'], '@'.$this->labelFromKey($bech));
}
- return false;
+ $nostrUrl = 'nostr:'.$bech;
+ $safeNostr = htmlspecialchars($nostrUrl, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
+ $inner = ''
+ .'
'
+ .' '
+ .'Loading preview… '
+ .'
'
+ .'
'
+ .'
';
+ return new HtmlElement('div', [
+ 'class' => 'nostr-preview nostr-preview--inline',
+ 'data-controller' => 'nostr-preview',
+ 'data-nostr-preview-identifier-value' => $bech,
+ 'data-nostr-preview-type-value' => $type,
+ 'data-nostr-preview-decoded-value' => $decodedJson,
+ 'data-nostr-preview-full-match-value' => $nostrUrl,
+ ], $inner, false);
}
- private function labelFromKey($key): string
+ private function labelFromKey(string $key): string
{
$start = substr($key, 0, 8);
$end = substr($key, -8);
- return $start . '…' . $end;
+
+ return $start.'…'.$end;
}
}
diff --git a/src/Util/CommonMark/NostrSchemeExtension/NostrSchemeExtension.php b/src/Util/CommonMark/NostrSchemeExtension/NostrSchemeExtension.php
index 6a603f5..7db9a19 100644
--- a/src/Util/CommonMark/NostrSchemeExtension/NostrSchemeExtension.php
+++ b/src/Util/CommonMark/NostrSchemeExtension/NostrSchemeExtension.php
@@ -16,6 +16,7 @@ class NostrSchemeExtension implements ExtensionInterface
public function register(EnvironmentBuilderInterface $environment): void
{
$environment
+ ->addInlineParser(new NostrBareBech32Parser(), 202)
->addInlineParser(new NostrMentionParser($this->cacheService), 200)
->addInlineParser(new NostrSchemeParser(), 199)
->addInlineParser(new NostrRawNpubParser($this->cacheService), 198)
diff --git a/templates/components/Header.html.twig b/templates/components/Header.html.twig
index 2429014..f24b3f6 100644
--- a/templates/components/Header.html.twig
+++ b/templates/components/Header.html.twig
@@ -5,7 +5,7 @@
- {{ website_name }}
+ {{ website_name }}
☰
diff --git a/templates/components/Organisms/Comments.html.twig b/templates/components/Organisms/Comments.html.twig
index c5b3b8b..0e1d475 100644
--- a/templates/components/Organisms/Comments.html.twig
+++ b/templates/components/Organisms/Comments.html.twig
@@ -1,18 +1,21 @@
- {{ item.created_at|date('F j Y') }} +{% if cpk != '' %} {% else %}Unknown{% endif %}
+ {% if cts is not null and cts != '' %}{{ cts|date('F j Y') }}{% endif %}#} {# {{ article.content }}#} {##} -Loading comments…
+