Browse Source

speed up app, fix bugs

imwald
Silberengel 1 week ago
parent
commit
8867dba25d
  1. 10
      assets/bootstrap.js
  2. 35
      assets/controllers/article_comments_controller.js
  3. 65
      assets/styles/app.css
  4. 4
      assets/styles/article.css
  5. 10
      assets/styles/card.css
  6. 14
      assets/styles/layout.css
  7. 5
      assets/styles/nostr-previews.css
  8. 49
      src/Controller/ArticleController.php
  9. 20
      src/Controller/DefaultController.php
  10. 72
      src/Service/ArticleCommentThreadLoader.php
  11. 154
      src/Service/NostrClient.php
  12. 32
      src/Service/NostrLinkParser.php
  13. 8
      src/Twig/Components/Atoms/Content.php
  14. 26
      src/Twig/Components/Header.php
  15. 55
      src/Twig/Components/Molecules/CategoryLink.php
  16. 52
      src/Twig/Components/Organisms/Comments.php
  17. 4
      src/Twig/Components/Organisms/FeaturedList.php
  18. 74
      src/Util/CommonMark/NostrSchemeExtension/NostrBareBech32Parser.php
  19. 54
      src/Util/CommonMark/NostrSchemeExtension/NostrEventRenderer.php
  20. 1
      src/Util/CommonMark/NostrSchemeExtension/NostrSchemeExtension.php
  21. 2
      templates/components/Header.html.twig
  22. 13
      templates/components/Organisms/Comments.html.twig
  23. 12
      templates/pages/article.html.twig

10
assets/bootstrap.js vendored

@ -1,5 +1,11 @@
import { startStimulusApp } from '@symfony/stimulus-bundle'; import { startStimulusApp } from '@symfony/stimulus-bundle';
import ArticleCommentsController from './controllers/article_comments_controller.js';
const app = startStimulusApp(); 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 */
}

35
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 =
'<p class="text-subtle">Comments could not be loaded.</p>';
}
}
}

65
assets/styles/app.css

@ -147,7 +147,8 @@ svg.icon {
.featured-list { .featured-list {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
flex-wrap: wrap; flex-wrap: nowrap;
align-items: flex-start;
} }
@ -162,6 +163,12 @@ svg.icon {
flex-direction: column !important; flex-direction: column !important;
} }
.featured-list > div:first-child,
.featured-list > div:last-child {
flex: 1 1 auto;
width: 100%;
}
.featured-list .card-header { .featured-list .card-header {
margin-top: 20px; margin-top: 20px;
} }
@ -179,12 +186,15 @@ div:nth-child(odd) .featured-list {
flex-direction: row-reverse; flex-direction: row-reverse;
} }
.featured-list div:first-child { /* Only the two column wrappers — not every .card that happens to be :first-child/:last-child of its parent */
flex: 0 0 66%; /* each item takes up 50% width = 2 columns */ .featured-list > div:first-child {
flex: 0 0 66%;
min-width: 0;
} }
.featured-list div:last-child { .featured-list > div:last-child {
flex: 0 0 34%; /* each item takes up 50% width = 2 columns */ flex: 0 0 34%;
min-width: 0;
} }
.featured-list h2.card-title { .featured-list h2.card-title {
@ -212,11 +222,14 @@ div:nth-child(odd) .featured-list {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: space-between; justify-content: space-between;
align-items: baseline; align-items: center;
gap: 0.75rem;
min-width: 0;
} }
.article-list .metadata p { .article-list .metadata p {
margin: 0; margin: 0;
min-width: 0;
} }
.truncate { .truncate {
@ -300,6 +313,39 @@ div:nth-child(odd) .featured-list {
.header__logo h1 { .header__logo h1 {
font-weight: normal; 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 /* Fixed square + overflow clips to a true circle. Logo img is out-of-flow so
@ -546,7 +592,8 @@ footer a {
z-index: 1; z-index: 1;
width: 100%; width: 100%;
height: 100%; height: 100%;
max-width: none; max-width: 100%;
max-height: 100%;
object-fit: cover; object-fit: cover;
object-position: center; object-position: center;
display: block; display: block;
@ -724,8 +771,8 @@ a:focus-visible {
} }
@media (max-width: 600px) { @media (max-width: 600px) {
h1.brand { .header__logo .brand {
font-size: 2.2rem; font-size: clamp(0.95rem, 4.8vw, 1.25rem);
} }
.header__logo-circle { .header__logo-circle {

4
assets/styles/article.css

@ -92,3 +92,7 @@ blockquote p {
height: auto; height: auto;
aspect-ratio: 16/9; aspect-ratio: 16/9;
} }
.article-comments-async .comments--pending {
margin: 1rem 0;
}

10
assets/styles/card.css

@ -37,13 +37,19 @@ h2.card-title {
.article-list .card { .article-list .card {
margin-bottom: 1rem; margin-bottom: 1rem;
min-width: 0; /* column flex: do not let cover images set unshrinkable row width */
}
.card-header {
overflow: hidden;
} }
.card-header img { .card-header img {
display: block;
max-width: 100%; max-width: 100%;
height: auto;
max-height: 200px;
width: 100%; width: 100%;
height: auto;
max-height: 220px;
object-fit: cover; object-fit: cover;
} }

14
assets/styles/layout.css

@ -83,7 +83,18 @@ header {
/* Mobile Styles */ /* Mobile Styles */
@media (max-width: 1024px) { @media (max-width: 1024px) {
.header__logo { .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 { .header__categories {
@ -111,6 +122,7 @@ header {
main { main {
margin-top: 140px; margin-top: 140px;
flex-grow: 1; flex-grow: 1;
min-width: 0; /* flex item: allow shrinking below wide images / intrinsic min-content */
padding: 1em; padding: 1em;
word-break: break-word; word-break: break-word;
} }

5
assets/styles/nostr-previews.css

@ -32,6 +32,11 @@
margin-top: 0.5rem; margin-top: 0.5rem;
} }
.nostr-preview.nostr-preview--inline {
margin: 1rem 0;
max-width: 100%;
}
.nostr-preview .nostr-preview-link a { .nostr-preview .nostr-preview-link a {
color: var(--color-link); color: var(--color-link);
text-decoration: underline; text-decoration: underline;

49
src/Controller/ArticleController.php

@ -5,6 +5,7 @@ namespace App\Controller;
use App\Entity\Article; use App\Entity\Article;
use App\Enum\KindsEnum; use App\Enum\KindsEnum;
use App\Form\EditorType; use App\Form\EditorType;
use App\Service\ArticleCommentThreadLoader;
use App\Service\NostrClient; use App\Service\NostrClient;
use App\Service\CacheService; use App\Service\CacheService;
use App\Util\CommonMark\Converter; use App\Util\CommonMark\Converter;
@ -24,6 +25,49 @@ use Symfony\Component\Workflow\WorkflowInterface;
class ArticleController extends AbstractController 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('<div class="comments"></div>', 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 * @throws \Exception
*/ */
@ -41,9 +85,10 @@ class ArticleController extends AbstractController
$slug = $data->identifier; $slug = $data->identifier;
$relays = $data->relays; $relays = $data->relays;
$author = $data->pubkey; $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'); throw new \Exception('Not a long form article');
} }

20
src/Controller/DefaultController.php

@ -33,7 +33,8 @@ class DefaultController extends AbstractController
{ {
$npub = $this->params->get('npub'); $npub = $this->params->get('npub');
$dTag = $this->params->get('d_tag'); $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) { $mag = $this->cache->get($cacheKey, function ($item) use ($npub, $dTag) {
$item->expiresAfter(300); // 5 minutes $item->expiresAfter(300); // 5 minutes
return $this->nostrClient->getMagazineIndex($npub, $dTag); return $this->nostrClient->getMagazineIndex($npub, $dTag);
@ -66,10 +67,19 @@ class DefaultController extends AbstractController
{ {
$npub = $this->params->get('npub'); $npub = $this->params->get('npub');
$cacheKey = 'magazine-' . $slug; $cacheKey = 'magazine-' . $slug;
$catIndex = $this->cache->get($cacheKey, function ($item) use ($npub, $slug) { try {
$item->expiresAfter(300); // 5 minutes $catIndex = $this->cache->get($cacheKey, function ($item) use ($npub, $slug) {
return $this->nostrClient->getMagazineIndex($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 = []; $list = [];
$coordinates = []; $coordinates = [];
$category = []; $category = [];

72
src/Service/ArticleCommentThreadLoader.php

@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace App\Service;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
/**
* Loads Nostr comment threads (kind 1111) for a coordinate and parses inline nostr links for previews.
*/
final readonly class ArticleCommentThreadLoader
{
public function __construct(
private NostrClient $nostrClient,
private NostrLinkParser $nostrLinkParser,
private CacheInterface $cache,
) {
}
/**
* @return array{list: array<int, object>, commentLinks: array<string, array<int, mixed>>, processedContent: array<string, string>}
*/
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,
];
}
}

154
src/Service/NostrClient.php

@ -19,58 +19,82 @@ use swentel\nostr\Relay\RelaySet;
use swentel\nostr\Request\Request; use swentel\nostr\Request\Request;
use swentel\nostr\Subscription\Subscription; use swentel\nostr\Subscription\Subscription;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
class NostrClient class NostrClient
{ {
private RelaySet $defaultRelaySet; private RelaySet $defaultRelaySet;
public function __construct(private readonly EntityManagerInterface $entityManager, public function __construct(
private readonly ManagerRegistry $managerRegistry, private readonly EntityManagerInterface $entityManager,
private readonly ArticleFactory $articleFactory, private readonly ManagerRegistry $managerRegistry,
private readonly TokenStorageInterface $tokenStorage, private readonly ArticleFactory $articleFactory,
private readonly LoggerInterface $logger, private readonly TokenStorageInterface $tokenStorage,
private readonly string $defaultRelayUrl) private readonly LoggerInterface $logger,
{ private readonly string $defaultRelayUrl,
private readonly CacheInterface $relayQueryCache,
) {
$this->defaultRelaySet = new RelaySet(); $this->defaultRelaySet = new RelaySet();
$this->defaultRelaySet->addRelay(new Relay($this->defaultRelayUrl)); $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 private function createRelaySet(array $relayUrls): RelaySet
{ {
$relaySet = $this->defaultRelaySet; $relaySet = new RelaySet();
foreach ($relayUrls as $relayUrl) { $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)); $relaySet->addRelay(new Relay($relayUrl));
} }
return $relaySet; 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 private function getTopReputableRelaysForAuthor(string $pubkey, int $limit = 3): array
{ {
try { $cacheKey = 'nostr_author_relays_'.hash('sha256', $pubkey);
$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
}
// Can only keep wss relays return $this->relayQueryCache->get($cacheKey, function (ItemInterface $item) use ($pubkey, $limit): array {
$authorRelays = array_filter($authorRelays, function ($relay) { $item->expiresAfter(3600);
return str_starts_with($relay, 'wss:') && !str_contains($relay, 'localhost'); 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 // construct a request from the descriptor to fetch the event
/** @var Data $ata */ /** @var Data $ata */
$data = json_decode($descriptor->decoded); $data = json_decode($descriptor->decoded);
// If id is set, search by id and kind if (!\is_object($data)) {
if (isset($data->id)) { $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( $request = $this->createNostrRequest(
kinds: [$data->kind], kinds: [$kind],
filters: ['e' => [$data->id]], filters: ['ids' => [$data->id]],
relaySet: $this->defaultRelaySet relaySet: $this->defaultRelaySet
); );
} else { } 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( $request = $this->createNostrRequest(
kinds: [$data->kind], kinds: [$kind],
filters: ['authors' => [$data->pubkey], 'd' => [$data->identifier]], filters: [
'authors' => [$pubkey],
'tag' => ['#d', [$identifier]],
],
relaySet: $this->defaultRelaySet relaySet: $this->defaultRelaySet
); );
} }
$events = $this->processResponse($request->send(), function($received) { $events = $this->processResponse($request->send(), function($received) {
$this->logger->info('Getting event', ['item' => $received]); $this->logger->info('Getting event', ['item' => $received]);
return $received; return $received;
}); });
if (!empty($events)) { if (empty($events)) {
// Return the first event found
return $events[0];
} else {
$this->logger->warning('No events found for descriptor', ['descriptor' => $descriptor]); $this->logger->warning('No events found for descriptor', ['descriptor' => $descriptor]);
return null; 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 { } else {
$this->logger->error('Invalid descriptor format', ['descriptor' => $descriptor]); $this->logger->error('Invalid descriptor format', ['descriptor' => $descriptor]);
return null; 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} * 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). * so callers can use {@see PublicationEventEntity::getTags()} (relay payloads are otherwise stdClass).

32
src/Service/NostrLinkParser.php

@ -131,6 +131,38 @@ readonly class NostrLinkParser
} }
} }
if (preg_match_all(
'~(?<![\w#])(?:@)?(naddr1[0-9a-z]+|nevent1[0-9a-z]+)(?![0-9a-z])~i',
$content,
$bare,
PREG_SET_ORDER | PREG_OFFSET_CAPTURE
)) {
foreach ($bare as $match) {
$raw = $match[0][0];
$position = $match[0][1];
$identifier = ltrim($raw, '@');
try {
$decoded = new Bech32($identifier);
if (!\in_array($decoded->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; return $links;
} }

8
src/Twig/Components/Atoms/Content.php

@ -19,10 +19,14 @@ class Content
*/ */
public function mount($content): void public function mount($content): void
{ {
$raw = $content ?? '';
if (!\is_string($raw)) {
$raw = (string) $raw;
}
try { try {
$this->parsed = $this->converter->convertToHtml($content); $this->parsed = $this->converter->convertToHtml($raw);
} catch (CommonMarkException) { } catch (CommonMarkException) {
$this->parsed = $content; $this->parsed = $raw;
} }
} }
} }

26
src/Twig/Components/Header.php

@ -4,9 +4,11 @@ declare(strict_types=1);
namespace App\Twig\Components; namespace App\Twig\Components;
use App\Service\NostrClient;
use Psr\Cache\InvalidArgumentException; use Psr\Cache\InvalidArgumentException;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Contracts\Cache\CacheInterface; use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[AsTwigComponent] #[AsTwigComponent]
@ -17,23 +19,31 @@ class Header
/** /**
* @throws InvalidArgumentException * @throws InvalidArgumentException
*/ */
public function __construct(private readonly CacheInterface $cache, private readonly ParameterBagInterface $params) public function __construct(
{ private readonly CacheInterface $cache,
$dTag = $this->params->get('d_tag'); private readonly ParameterBagInterface $params,
$mag = $this->cache->get('magazine-' . $dTag, function (){ private readonly NostrClient $nostrClient,
return null; ) {
$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) { if ($mag === null) {
$this->cats = []; $this->cats = [];
return; return;
} }
$tags = $mag->getTags(); $tags = $mag->getTags();
$this->cats = array_filter($tags, function($tag) { $this->cats = array_filter($tags, static function ($tag): bool {
return ($tag[0] === 'a'); return ($tag[0] ?? null) === 'a';
}); });
} }
} }

55
src/Twig/Components/Molecules/CategoryLink.php

@ -2,7 +2,10 @@
namespace App\Twig\Components\Molecules; namespace App\Twig\Components\Molecules;
use App\Service\NostrClient;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Contracts\Cache\CacheInterface; use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[AsTwigComponent] #[AsTwigComponent]
@ -12,8 +15,11 @@ final class CategoryLink
public string $slug = ''; 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 public function mount($category): void
@ -21,25 +27,42 @@ final class CategoryLink
$coord = $category[1] ?? ''; $coord = $category[1] ?? '';
$parts = explode(':', (string) $coord, 3); $parts = explode(':', (string) $coord, 3);
$this->slug = $parts[2] ?? ''; $this->slug = $parts[2] ?? '';
$this->title = $this->slug !== '' ? $this->slug : 'Category'; if ($this->slug === '') {
$this->title = 'Category';
try { return;
$cat = $this->cache->get('magazine-' . $this->slug, function () { }
throw new \RuntimeException('Not found');
});
$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 $mag;
return isset($tag[0]) && $tag[0] === 'title' && isset($tag[1]);
}); });
$first = array_key_first($titleTags);
if ($first !== null) {
$this->title = (string) $titleTags[$first][1];
}
} catch (\Throwable) { } 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];
} }
} }
} }

52
src/Twig/Components/Organisms/Comments.php

@ -2,10 +2,7 @@
namespace App\Twig\Components\Organisms; namespace App\Twig\Components\Organisms;
use App\Service\NostrClient; use App\Service\ArticleCommentThreadLoader;
use App\Service\NostrLinkParser;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[AsTwigComponent] #[AsTwigComponent]
@ -17,50 +14,15 @@ final class Comments
public array $processedContent = []; public array $processedContent = [];
public function __construct( public function __construct(private readonly ArticleCommentThreadLoader $commentThreadLoader)
private readonly NostrClient $nostrClient,
private readonly NostrLinkParser $nostrLinkParser,
private readonly CacheInterface $cache,
) {
}
/**
* @throws \Exception
*/
public function mount($current): void
{ {
$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();
} }
/** public function mount($current): void
* Parse Nostr links in comments for client-side loading
*/
private function parseNostrLinks(): void
{ {
foreach ($this->list as $comment) { $data = $this->commentThreadLoader->load((string) $current);
$content = $comment->content ?? ''; $this->list = $data['list'];
if (empty($content)) { $this->commentLinks = $data['commentLinks'];
continue; $this->processedContent = $data['processedContent'];
}
// 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;
}
}
} }
} }

4
src/Twig/Components/Organisms/FeaturedList.php

@ -59,6 +59,10 @@ final class FeaturedList
return; return;
} }
if (!\is_object($catIndex) || !\method_exists($catIndex, 'getTags')) {
return;
}
$slugs = []; $slugs = [];
foreach ($catIndex->getTags() as $tag) { foreach ($catIndex->getTags() as $tag) {
if (($tag[0] ?? null) === 'title' && isset($tag[1])) { if (($tag[0] ?? null) === 'title' && isset($tag[1])) {

74
src/Util/CommonMark/NostrSchemeExtension/NostrBareBech32Parser.php

@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace App\Util\CommonMark\NostrSchemeExtension;
use League\CommonMark\Parser\Inline\InlineParserInterface;
use League\CommonMark\Parser\Inline\InlineParserMatch;
use League\CommonMark\Parser\InlineParserContext;
use nostriphant\NIP19\Bech32;
use nostriphant\NIP19\Data\NAddr;
use nostriphant\NIP19\Data\NEvent;
/**
* Matches bare or @-prefixed naddr1 / nevent1 (NIP-19), so they render like nostr:… links.
*/
final class NostrBareBech32Parser implements InlineParserInterface
{
public function getMatchDefinition(): InlineParserMatch
{
return InlineParserMatch::regex('(?:@)?(?:naddr1|nevent1)[0-9a-z]+');
}
public function parse(InlineParserContext $inlineContext): bool
{
$cursor = $inlineContext->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;
}
}

54
src/Util/CommonMark/NostrSchemeExtension/NostrEventRenderer.php

@ -6,38 +6,62 @@ use League\CommonMark\Node\Node;
use League\CommonMark\Renderer\ChildNodeRendererInterface; use League\CommonMark\Renderer\ChildNodeRendererInterface;
use League\CommonMark\Renderer\NodeRendererInterface; use League\CommonMark\Renderer\NodeRendererInterface;
use League\CommonMark\Util\HtmlElement; use League\CommonMark\Util\HtmlElement;
use nostriphant\NIP19\Bech32;
class NostrEventRenderer implements NodeRendererInterface class NostrEventRenderer implements NodeRendererInterface
{ {
public function render(Node $node, ChildNodeRendererInterface $childRenderer) public function render(Node $node, ChildNodeRendererInterface $childRenderer)
{ {
if (!($node instanceof NostrSchemeData)) { 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') { $type = $node->getType();
// Construct the local link URL from the special part if ($type === 'nevent' || $type === 'naddr') {
$url = '/e/' . $node->getSpecial(); return $this->renderPreviewOrFallback($node, $type);
} else if ($node->getType() === 'naddr') {
// dump($node);
// Construct the local link URL from the special part
$url = '/article/' . $node->getSpecial();
} }
if (isset($url)) { return false;
// Create the anchor element }
return new HtmlElement('a', ['href' => $url], '@' . $this->labelFromKey($node->getSpecial()));
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 = '<div data-nostr-preview-target="container">'
.'<div class="nostr-preview__loading text-center my-2">'
.'<span class="nostr-preview__spinner" role="status" aria-label="Loading"></span>'
.'<span class="nostr-preview__loading-text ms-2">Loading preview…</span>'
.'</div>'
.'<div class="nostr-preview-link mt-2"><a href="'.$safeNostr.'" target="_blank" rel="noopener noreferrer">'.$safeNostr.'</a></div>'
.'</div>';
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); $start = substr($key, 0, 8);
$end = substr($key, -8); $end = substr($key, -8);
return $start . '…' . $end;
return $start.'…'.$end;
} }
} }

1
src/Util/CommonMark/NostrSchemeExtension/NostrSchemeExtension.php

@ -16,6 +16,7 @@ class NostrSchemeExtension implements ExtensionInterface
public function register(EnvironmentBuilderInterface $environment): void public function register(EnvironmentBuilderInterface $environment): void
{ {
$environment $environment
->addInlineParser(new NostrBareBech32Parser(), 202)
->addInlineParser(new NostrMentionParser($this->cacheService), 200) ->addInlineParser(new NostrMentionParser($this->cacheService), 200)
->addInlineParser(new NostrSchemeParser(), 199) ->addInlineParser(new NostrSchemeParser(), 199)
->addInlineParser(new NostrRawNpubParser($this->cacheService), 198) ->addInlineParser(new NostrRawNpubParser($this->cacheService), 198)

2
templates/components/Header.html.twig

@ -5,7 +5,7 @@
<span class="header__logo-circle"> <span class="header__logo-circle">
<img src="{{ asset('icons/favicon-96x96.png') }}" alt="{{ website_short_name }}" class="logo" /> <img src="{{ asset('icons/favicon-96x96.png') }}" alt="{{ website_short_name }}" class="logo" />
</span> </span>
{{ website_name }} <span class="brand__title">{{ website_name }}</span>
</h1> </h1>
</a> </a>
<button class="hamburger btn btn-secondary" data-action="click->menu#toggle" aria-label="Menu">&#9776;</button> <button class="hamburger btn btn-secondary" data-action="click->menu#toggle" aria-label="Menu">&#9776;</button>

13
templates/components/Organisms/Comments.html.twig

@ -1,18 +1,21 @@
<div class="comments"> <div class="comments">
{% for item in list %} {% for item in list %}
{% set cid = item.id|default('') %}
{% set cpk = item.pubkey|default('') %}
{% set cts = item.created_at|default(null) %}
<div class="card comment"> <div class="card comment">
<div class="metadata"> <div class="metadata">
<p><twig:Molecules:UserFromNpub ident="{{ item.pubkey }}" /></p> <p>{% if cpk != '' %}<twig:Molecules:UserFromNpub ident="{{ cpk }}" />{% else %}<span class="text-subtle">Unknown</span>{% endif %}</p>
<small>{{ item.created_at|date('F j Y') }}</small> <small>{% if cts is not null and cts != '' %}{{ cts|date('F j Y') }}{% endif %}</small>
</div> </div>
<div class="card-body"> <div class="card-body">
<twig:Atoms:Content content="{{ item.content }}" /> <twig:Atoms:Content content="{{ item.content|default('') }}" />
</div> </div>
{# Display Nostr link previews if links detected #} {# Display Nostr link previews if links detected #}
{% if commentLinks[item.id] is defined and commentLinks[item.id]|length > 0 %} {% if cid != '' and commentLinks[cid] is defined and commentLinks[cid]|length > 0 %}
<div class="card-footer nostr-previews mt-3"> <div class="card-footer nostr-previews mt-3">
<div class="preview-container"> <div class="preview-container">
{% for link in commentLinks[item.id] %} {% for link in commentLinks[cid] %}
<div> <div>
<twig:Molecules:NostrPreview preview="{{ link }}" /> <twig:Molecules:NostrPreview preview="{{ link }}" />
</div> </div>

12
templates/pages/article.html.twig

@ -75,7 +75,17 @@
{# <pre>#} {# <pre>#}
{# {{ article.content }}#} {# {{ article.content }}#}
{# </pre>#} {# </pre>#}
<twig:Organisms:Comments current="30023:{{ article.pubkey }}:{{ article.slug|e }}"></twig:Organisms:Comments> {% set article_coordinate = '30023:' ~ article.pubkey ~ ':' ~ article.slug %}
<section class="article-comments-async" aria-label="Comments">
<div
data-controller="article-comments"
data-article-comments-url-value="{{ path('article_comments_fragment', { coordinate: article_coordinate })|e('html_attr') }}"
>
<div data-article-comments-target="container" class="comments comments--pending">
<p class="text-subtle">Loading comments…</p>
</div>
</div>
</section>
{% endblock %} {% endblock %}
{% block aside %} {% block aside %}

Loading…
Cancel
Save