Browse Source

show all comments and pingbacks

imwald
Silberengel 1 week ago
parent
commit
2e0f814402
  1. 7
      assets/controllers/article_comments_controller.js
  2. 36
      assets/styles/article.css
  3. 20
      assets/styles/layout.css
  4. 47
      src/Controller/ArticleController.php
  5. 124
      src/Service/ArticleCommentThreadLoader.php
  6. 394
      src/Service/NostrClient.php
  7. 39
      src/Service/NostrLinkParser.php
  8. 51
      templates/components/Organisms/Comments.html.twig
  9. 3
      templates/pages/article.html.twig

7
assets/controllers/article_comments_controller.js

@ -18,6 +18,7 @@ export default class extends Controller { @@ -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 { @@ -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 =
'<p class="text-subtle">Comments could not be loaded.</p>';
}

36
assets/styles/article.css

@ -96,3 +96,39 @@ blockquote p { @@ -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;
}

20
assets/styles/layout.css

@ -54,6 +54,15 @@ header { @@ -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 { @@ -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;

47
src/Controller/ArticleController.php

@ -13,6 +13,7 @@ use Doctrine\ORM\EntityManagerInterface; @@ -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 @@ -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('<div class="comments"></div>', Response::HTTP_OK, $headers);
}
}
@ -68,6 +102,11 @@ class ArticleController extends AbstractController @@ -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
*/

124
src/Service/ArticleCommentThreadLoader.php

@ -4,11 +4,12 @@ declare(strict_types=1); @@ -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 @@ -16,57 +17,132 @@ final readonly class ArticleCommentThreadLoader
private NostrClient $nostrClient,
private NostrLinkParser $nostrLinkParser,
private CacheInterface $cache,
private LoggerInterface $logger,
) {
}
/**
* @return array{list: array<int, object>, commentLinks: array<string, array<int, mixed>>, processedContent: array<string, string>}
* @return array{
* list: array<int, object>,
* quotes: array<int, object>,
* commentLinks: array<string, array<int, mixed>>,
* quoteLinks: array<string, array<int, mixed>>,
* processedContent: array<string, string>
* }
*/
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 ?? '';
$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<string, array<int, mixed>> $linkBucket
* @param array<string, string> $processedContent
*/
private function collectLinkPreviewsForEvent(object $event, array &$linkBucket, array &$processedContent): void
{
$content = $event->content ?? '';
if ($content === '') {
continue;
return;
}
$id = $comment->id ?? null;
$id = $event->id ?? null;
if ($id === null || $id === '') {
continue;
return;
}
$idKey = (string) $id;
$processedContent[$idKey] = $content;
$processedContent[$idKey] = (string) $content;
try {
$links = $this->nostrLinkParser->parseLinks($content);
$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 !== []) {
$commentLinks[$idKey] = $links;
}
$linkBucket[$idKey] = $links;
}
return [
'list' => $list,
'commentLinks' => $commentLinks,
'processedContent' => $processedContent,
];
}
}

394
src/Service/NostrClient.php

@ -408,45 +408,382 @@ class NostrClient @@ -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<int, object>, quotes: array<int, object>}
*/
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;
return array_values($uniqueEvents);
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];
}
/**
* Same merge/dedupe rules as {@see createRelaySet()} — used only for logging planned relay URLs.
*
* @param array<int, string> $relayUrls
*
* @return list<string>
*/
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<string, mixed> $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<int, Filter>
*/
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 @@ -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 @@ -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 @@ -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) {

39
src/Service/NostrLinkParser.php

@ -30,7 +30,44 @@ readonly class NostrLinkParser @@ -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<array<string, mixed>> $links
*
* @return list<array<string, mixed>>
*/
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

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

@ -5,13 +5,19 @@ @@ -5,13 +5,19 @@
{% set cts = item.created_at|default(null) %}
<div class="card comment">
<div class="metadata">
<p>{% if cpk != '' %}<twig:Molecules:UserFromNpub ident="{{ cpk }}" />{% else %}<span class="text-subtle">Unknown</span>{% endif %}</p>
<p>
{% if item.kind is defined and item.kind == 1 %}
<span class="ui-badge ui-badge--neutral" title="Legacy text-note reply (pre–NIP-22)">kind 1</span>
{% elseif item.kind is defined and item.kind == 1111 %}
<span class="ui-badge ui-badge--secondary" title="NIP-22 comment">1111</span>
{% endif %}
{% if cpk != '' %}<twig:Molecules:UserFromNpub ident="{{ cpk }}" />{% else %}<span class="text-subtle">Unknown</span>{% endif %}
</p>
<small>{% if cts is not null and cts != '' %}{{ cts|date('F j Y') }}{% endif %}</small>
</div>
<div class="card-body">
<twig:Atoms:Content content="{{ item.content|default('') }}" />
</div>
{# Display Nostr link previews if links detected #}
{% if cid != '' and commentLinks[cid] is defined and commentLinks[cid]|length > 0 %}
<div class="card-footer nostr-previews mt-3">
<div class="preview-container">
@ -26,3 +32,44 @@ @@ -26,3 +32,44 @@
</div>
{% endfor %}
</div>
{% if quotes is defined and quotes|length > 0 %}
<div class="comments-quotes">
<h3 class="comments-quotes__title">Quotes and references</h3>
<p class="text-subtle comments-quotes__lede">Other notes that cite this article in a <code>q</code> tag (NIP-18) or reference its address in <code>a</code> / <code>A</code> (e.g. generic reposts, highlights).</p>
{% for item in quotes %}
{% set cid = item.id|default('') %}
{% set cpk = item.pubkey|default('') %}
{% set cts = item.created_at|default(null) %}
<div class="card comment comment--quote">
<div class="metadata">
<p>
<span class="ui-badge ui-badge--neutral">kind {{ item.kind|default('?') }}</span>
{% if cpk != '' %}<twig:Molecules:UserFromNpub ident="{{ cpk }}" />{% else %}<span class="text-subtle">Unknown</span>{% endif %}
</p>
<small>
{% if cts is not null and cts != '' %}{{ cts|date('F j Y') }}{% endif %}
{% if cid != '' %}
<span class="comments-quotes__sep">·</span>
<a href="https://jumble.imwald.eu/feed/notes/{{ cid }}" class="comments-quotes__outlink" target="_blank" rel="noopener noreferrer">View event</a>
{% endif %}
</small>
</div>
<div class="card-body">
<twig:Atoms:Content content="{{ item.content|default('') }}" />
</div>
{% if cid != '' and quoteLinks[cid] is defined and quoteLinks[cid]|length > 0 %}
<div class="card-footer nostr-previews mt-3">
<div class="preview-container">
{% for link in quoteLinks[cid] %}
<div>
<twig:Molecules:NostrPreview preview="{{ link }}" />
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}

3
templates/pages/article.html.twig

@ -76,10 +76,11 @@ @@ -76,10 +76,11 @@
{# {{ article.content }}#}
{# </pre>#}
{% set article_coordinate = '30023:' ~ article.pubkey ~ ':' ~ article.slug %}
{% set comments_query = article.eventId ? { coordinate: article_coordinate, e: article.eventId } : { coordinate: article_coordinate } %}
<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') }}"
data-article-comments-url-value="{{ path('article_comments_fragment', comments_query)|e('html_attr') }}"
>
<div data-article-comments-target="container" class="comments comments--pending">
<p class="text-subtle">Loading comments…</p>

Loading…
Cancel
Save