You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

483 lines
18 KiB

<?php
declare(strict_types=1);
namespace App\Service;
use App\Enum\KindsEnum;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
/**
* Loads Nostr article discussion: NIP-22 (1111) + legacy kind 1 replies, plus quotes/reposts (q / a tags).
*
* Reply blurbs mirror the jumble client: resolve the parent from `e` / `E` tags (NIP-10, `reply` marker,
* last-of-sequence), then show a short preview of the parent’s body (see jumble `ParentNotePreview`). Inline
* NIP-22 blockquotes with `nostr:` in the child still take precedence when present.
*/
final readonly class ArticleCommentThreadLoader
{
private const PARENT_REPLY_TEXT_PREVIEW_MAX = 200;
/** PSR-6 pool backing {@see $cache}; used for true cache-only reads (SSR) without invoking Nostr. */
public function __construct(
private NostrClient $nostrClient,
private NostrLinkParser $nostrLinkParser,
private CacheInterface $cache,
private CacheItemPoolInterface $appCachePool,
private LoggerInterface $logger,
) {
}
/**
* @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>
* }|null
*
* Each object in `list` may be enriched with: unfold_reply_blurb, unfold_body, unfold_depth
* (0–3, for UI indentation).
*/
public function tryLoadFromCacheOnly(string $coordinate, ?string $articleEventHexId = null): ?array
{
$key = $this->cacheKeyForThread($coordinate, $articleEventHexId);
try {
$item = $this->appCachePool->getItem($key);
} catch (InvalidArgumentException) {
return null;
}
if (!$item->isHit()) {
return null;
}
$discussion = $item->get();
if (!\is_array($discussion)) {
return null;
}
if (($discussion['thread'] ?? []) === [] && ($discussion['quotes'] ?? []) === []) {
$this->logger->info('comments.loader.cache_hit_empty', ['coordinate' => $coordinate]);
} else {
$this->logger->info('comments.loader.cache_hit_only', [
'coordinate' => $coordinate,
'thread' => \count($discussion['thread'] ?? []),
]);
}
return $this->expandFromDiscussion($discussion, microtime(true), $articleEventHexId);
}
/**
* @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>
* }
*
* @see self::tryLoadFromCacheOnly() for list object enrichments
*/
public function load(string $coordinate, ?string $articleEventHexId = null): array
{
$t0 = microtime(true);
$cacheKey = $this->cacheKeyForThread($coordinate, $articleEventHexId);
$this->logger->info('comments.loader.start', [
'cache_key_suffix' => substr($cacheKey, -16),
'coordinate' => $coordinate,
'article_event_hex' => $articleEventHexId,
]);
try {
$discussion = $this->cache->get($cacheKey, function (ItemInterface $item) use ($coordinate, $articleEventHexId, $t0): array {
// Prewarm + HTTP should share the same key; 2m expiry caused cold misses during normal use.
$item->expiresAfter(86400);
$this->logger->info('comments.loader.cache_miss', [
'elapsed_since_load_start_ms' => (int) round((microtime(true) - $t0) * 1000),
]);
$tNostr = microtime(true);
// On failure, let this throw: Symfony cache will not store a value, so a prior good thread is not replaced by [].
$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.cache_or_nostr_failed', [
'message' => $e->getMessage(),
'exception_class' => \get_class($e),
]);
$discussion = ['thread' => [], 'quotes' => []];
}
return $this->expandFromDiscussion($discussion, $t0, $articleEventHexId);
}
/**
* Drop cached thread so the next load refetches from relays (e.g. after publishing a comment).
*/
public function invalidateThread(string $coordinate, ?string $articleEventHexId): void
{
$key = $this->cacheKeyForThread($coordinate, $articleEventHexId);
try {
$this->appCachePool->deleteItem($key);
} catch (InvalidArgumentException) {
}
}
/**
* Same key for CLI prewarm, anonymous, and logged-in readers so cached threads are shared.
* (Relay selection for misses may still add aggr for signed-in users in {@see NostrClient::getArticleDiscussion}.)
*/
private function cacheKeyForThread(string $coordinate, ?string $articleEventHexId): string
{
return 'comments_v5_'.hash('sha256', $coordinate."\0".($articleEventHexId ?? ''));
}
/**
* @param array{thread: array<int, object>, quotes: array<int, object>} $discussion
*
* @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>
* }
*/
private function expandFromDiscussion(array $discussion, float $t0, ?string $articleEventHexId = null): array
{
$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),
]);
$this->enrichThreadListForDisplay($list, $articleEventHexId);
$this->stripRepostEventBodies($list, $quotes);
$commentLinks = [];
$quoteLinks = [];
$processedContent = [];
$tLinks = microtime(true);
foreach ($list as $comment) {
$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,
];
}
/**
* NIP-18 reposts (kinds 6 and 16) carry a JSON-wrapped copy of the original; we only show who reposted, not the body.
*
* @param array<int, object> $list
* @param array<int, object> $quotes
*/
private function stripRepostEventBodies(array $list, array $quotes): void
{
$strip = static function (object $ev): void {
$k = (int) ($ev->kind ?? 0);
if ($k !== KindsEnum::REPOST->value && $k !== KindsEnum::GENERIC_REPOST->value) {
return;
}
$ev->content = '';
if (isset($ev->unfold_reply_blurb)) {
$ev->unfold_reply_blurb = null;
}
if (isset($ev->unfold_body)) {
$ev->unfold_body = '';
}
};
foreach ($list as $ev) {
if (\is_object($ev)) {
$strip($ev);
}
}
foreach ($quotes as $ev) {
if (\is_object($ev)) {
$strip($ev);
}
}
}
/**
* @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 === '') {
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;
}
}
/**
* Adds reply blurb / body split and capped thread depth (0–3) on each thread event for Twig/CSS.
*
* @param array<int, object> $list
*/
private function enrichThreadListForDisplay(array $list, ?string $articleEventHexId): void
{
$threadIdSet = [];
foreach ($list as $ev) {
$hid = isset($ev->id) ? strtolower((string) $ev->id) : '';
if (64 === \strlen($hid) && ctype_xdigit($hid)) {
$threadIdSet[$hid] = true;
}
}
$idToEvent = [];
foreach ($list as $ev) {
$hid = isset($ev->id) ? strtolower((string) $ev->id) : '';
if (64 === \strlen($hid) && ctype_xdigit($hid)) {
$idToEvent[$hid] = $ev;
}
}
$parentOf = [];
foreach ($list as $ev) {
$id = isset($ev->id) ? strtolower((string) $ev->id) : '';
if (64 !== \strlen($id) || !ctype_xdigit($id)) {
continue;
}
$p = $this->resolveInThreadParentId($ev, $threadIdSet, $articleEventHexId);
if ($p !== null) {
$parentOf[$id] = $p;
}
}
foreach ($list as $ev) {
$id = isset($ev->id) ? strtolower((string) $ev->id) : '';
$raw = isset($ev->content) ? (string) $ev->content : '';
$split = $this->splitNip22ReplyBlurb($raw);
$blurb = $split['blurb'];
if (($blurb === null || trim($blurb) === '') && $id !== '' && isset($parentOf[$id])) {
$pid = $parentOf[$id];
if (isset($idToEvent[$pid])) {
$parent = $idToEvent[$pid];
$pRaw = isset($parent->content) ? (string) $parent->content : '';
$preview = $this->parentEventTextPreviewForBlurb($pRaw);
if ($preview !== '') {
$blurb = '> *'.'Replying to thread'.'* — '."\n> ".$preview;
}
}
}
$ev->unfold_reply_blurb = $this->formatReplyBlurbForDisplay($blurb);
$ev->unfold_body = $split['body'];
$ev->unfold_depth = $id === '' || !ctype_xdigit($id) ? 0 : $this->threadDepthCapped($id, $parentOf, 3);
}
}
/**
* NIP-22 storage often includes a markdown link to the parent; hide that in the UI and show plain “replying to …” text.
*/
private function formatReplyBlurbForDisplay(?string $blurb): ?string
{
if ($blurb === null) {
return null;
}
$s = trim($blurb);
if ($s === '') {
return null;
}
$s = preg_replace('/\s*\[[^\]]+\]\(nostr:[^)]+\)/u', '', $s) ?? $s;
$s = preg_replace('/\s*\(nostr:[^)]+\)/u', '', $s) ?? $s;
$s = rtrim($s, " \t");
$s = preg_replace('/\s*—\s*$/u', '', $s) ?? $s;
$s = rtrim($s, " \t");
$s = preg_replace('/\*\*([^*]+)\*\*/u', '$1', $s) ?? $s;
$s = trim($s);
return $s === '' ? null : $s;
}
/**
* @return array{blurb: string|null, body: string}
*/
private function splitNip22ReplyBlurb(string $content): array
{
if (!str_contains($content, "\n\n")) {
return ['blurb' => null, 'body' => $content];
}
$parts = explode("\n\n", $content, 2);
$first = trim((string) ($parts[0] ?? ''));
$rest = (string) ($parts[1] ?? '');
if ($first === '' || !str_starts_with($first, '>')) {
return ['blurb' => null, 'body' => $content];
}
if (!str_contains($first, 'nostr:')) {
return ['blurb' => null, 'body' => $content];
}
return ['blurb' => $first, 'body' => $rest];
}
/**
* Truncated single-line text from a parent’s content (strips a leading NIP-22 quote block when present),
* similar in spirit to Jumble’s {@see ParentNotePreview} + compact ContentPreview.
*/
private function parentEventTextPreviewForBlurb(string $raw): string
{
$split = $this->splitNip22ReplyBlurb($raw);
$use = (string) $split['body'];
if (trim($use) === '' && $raw !== '') {
$use = $raw;
}
$one = trim((string) (preg_replace('/\s+/', ' ', $use) ?? ''));
if ($one === '') {
return '';
}
if (mb_strlen($one) > self::PARENT_REPLY_TEXT_PREVIEW_MAX) {
return mb_substr($one, 0, self::PARENT_REPLY_TEXT_PREVIEW_MAX).'…';
}
return $one;
}
/**
* In-thread parent id for a reply, mirroring jumble’s {@code getParentETag} / kind-1111 branch: prefer
* {@code e}/{@code E} with marker {@code reply} when that id is another event in the loaded thread, else
* the last in-thread id when several {@code e}/{@code E} apply (NIP-10), else the only in-thread id.
*
* The article’s root event id is never returned (blurbs/depth are about comments in the fetched list only).
*
* @param array<string, true> $threadIdSet lower-hex id keys
*
* @return string|null lower-hex parent id, or null
*/
private function resolveInThreadParentId(object $event, array $threadIdSet, ?string $articleEventHexId): ?string
{
$selfId = isset($event->id) ? strtolower((string) $event->id) : '';
if (64 !== \strlen($selfId) || !ctype_xdigit($selfId)) {
$selfId = '';
}
$article = ($articleEventHexId !== null && $articleEventHexId !== '' && 64 === \strlen($articleEventHexId) && ctype_xdigit($articleEventHexId))
? strtolower($articleEventHexId) : null;
$isThreadTag = static function (string $n): bool {
return $n === 'e' || $n === 'E';
};
$validInThread = function (string $pid) use ($selfId, $article, $threadIdSet): bool {
if (64 !== \strlen($pid) || !ctype_xdigit($pid)) {
return false;
}
if ($selfId !== '' && hash_equals($pid, $selfId)) {
return false;
}
if ($article !== null && hash_equals($pid, $article)) {
return false;
}
return isset($threadIdSet[$pid]);
};
// 1) Explicit NIP-10 "reply" marker
foreach ($event->tags ?? [] as $tag) {
if (!\is_array($tag) || \count($tag) < 2) {
continue;
}
if (!$isThreadTag((string) ($tag[0] ?? ''))) {
continue;
}
if (($tag[3] ?? '') !== 'reply') {
continue;
}
$pid = strtolower((string) ($tag[1] ?? ''));
if ($validInThread($pid)) {
return $pid;
}
}
// 2) All in-thread references in tag order; last wins when multiple (cf. jumble getParentETagCommentOrDiscussion)
$candidates = [];
foreach ($event->tags ?? [] as $tag) {
if (!\is_array($tag) || \count($tag) < 2) {
continue;
}
if (!$isThreadTag((string) ($tag[0] ?? ''))) {
continue;
}
$pid = strtolower((string) ($tag[1] ?? ''));
if ($validInThread($pid)) {
$candidates[] = $pid;
}
}
if ($candidates === []) {
return null;
}
if (\count($candidates) >= 2) {
return $candidates[\count($candidates) - 1];
}
return $candidates[0];
}
/**
* @param array<string, string> $parentOf child id => parent id
*/
private function threadDepthCapped(string $id, array $parentOf, int $max): int
{
$depth = 0;
$current = $id;
for ($i = 0; $i < 64; ++$i) {
if (!isset($parentOf[$current])) {
break;
}
$current = $parentOf[$current];
++$depth;
}
return $depth > $max ? $max : $depth;
}
}