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.
258 lines
8.4 KiB
258 lines
8.4 KiB
<?php |
|
|
|
namespace App\Twig\Components\Organisms; |
|
|
|
use App\Message\FetchCommentsMessage; |
|
use App\Service\NostrLinkParser; |
|
use App\Service\RedisCacheService; |
|
use Symfony\Component\Messenger\Exception\ExceptionInterface; |
|
use Symfony\Component\Messenger\MessageBusInterface; |
|
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; |
|
use Symfony\UX\LiveComponent\Attribute\LiveAction; |
|
use Symfony\UX\LiveComponent\Attribute\LiveArg; |
|
use Symfony\UX\LiveComponent\Attribute\LiveProp; |
|
use Symfony\UX\LiveComponent\DefaultActionTrait; |
|
|
|
#[AsLiveComponent('Organisms:Comments')] |
|
final class Comments |
|
{ |
|
use DefaultActionTrait; |
|
|
|
// Live input |
|
#[LiveProp(writable: false)] |
|
public string $current; |
|
|
|
// same fields you used before (the template will read from the payload) |
|
public array $list = []; |
|
public array $commentLinks = []; |
|
public array $processedContent = []; |
|
public array $zapAmounts = []; |
|
public array $zappers = []; |
|
public array $authorsMetadata = []; |
|
public bool $loading = true; |
|
|
|
public function __construct( |
|
private readonly NostrLinkParser $nostrLinkParser, |
|
private readonly RedisCacheService $redisCacheService, |
|
private readonly MessageBusInterface $bus, |
|
) {} |
|
|
|
/** |
|
* Mount the component with the current coordinate |
|
*/ |
|
public function mount(string $current): void |
|
{ |
|
$this->current = $current; |
|
$this->loading = true; |
|
|
|
// Kick off (async) fetch to populate cache + publish Mercure |
|
try { |
|
$this->bus->dispatch(new FetchCommentsMessage($current)); |
|
} catch (ExceptionInterface) { |
|
// Doing nothing for now |
|
} |
|
} |
|
|
|
#[LiveAction] |
|
public function ingest(#[LiveArg('payload')] string $json): void |
|
{ |
|
$data = json_decode($json, true) ?: []; |
|
|
|
// Validate/normalize as needed |
|
$this->list = $data['comments'] ?? []; |
|
$this->authorsMetadata = $data['profiles'] ?? []; |
|
|
|
// If you send these in the event: |
|
$this->zappers = $data['zappers'] ?? []; |
|
$this->zapAmounts = $data['zapAmounts'] ?? []; |
|
$this->commentLinks = $data['commentLinks'] ?? []; |
|
|
|
$this->loading = false; |
|
} |
|
|
|
/** Expose a view model to the template; keeps all parsing server-side */ |
|
public function getPayload(): array |
|
{ |
|
// Uses the helper we added earlier: getCommentsPayload($coordinate) |
|
$payload = $this->redisCacheService->getCommentsPayload($this->current) ?? [ |
|
'comments' => [], |
|
'profiles' => [], |
|
'zappers' => [], |
|
'zapAmounts' => [], |
|
'commentLinks' => [], |
|
]; |
|
|
|
// If your handler doesn’t compute zaps/links yet, reuse your helpers here: |
|
$this->list = $payload['comments']; |
|
$this->authorsMetadata = $payload['profiles'] ?? []; |
|
|
|
$this->parseZaps(); // your existing method – fills $zapAmounts & $zappers |
|
$this->parseNostrLinks(); // your existing method – fills $commentLinks & $processedContent |
|
|
|
return [ |
|
'list' => $this->list, |
|
'authorsMetadata' => $this->authorsMetadata, |
|
'zappers' => $this->zappers, |
|
'zapAmounts' => $this->zapAmounts, |
|
'commentLinks' => $this->commentLinks, |
|
'loading' => false, |
|
]; |
|
} |
|
|
|
/** |
|
* Parse Nostr links in comments for client-side loading |
|
*/ |
|
private function parseNostrLinks(): void |
|
{ |
|
foreach ($this->list as $comment) { |
|
$content = $comment->content ?? ''; |
|
if (empty($content)) { |
|
continue; |
|
} |
|
|
|
// Store the original content |
|
$this->processedContent[$comment->id] = $content; |
|
|
|
// Parse the content for Nostr links |
|
$links = $this->nostrLinkParser->parseLinks($content); |
|
|
|
if (!empty($links)) { |
|
// Save the links for the client-side to fetch |
|
$this->commentLinks[$comment->id] = $links; |
|
} |
|
} |
|
} |
|
|
|
/** |
|
* Parse Zaps to get amounts |
|
*/ |
|
public function parseZaps(): void |
|
{ |
|
foreach ($this->list as $comment) { |
|
// check if kind is 9735 to get zaps |
|
if ($comment->kind !== 9735) { |
|
continue; |
|
} |
|
|
|
$tags = $comment->tags ?? []; |
|
if (empty($tags) || !is_array($tags)) { |
|
continue; |
|
} |
|
|
|
// 1) Find and decode description JSON |
|
$descriptionJson = $this->findTagValue($tags, 'description'); |
|
|
|
if ($descriptionJson === null) { |
|
continue; // can't validate sender without description |
|
} |
|
$description = json_decode($descriptionJson); |
|
|
|
// 2) If description has content, add it to the comment |
|
if (!empty($description->content)) { |
|
$comment->content = $description->content; |
|
} |
|
|
|
// 3) Get amount: prefer explicit 'amount' tag (msat), fallback to BOLT11 invoice parsing |
|
$amountSats = null; |
|
|
|
$amountMsatStr = $this->findTagValue($description->tags, 'amount'); |
|
if (is_numeric($amountMsatStr)) { |
|
// amount in millisats per NIP-57 |
|
$msats = (int) $amountMsatStr; |
|
if ($msats > 0) { |
|
$amountSats = intdiv($msats, 1000); |
|
} |
|
} |
|
|
|
if (empty($amountSats)) { |
|
$bolt11 = $this->findTagValue($tags, 'bolt11'); |
|
if (!empty($bolt11)) { |
|
$amountSats = $this->parseBolt11ToSats($bolt11); |
|
} |
|
} |
|
|
|
$this->zappers[$comment->id] = $this->findTagValue($tags, 'P'); |
|
if ($amountSats !== null && $amountSats > 0) { |
|
$this->zapAmounts[$comment->id] = $amountSats; |
|
} |
|
} |
|
} |
|
|
|
// --- Helpers --- |
|
|
|
/** |
|
* Find first tag value by key. |
|
* @param array $tags |
|
* @param string $key |
|
* @return string|null |
|
*/ |
|
private function findTagValue(array $tags, string $key): ?string |
|
{ |
|
foreach ($tags as $tag) { |
|
if (!is_array($tag) || count($tag) < 2) { |
|
continue; |
|
} |
|
if (($tag[0] ?? null) === $key) { |
|
return (string) $tag[1]; |
|
} |
|
} |
|
return null; |
|
} |
|
|
|
/** |
|
* Minimal BOLT11 amount parser to sats from invoice prefix (lnbc...amount[munp]). |
|
* Returns null if amount is not embedded. |
|
*/ |
|
private function parseBolt11ToSats(string $invoice): ?int |
|
{ |
|
// Normalize |
|
$inv = strtolower(trim($invoice)); |
|
// Find the amount section after the hrp prefix (lnbc or lntb etc.) |
|
// Spec: ln + currency + amount + multiplier. We only need amount+multiplier. |
|
if (!str_starts_with($inv, 'ln')) { |
|
return null; |
|
} |
|
// Strip prefix up to first digit |
|
$i = 0; |
|
while ($i < strlen($inv) && !ctype_digit($inv[$i])) { |
|
$i++; |
|
} |
|
if ($i >= strlen($inv)) { |
|
return null; // no amount encoded |
|
} |
|
// Read numeric part |
|
$j = $i; |
|
while ($j < strlen($inv) && ctype_digit($inv[$j])) { |
|
$j++; |
|
} |
|
$numStr = substr($inv, $i, $j - $i); |
|
if ($numStr === '') { |
|
return null; |
|
} |
|
$amount = (int) $numStr; |
|
// Multiplier char (optional). If none, amount is in BTC (rare for zaps) |
|
$mult = $inv[$j] ?? ''; |
|
$sats = null; |
|
// 1 BTC = 100_000_000 sats |
|
switch ($mult) { |
|
case 'm': // milli-btc |
|
$sats = (int) round($amount * 100_000); // 0.001 BTC = 100_000 sats |
|
break; |
|
case 'u': // micro-btc |
|
$sats = (int) round($amount * 1000); // 0.000001 BTC = 1000 sats |
|
break; |
|
case 'n': // nano-btc |
|
$sats = (int) round($amount * 0.001); // 0.000000001 BTC = 0.001 sats |
|
break; |
|
case 'p': // pico-btc |
|
$sats = (int) round($amount * 0.000001); // 1e-12 BTC = 1e-4 sats |
|
break; |
|
default: |
|
// No multiplier => amount in BTC |
|
$sats = (int) round($amount * 100_000_000); |
|
break; |
|
} |
|
// Ensure positive and at least 1 sat if rounding produced 0 |
|
return $sats > 0 ? $sats : null; |
|
} |
|
}
|
|
|