From 2e0f81440215fc530f1ebd5d1b8874fda9c9f025 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Tue, 21 Apr 2026 22:25:47 +0200 Subject: [PATCH] show all comments and pingbacks --- .../article_comments_controller.js | 7 +- assets/styles/article.css | 36 ++ assets/styles/layout.css | 20 + src/Controller/ArticleController.php | 47 ++- src/Service/ArticleCommentThreadLoader.php | 132 ++++-- src/Service/NostrClient.php | 394 ++++++++++++++++-- src/Service/NostrLinkParser.php | 39 +- .../components/Organisms/Comments.html.twig | 51 ++- templates/pages/article.html.twig | 3 +- 9 files changed, 665 insertions(+), 64 deletions(-) diff --git a/assets/controllers/article_comments_controller.js b/assets/controllers/article_comments_controller.js index 639cf94..dec992c 100644 --- a/assets/controllers/article_comments_controller.js +++ b/assets/controllers/article_comments_controller.js @@ -18,6 +18,7 @@ export default class extends Controller { } async load() { + const t0 = performance.now(); try { const res = await fetch(this.urlValue, { headers: { Accept: 'text/html', 'X-Requested-With': 'XMLHttpRequest' }, @@ -27,7 +28,11 @@ export default class extends Controller { } const html = await res.text(); this.containerTarget.innerHTML = html; - } catch { + const ms = Math.round(performance.now() - t0); + console.info(`[article-comments] fragment OK in ${ms}ms`, this.urlValue); + } catch (err) { + const ms = Math.round(performance.now() - t0); + console.warn(`[article-comments] fragment failed after ${ms}ms`, this.urlValue, err); this.containerTarget.innerHTML = '

Comments could not be loaded.

'; } diff --git a/assets/styles/article.css b/assets/styles/article.css index c8e8bd2..78129b7 100644 --- a/assets/styles/article.css +++ b/assets/styles/article.css @@ -96,3 +96,39 @@ blockquote p { .article-comments-async .comments--pending { margin: 1rem 0; } + +.comments-quotes { + margin-top: 2rem; + padding-top: 1.5rem; + border-top: 1px solid var(--color-border); +} + +.comments-quotes__title { + font-size: 1.25rem; + margin: 0 0 0.35rem; +} + +.comments-quotes__lede { + font-size: 0.95rem; + margin: 0 0 1.25rem; +} + +.comments-quotes__lede code { + font-size: 0.9em; +} + +.comments-quotes__sep { + margin: 0 0.25rem; + color: var(--color-text-mid); +} + +.comments-quotes__outlink { + color: var(--color-link); + text-decoration: underline; + text-underline-offset: 2px; +} + +.comment--quote .metadata { + flex-wrap: wrap; + gap: 0.35rem; +} diff --git a/assets/styles/layout.css b/assets/styles/layout.css index b82957f..4f9beba 100644 --- a/assets/styles/layout.css +++ b/assets/styles/layout.css @@ -54,6 +54,15 @@ header { width: 100vw; top: 0; left: 0; + box-sizing: border-box; +} + +/* Desktop: breathing room under the browser chrome. Mobile gets inset via + .header__logo padding in the max-width block below. */ +@media (min-width: 1025px) { + header { + padding-top: max(0.65rem, env(safe-area-inset-top, 0px)); + } } /* Hamburger button */ @@ -135,6 +144,17 @@ main { max-width: 270px; } +@media (min-width: 1025px) { + /* Match extra header padding-top so content and menu clear the fixed bar */ + main { + margin-top: 152px; + } + + .user-menu { + top: 162px; + } +} + .user-nav { padding: 10px; margin: 10px 0; diff --git a/src/Controller/ArticleController.php b/src/Controller/ArticleController.php index 3614275..d117e2e 100644 --- a/src/Controller/ArticleController.php +++ b/src/Controller/ArticleController.php @@ -13,6 +13,7 @@ use Doctrine\ORM\EntityManagerInterface; use League\CommonMark\Exception\CommonMarkException; use nostriphant\NIP19\Bech32; use nostriphant\NIP19\Data\NAddr; +use Psr\Log\LoggerInterface; use Psr\Cache\CacheItemPoolInterface; use Psr\Cache\InvalidArgumentException; use swentel\nostr\Key\Key; @@ -29,27 +30,60 @@ 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 + public function commentsFragment(Request $request, ArticleCommentThreadLoader $loader, LoggerInterface $logger): Response { + // Article body may raise the global limit; keep this sub-request bounded so relay I/O cannot hit max_execution_time (500). + set_time_limit(45); + + $t0 = microtime(true); $coordinate = $request->query->getString('coordinate'); if ($coordinate === '' || !self::isValidNostrCoordinate($coordinate)) { return new Response('Invalid coordinate', Response::HTTP_BAD_REQUEST); } + $articleEventId = $request->query->getString('e'); + if ($articleEventId !== '' && !self::isValidHexEventId($articleEventId)) { + return new Response('Invalid event id', Response::HTTP_BAD_REQUEST); + } + if ($articleEventId === '') { + $articleEventId = null; + } + + $logger->info('http.fragment.comments_start', [ + 'coordinate' => $coordinate, + 'article_event_hex' => $articleEventId, + ]); + $headers = [ 'Content-Type' => 'text/html; charset=UTF-8', 'Cache-Control' => 'private, max-age=60', ]; try { - $data = $loader->load($coordinate); + $data = $loader->load($coordinate, $articleEventId); + $logger->info('http.fragment.comments_after_load', [ + 'elapsed_ms' => (int) round((microtime(true) - $t0) * 1000), + ]); - return $this->render('components/Organisms/Comments.html.twig', $data, new Response( + $tRender = microtime(true); + $response = $this->render('components/Organisms/Comments.html.twig', $data, new Response( '', Response::HTTP_OK, $headers )); - } catch (\Throwable) { + $logger->info('http.fragment.comments_response', [ + 'total_elapsed_ms' => (int) round((microtime(true) - $t0) * 1000), + 'render_elapsed_ms' => (int) round((microtime(true) - $tRender) * 1000), + ]); + + return $response; + } catch (\Throwable $e) { + $logger->error('http.fragment.comments_exception', [ + 'message' => $e->getMessage(), + 'exception_class' => \get_class($e), + 'elapsed_ms' => (int) round((microtime(true) - $t0) * 1000), + ]); + return new Response('
', Response::HTTP_OK, $headers); } } @@ -68,6 +102,11 @@ class ArticleController extends AbstractController return strlen($pubkey) === 64 && ctype_xdigit($pubkey); } + private static function isValidHexEventId(string $id): bool + { + return strlen($id) === 64 && ctype_xdigit($id); + } + /** * @throws \Exception */ diff --git a/src/Service/ArticleCommentThreadLoader.php b/src/Service/ArticleCommentThreadLoader.php index 6870c13..b023f57 100644 --- a/src/Service/ArticleCommentThreadLoader.php +++ b/src/Service/ArticleCommentThreadLoader.php @@ -4,11 +4,12 @@ declare(strict_types=1); namespace App\Service; +use Psr\Log\LoggerInterface; 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. + * Loads Nostr article discussion: NIP-22 (1111) + legacy kind 1 replies, plus quotes/reposts (q / a tags). */ final readonly class ArticleCommentThreadLoader { @@ -16,57 +17,132 @@ final readonly class ArticleCommentThreadLoader private NostrClient $nostrClient, private NostrLinkParser $nostrLinkParser, private CacheInterface $cache, + private LoggerInterface $logger, ) { } /** - * @return array{list: array, commentLinks: array>, processedContent: array} + * @return array{ + * list: array, + * quotes: array, + * commentLinks: array>, + * quoteLinks: array>, + * processedContent: array + * } */ - public function load(string $coordinate): array + public function load(string $coordinate, ?string $articleEventHexId = null): array { - $cacheKey = 'comments_'.hash('sha256', $coordinate); + $t0 = microtime(true); + $cacheKey = 'comments_v4_'.hash('sha256', $coordinate."\0".($articleEventHexId ?? '')); + $this->logger->info('comments.loader.start', [ + 'cache_key_suffix' => substr($cacheKey, -16), + 'coordinate' => $coordinate, + 'article_event_hex' => $articleEventHexId, + ]); try { - $list = $this->cache->get($cacheKey, function (ItemInterface $item) use ($coordinate): array { + $discussion = $this->cache->get($cacheKey, function (ItemInterface $item) use ($coordinate, $articleEventHexId, $t0): array { $item->expiresAfter(120); + $this->logger->info('comments.loader.cache_miss', [ + 'elapsed_since_load_start_ms' => (int) round((microtime(true) - $t0) * 1000), + ]); + $tNostr = microtime(true); try { - return $this->nostrClient->getComments($coordinate); - } catch (\Throwable) { - return []; + $out = $this->nostrClient->getArticleDiscussion($coordinate, $articleEventHexId); + $this->logger->info('comments.loader.nostr_ok', [ + 'nostr_elapsed_ms' => (int) round((microtime(true) - $tNostr) * 1000), + 'thread' => \count($out['thread'] ?? []), + 'quotes' => \count($out['quotes'] ?? []), + ]); + + return $out; + } catch (\Throwable $e) { + $this->logger->error('comments.loader.nostr_failed', [ + 'message' => $e->getMessage(), + 'exception_class' => \get_class($e), + 'nostr_elapsed_ms' => (int) round((microtime(true) - $tNostr) * 1000), + ]); + + return ['thread' => [], 'quotes' => []]; } }); - } catch (\Throwable) { - $list = []; + } catch (\Throwable $e) { + $this->logger->error('comments.loader.cache_failed', [ + 'message' => $e->getMessage(), + 'exception_class' => \get_class($e), + ]); + $discussion = ['thread' => [], 'quotes' => []]; } + $list = $discussion['thread'] ?? []; + $quotes = $discussion['quotes'] ?? []; + $this->logger->info('comments.loader.cache_resolved', [ + 'elapsed_since_start_ms' => (int) round((microtime(true) - $t0) * 1000), + 'thread_events' => \count($list), + 'quote_events' => \count($quotes), + ]); + $commentLinks = []; + $quoteLinks = []; $processedContent = []; + $tLinks = microtime(true); 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; - } + $this->collectLinkPreviewsForEvent($comment, $commentLinks, $processedContent); + } + foreach ($quotes as $event) { + $this->collectLinkPreviewsForEvent($event, $quoteLinks, $processedContent); } + $this->logger->info('comments.loader.link_parse_done', [ + 'elapsed_ms' => (int) round((microtime(true) - $tLinks) * 1000), + 'thread_events' => \count($list), + 'quote_events' => \count($quotes), + 'preview_buckets' => \count($commentLinks) + \count($quoteLinks), + ]); + + $this->logger->info('comments.loader.complete', [ + 'total_elapsed_ms' => (int) round((microtime(true) - $t0) * 1000), + ]); return [ 'list' => $list, + 'quotes' => $quotes, 'commentLinks' => $commentLinks, + 'quoteLinks' => $quoteLinks, 'processedContent' => $processedContent, ]; } + + /** + * @param array> $linkBucket + * @param array $processedContent + */ + private function collectLinkPreviewsForEvent(object $event, array &$linkBucket, array &$processedContent): void + { + $content = $event->content ?? ''; + if ($content === '') { + return; + } + $id = $event->id ?? null; + if ($id === null || $id === '') { + return; + } + $idKey = (string) $id; + $processedContent[$idKey] = (string) $content; + try { + $links = $this->nostrLinkParser->parseLinks((string) $content); + } catch (\Throwable) { + $links = []; + } + // naddr / nevent are already expanded as inline `nostr-preview` widgets in markdown + // (NostrEventRenderer + NostrBareBech32Parser). Footer previews would duplicate the + // same fetch/card (and looked like extra “OG” embeds next to the body). + $links = array_values(array_filter( + $links, + static fn (array $link): bool => !\in_array($link['type'] ?? '', ['naddr', 'nevent'], true), + )); + if ($links !== []) { + $linkBucket[$idKey] = $links; + } + } } diff --git a/src/Service/NostrClient.php b/src/Service/NostrClient.php index b36f10e..923de4f 100644 --- a/src/Service/NostrClient.php +++ b/src/Service/NostrClient.php @@ -408,45 +408,382 @@ class NostrClient } /** - * Get comments for a specific coordinate + * NIP-22 kind 1111 thread, legacy kind 1 replies (pre-NIP-22 clients), and quote/repost-style references. * - * @param string $coordinate The event coordinate (kind:pubkey:identifier) - * @return array Array of comment events - * @throws \Exception + * @param string $coordinate kind:pubkey:d-identifier (e.g. longform address) + * @param null|string $rootEventHexId Published article event id (hex) for #e / #q matching + * + * @return array{thread: array, quotes: array} */ - public function getComments(string $coordinate): array + public function getArticleDiscussion(string $coordinate, ?string $rootEventHexId = null): array { - $this->logger->info('Getting comments for coordinate', ['coordinate' => $coordinate]); + $this->logger->info('nostr.article_discussion.start', [ + 'coordinate' => $coordinate, + 'root_event_hex' => $rootEventHexId, + ]); - // Get author from coordinate, then relays $parts = explode(':', $coordinate, 3); - if (count($parts) < 3) { + if (\count($parts) < 3) { throw new \InvalidArgumentException('Invalid coordinate format, expected kind:pubkey:identifier'); } - $kind = (int)$parts[0]; $pubkey = $parts[1]; - $identifier = end($parts); - // Get relays for the author - $authorRelays = $this->getTopReputableRelaysForAuthor($pubkey); - // Turn into a relaySet + + $tRelays = microtime(true); + $authorRelays = $this->getTopReputableRelaysForAuthor($pubkey, 1); + $this->logger->info('nostr.article_discussion.author_relays_ready', [ + 'elapsed_ms' => (int) round((microtime(true) - $tRelays) * 1000), + 'author_relays' => $authorRelays, + ]); + $relaySet = $this->createRelaySet($authorRelays); + $plannedRelayUrls = $this->plannedRelayUrlsForSet($authorRelays); - // Create request using the helper method - $request = $this->createNostrRequest( - kinds: [KindsEnum::COMMENTS->value], - filters: ['tag' => ['#A', [$coordinate]]], - relaySet: $relaySet - ); + $filters = $this->createArticleDiscussionFilters($coordinate, $rootEventHexId); + $subscription = new Subscription(); + $subscriptionId = $subscription->setId(); + $requestMessage = new RequestMessage($subscriptionId, $filters); + $request = new Request($relaySet, $requestMessage); + + $this->logger->info('nostr.article_discussion.req_sending', [ + 'subscription_id' => $subscriptionId, + 'filter_count' => \count($filters), + 'relay_urls' => $plannedRelayUrls, + 'relay_count' => \count($plannedRelayUrls), + ]); + + $byId = []; + try { + $tSend = microtime(true); + $response = $request->send(); + $sendMs = (int) round((microtime(true) - $tSend) * 1000); + $this->logger->info('nostr.article_discussion.req_response_envelope', [ + 'elapsed_ms' => $sendMs, + 'subscription_id' => $subscriptionId, + ]); + $this->logNostrWireResponseSummary('article_discussion', $response); + } catch (\Throwable $e) { + $this->logger->error('nostr.article_discussion.req_send_failed', [ + 'coordinate' => $coordinate, + 'error' => $e->getMessage(), + 'exception_class' => \get_class($e), + ]); + + return ['thread' => [], 'quotes' => []]; + } + + $tParse = microtime(true); + $this->processResponse($response, function ($event) use (&$byId) { + if (\is_object($event) && isset($event->id)) { + $byId[(string) $event->id] = $event; + } - // Process the response and deduplicate by eventId - $uniqueEvents = []; - $this->processResponse($request->send(), function($event) use (&$uniqueEvents, $pubkey) { - $this->logger->debug('Received comment event', ['event_id' => $event->id]); - $uniqueEvents[$event->id] = $event; return null; }); + $this->logger->info('nostr.article_discussion.events_collected', [ + 'elapsed_ms' => (int) round((microtime(true) - $tParse) * 1000), + 'unique_events' => \count($byId), + ]); + + $all = array_values($byId); + $thread = []; + $threadIds = []; + + foreach ($all as $event) { + $kind = (int) ($event->kind ?? 0); + if ($kind === KindsEnum::COMMENTS->value && $this->eventIsNip22ArticleThreadReply($event, $coordinate)) { + $thread[] = $event; + $threadIds[(string) $event->id] = true; + + continue; + } + if ($kind === KindsEnum::TEXT_NOTE->value && $this->eventIsLegacyThreadReply($event, $coordinate, $rootEventHexId)) { + $thread[] = $event; + $threadIds[(string) $event->id] = true; + } + } + + $quotes = []; + foreach ($all as $event) { + $id = (string) ($event->id ?? ''); + if ($id === '' || isset($threadIds[$id])) { + continue; + } + if ($this->eventIsArticleQuote($event, $coordinate, $rootEventHexId)) { + $quotes[] = $event; + } + } + + $sortAsc = static function ($a, $b): int { + return ((int) ($a->created_at ?? 0)) <=> ((int) ($b->created_at ?? 0)); + }; + $sortDesc = static function ($a, $b): int { + return ((int) ($b->created_at ?? 0)) <=> ((int) ($a->created_at ?? 0)); + }; + usort($thread, $sortAsc); + usort($quotes, $sortDesc); + + $this->logger->info('nostr.article_discussion.done', [ + 'thread_count' => \count($thread), + 'quotes_count' => \count($quotes), + ]); + + return ['thread' => $thread, 'quotes' => $quotes]; + } - return array_values($uniqueEvents); + /** + * Same merge/dedupe rules as {@see createRelaySet()} — used only for logging planned relay URLs. + * + * @param array $relayUrls + * + * @return list + */ + private function plannedRelayUrlsForSet(array $relayUrls): array + { + $seen = []; + $out = []; + foreach (array_merge([$this->defaultRelayUrl], $relayUrls) as $relayUrl) { + if (!\is_string($relayUrl) || $relayUrl === '') { + continue; + } + if (isset($seen[$relayUrl])) { + continue; + } + $seen[$relayUrl] = true; + $out[] = $relayUrl; + } + + return $out; + } + + /** + * One line per relay after {@see Request::send()}: errors vs message-type counts (EVENT, EOSE, …). + * + * @param array $response + */ + private function logNostrWireResponseSummary(string $context, array $response): void + { + foreach ($response as $relayUrl => $relayRes) { + if ($relayRes instanceof \Throwable) { + $this->logger->warning('nostr.wire.relay_throwable', [ + 'context' => $context, + 'relay' => $relayUrl, + 'message' => $relayRes->getMessage(), + 'class' => \get_class($relayRes), + ]); + + continue; + } + if (!\is_iterable($relayRes)) { + $this->logger->warning('nostr.wire.relay_not_iterable', [ + 'context' => $context, + 'relay' => $relayUrl, + 'php_type' => \get_debug_type($relayRes), + ]); + + continue; + } + $counts = [ + 'EVENT' => 0, + 'EOSE' => 0, + 'NOTICE' => 0, + 'ERROR' => 0, + 'AUTH' => 0, + 'CLOSED' => 0, + 'other' => 0, + ]; + foreach ($relayRes as $item) { + if (!\is_object($item)) { + ++$counts['other']; + + continue; + } + $t = (string) ($item->type ?? 'other'); + if (\array_key_exists($t, $counts)) { + ++$counts[$t]; + } else { + ++$counts['other']; + } + } + $this->logger->info('nostr.wire.relay_messages', [ + 'context' => $context, + 'relay' => $relayUrl, + 'counts' => $counts, + ]); + } + } + + private function eventIsNip22ArticleThreadReply(object $event, string $coordinate): bool + { + if ((int) ($event->kind ?? 0) !== KindsEnum::COMMENTS->value) { + return false; + } + foreach ($event->tags ?? [] as $tag) { + if (!\is_array($tag) || \count($tag) < 2) { + continue; + } + $name = (string) ($tag[0] ?? ''); + if (($name === 'a' || $name === 'A') && (string) ($tag[1] ?? '') === $coordinate) { + return true; + } + } + + return false; + } + + private function eventIsLegacyThreadReply(object $event, string $coordinate, ?string $rootEventHexId): bool + { + if ((int) ($event->kind ?? 0) !== KindsEnum::TEXT_NOTE->value) { + return false; + } + foreach ($event->tags ?? [] as $tag) { + if (!\is_array($tag) || \count($tag) < 2) { + continue; + } + $name = (string) ($tag[0] ?? ''); + $val = (string) ($tag[1] ?? ''); + if (($name === 'a' || $name === 'A') && $val === $coordinate) { + return true; + } + if ($rootEventHexId !== null && $rootEventHexId !== '' && $name === 'e' && $val === $rootEventHexId) { + return true; + } + } + + return false; + } + + private function eventIsArticleQuote(object $event, string $coordinate, ?string $rootEventHexId): bool + { + $kind = (int) ($event->kind ?? 0); + if ($kind === KindsEnum::COMMENTS->value) { + foreach ($event->tags ?? [] as $tag) { + if (!\is_array($tag) || \count($tag) < 2) { + continue; + } + if (($tag[0] ?? '') === 'q') { + $val = (string) ($tag[1] ?? ''); + if ($val === $coordinate || ($rootEventHexId !== null && $val === $rootEventHexId)) { + return true; + } + } + } + + return false; + } + foreach ($event->tags ?? [] as $tag) { + if (!\is_array($tag) || \count($tag) < 2) { + continue; + } + $name = (string) ($tag[0] ?? ''); + $val = (string) ($tag[1] ?? ''); + if ($name === 'q') { + if ($val === $coordinate || ($rootEventHexId !== null && $val === $rootEventHexId)) { + return true; + } + } + } + if ($kind === KindsEnum::GENERIC_REPOST->value) { + foreach ($event->tags ?? [] as $tag) { + if (!\is_array($tag) || \count($tag) < 2) { + continue; + } + if (($tag[0] ?? '') === 'a' && (string) ($tag[1] ?? '') === $coordinate) { + return true; + } + } + } + if ($kind === KindsEnum::HIGHLIGHTS->value) { + foreach ($event->tags ?? [] as $tag) { + if (!\is_array($tag) || \count($tag) < 2) { + continue; + } + $n = (string) ($tag[0] ?? ''); + if (($n === 'a' || $n === 'A') && (string) ($tag[1] ?? '') === $coordinate) { + return true; + } + } + } + + return false; + } + + /** + * @return array + */ + private function createArticleDiscussionFilters(string $coordinate, ?string $rootEventHexId): array + { + $limThread = 100; + $limQuote = 80; + + $filters = []; + + $k1111 = KindsEnum::COMMENTS->value; + $f = new Filter(); + $f->setKinds([$k1111]); + $f->setTag('#A', [$coordinate]); + $f->setLimit($limThread); + $filters[] = $f; + $f = new Filter(); + $f->setKinds([$k1111]); + $f->setTag('#a', [$coordinate]); + $f->setLimit($limThread); + $filters[] = $f; + + $k1 = KindsEnum::TEXT_NOTE->value; + $f = new Filter(); + $f->setKinds([$k1]); + $f->setTag('#A', [$coordinate]); + $f->setLimit($limThread); + $filters[] = $f; + $f = new Filter(); + $f->setKinds([$k1]); + $f->setTag('#a', [$coordinate]); + $f->setLimit($limThread); + $filters[] = $f; + + if ($rootEventHexId !== null && $rootEventHexId !== '') { + $f = new Filter(); + $f->setKinds([$k1]); + $f->setTag('#e', [$rootEventHexId]); + $f->setLimit($limThread); + $filters[] = $f; + } + + $qKinds = [ + KindsEnum::TEXT_NOTE->value, + KindsEnum::REPOST->value, + KindsEnum::GENERIC_REPOST->value, + KindsEnum::COMMENTS->value, + KindsEnum::HIGHLIGHTS->value, + ]; + $qVals = [$coordinate]; + if ($rootEventHexId !== null && $rootEventHexId !== '') { + $qVals[] = $rootEventHexId; + } + $f = new Filter(); + $f->setKinds($qKinds); + $f->setTag('#q', $qVals); + $f->setLimit($limQuote); + $filters[] = $f; + + $f = new Filter(); + $f->setKinds([KindsEnum::GENERIC_REPOST->value]); + $f->setTag('#a', [$coordinate]); + $f->setLimit(50); + $filters[] = $f; + + $f = new Filter(); + $f->setKinds([KindsEnum::HIGHLIGHTS->value]); + $f->setTag('#a', [$coordinate]); + $f->setLimit(40); + $filters[] = $f; + $f = new Filter(); + $f->setKinds([KindsEnum::HIGHLIGHTS->value]); + $f->setTag('#A', [$coordinate]); + $f->setLimit(40); + $filters[] = $f; + + return $filters; } /** @@ -691,6 +1028,7 @@ class NostrClient private function createNostrRequest(array $kinds, array $filters = [], ?RelaySet $relaySet = null): Request { $subscription = new Subscription(); + $subscriptionId = $subscription->setId(); $filter = new Filter(); $filter->setKinds($kinds); @@ -707,7 +1045,8 @@ class NostrClient } } - $requestMessage = new RequestMessage($subscription->getId(), [$filter]); + $requestMessage = new RequestMessage($subscriptionId, [$filter]); + return new Request($relaySet ?? $this->defaultRelaySet, $requestMessage); } @@ -724,9 +1063,10 @@ class NostrClient continue; } + $itemEstimate = \is_countable($relayRes) ? \count($relayRes) : null; $this->logger->debug('Processing relay response', [ 'relay' => $relayUrl, - 'response' => $relayRes + 'item_count' => $itemEstimate, ]); foreach ($relayRes as $item) { diff --git a/src/Service/NostrLinkParser.php b/src/Service/NostrLinkParser.php index 3e3856f..c71cafd 100644 --- a/src/Service/NostrLinkParser.php +++ b/src/Service/NostrLinkParser.php @@ -30,7 +30,44 @@ readonly class NostrLinkParser ); // Sort by position to maintain the original order in the text usort($links, fn($a, $b) => $a['position'] <=> $b['position']); - return $links; + + return $this->dedupeLinksForPreviews($links); + } + + /** + * One preview per target. A single `nostr:naddr1…` line is matched both as a prefixed + * link and again as a bare `naddr1…` substring; URL + bare overlaps can happen too. + * + * @param list> $links + * + * @return list> + */ + private function dedupeLinksForPreviews(array $links): array + { + $seen = []; + $out = []; + foreach ($links as $link) { + $key = $this->linkPreviewDedupeKey($link); + if (isset($seen[$key])) { + continue; + } + $seen[$key] = true; + $out[] = $link; + } + + return $out; + } + + private function linkPreviewDedupeKey(array $link): string + { + $identifier = $link['identifier'] ?? null; + if (\is_string($identifier) && $identifier !== '') { + $type = (string) ($link['type'] ?? ''); + + return $type."\0".strtolower($identifier); + } + + return 'match:' . (string) ($link['full_match'] ?? ''); } private function parseUrlsWithNostrIds(string $content): array diff --git a/templates/components/Organisms/Comments.html.twig b/templates/components/Organisms/Comments.html.twig index 0e1d475..0d0ecae 100644 --- a/templates/components/Organisms/Comments.html.twig +++ b/templates/components/Organisms/Comments.html.twig @@ -5,13 +5,19 @@ {% set cts = item.created_at|default(null) %}
- {# Display Nostr link previews if links detected #} {% if cid != '' and commentLinks[cid] is defined and commentLinks[cid]|length > 0 %} + +{% if quotes is defined and quotes|length > 0 %} +
+

Quotes and references

+

Other notes that cite this article in a q tag (NIP-18) or reference its address in a / A (e.g. generic reposts, highlights).

+ {% for item in quotes %} + {% set cid = item.id|default('') %} + {% set cpk = item.pubkey|default('') %} + {% set cts = item.created_at|default(null) %} +
+ +
+ +
+ {% if cid != '' and quoteLinks[cid] is defined and quoteLinks[cid]|length > 0 %} + + {% endif %} +
+ {% endfor %} +
+{% endif %} diff --git a/templates/pages/article.html.twig b/templates/pages/article.html.twig index 88598b5..ffef94a 100644 --- a/templates/pages/article.html.twig +++ b/templates/pages/article.html.twig @@ -76,10 +76,11 @@ {# {{ article.content }}#} {# #} {% set article_coordinate = '30023:' ~ article.pubkey ~ ':' ~ article.slug %} + {% set comments_query = article.eventId ? { coordinate: article_coordinate, e: article.eventId } : { coordinate: article_coordinate } %}

Loading comments…