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 @@
{% for item in list %} + {% set cid = item.id|default('') %} + {% set cpk = item.pubkey|default('') %} + {% set cts = item.created_at|default(null) %}
- +
{# 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 %}