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) %}
Quotes and references
+Other notes that cite this article in a
+ {% for item in quotes %} + {% set cid = item.id|default('') %} + {% set cpk = item.pubkey|default('') %} + {% set cts = item.created_at|default(null) %} +qtag (NIP-18) or reference its address ina/A(e.g. generic reposts, highlights).+ kind {{ item.kind|default('?') }} + {% if cpk != '' %} {% else %}Unknown{% endif %}
+
+ + {% if cts is not null and cts != '' %}{{ cts|date('F j Y') }}{% endif %} + {% if cid != '' %} + · + View event + {% endif %} + +