22 changed files with 1101 additions and 591 deletions
@ -0,0 +1,124 @@ |
|||||||
|
import { Controller } from "@hotwired/stimulus"; |
||||||
|
|
||||||
|
// Connects to data-controller="comments-mercure"
|
||||||
|
export default class extends Controller { |
||||||
|
static values = { |
||||||
|
coordinate: String |
||||||
|
} |
||||||
|
static targets = ["list", "loading"]; |
||||||
|
|
||||||
|
connect() { |
||||||
|
const coordinate = this.coordinateValue; |
||||||
|
const topic = `/comments/${coordinate}`; |
||||||
|
const hubUrl = window.MercureHubUrl || (document.querySelector('meta[name="mercure-hub"]')?.content); |
||||||
|
console.log('[comments-mercure] connect', { coordinate, topic, hubUrl }); |
||||||
|
if (!hubUrl) return; |
||||||
|
const url = new URL(hubUrl); |
||||||
|
url.searchParams.append('topic', topic); |
||||||
|
this.eventSource = new EventSource(url.toString()); |
||||||
|
this.eventSource.onopen = () => { |
||||||
|
console.log('[comments-mercure] EventSource opened', url.toString()); |
||||||
|
}; |
||||||
|
this.eventSource.onerror = (e) => { |
||||||
|
console.error('[comments-mercure] EventSource error', e); |
||||||
|
}; |
||||||
|
this.eventSource.onmessage = (event) => { |
||||||
|
console.log('[comments-mercure] Event received', event.data); |
||||||
|
const data = JSON.parse(event.data); |
||||||
|
this.profiles = data.profiles || {}; |
||||||
|
if (this.hasLoadingTarget) this.loadingTarget.style.display = 'none'; |
||||||
|
if (this.hasListTarget) { |
||||||
|
if (data.comments && data.comments.length > 0) { |
||||||
|
this.listTarget.innerHTML = data.comments.map((item) => { |
||||||
|
const zapData = this.parseZapAmount(item) || {}; |
||||||
|
const zapAmount = zapData.amount; |
||||||
|
const zapperPubkey = zapData.zapper; |
||||||
|
const parsedContent = this.parseContent(item.content); |
||||||
|
const isZap = item.kind === 9735; |
||||||
|
const displayPubkey = isZap ? (zapperPubkey || item.pubkey) : item.pubkey; |
||||||
|
const profile = this.profiles[displayPubkey]; |
||||||
|
const displayName = profile?.name || displayPubkey; |
||||||
|
return `<div class="card comment ${isZap ? 'zap-comment' : ''}">
|
||||||
|
<div class="metadata"> |
||||||
|
<span><a href="/p/${displayPubkey}">${displayName}</a></span> |
||||||
|
<small>${item.created_at ? new Date(item.created_at * 1000).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }) : ''}</small> |
||||||
|
</div> |
||||||
|
<div class="card-body"> |
||||||
|
${isZap ? `<div class="zap-amount">${zapAmount ? `<strong>${zapAmount} sat</strong>` : '<em>Zap</em>'}</div>` : ''} |
||||||
|
<div>${parsedContent}</div> |
||||||
|
</div> |
||||||
|
</div>`; |
||||||
|
}).join(''); |
||||||
|
} else { |
||||||
|
this.listTarget.innerHTML = '<div class="no-comments">No comments yet.</div>'; |
||||||
|
} |
||||||
|
this.listTarget.style.display = ''; |
||||||
|
} |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
disconnect() { |
||||||
|
if (this.eventSource) { |
||||||
|
this.eventSource.close(); |
||||||
|
console.log('[comments-mercure] EventSource closed'); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
parseContent(content) { |
||||||
|
if (!content) return ''; |
||||||
|
|
||||||
|
// Escape HTML to prevent XSS
|
||||||
|
let html = content.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); |
||||||
|
|
||||||
|
// Parse URLs
|
||||||
|
html = html.replace(/(https?:\/\/[^\s]+)/g, '<a href="$1" target="_blank" rel="noopener">$1</a>'); |
||||||
|
|
||||||
|
// Parse Nostr npub
|
||||||
|
html = html.replace(/\b(npub1[a-z0-9]+)\b/g, '<a href="/user/$1">$1</a>'); |
||||||
|
|
||||||
|
// Parse Nostr nevent
|
||||||
|
html = html.replace(/\b(nevent1[a-z0-9]+)\b/g, '<a href="/event/$1">$1</a>'); |
||||||
|
|
||||||
|
// Parse Nostr nprofile
|
||||||
|
html = html.replace(/\b(nprofile1[a-z0-9]+)\b/g, '<a href="/profile/$1">$1</a>'); |
||||||
|
|
||||||
|
// Parse Nostr note
|
||||||
|
html = html.replace(/\b(note1[a-z0-9]+)\b/g, '<a href="/note/$1">$1</a>'); |
||||||
|
|
||||||
|
return html; |
||||||
|
} |
||||||
|
|
||||||
|
parseZapAmount(item) { |
||||||
|
if (item.kind !== 9735) return null; |
||||||
|
|
||||||
|
const tags = item.tags || []; |
||||||
|
let amount = null; |
||||||
|
let zapper = null; |
||||||
|
|
||||||
|
// Find zapper from 'p' tag
|
||||||
|
const pTag = tags.find(tag => tag[0] === 'p'); |
||||||
|
if (pTag && pTag[1]) { |
||||||
|
zapper = pTag[1]; |
||||||
|
} |
||||||
|
|
||||||
|
// Find amount in 'amount' tag (msat)
|
||||||
|
const amountTag = tags.find(tag => tag[0] === 'amount'); |
||||||
|
if (amountTag && amountTag[1]) { |
||||||
|
const msat = parseInt(amountTag[1], 10); |
||||||
|
amount = Math.floor(msat / 1000); // Convert to sat
|
||||||
|
} |
||||||
|
|
||||||
|
// Fallback to description for content
|
||||||
|
const descTag = tags.find(tag => tag[0] === 'description'); |
||||||
|
if (descTag && descTag[1]) { |
||||||
|
try { |
||||||
|
const desc = JSON.parse(descTag[1]); |
||||||
|
if (desc.content) { |
||||||
|
item.content = desc.content; // Update content
|
||||||
|
} |
||||||
|
} catch (e) {} |
||||||
|
} |
||||||
|
|
||||||
|
return { amount, zapper }; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,19 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
namespace App\Message; |
||||||
|
|
||||||
|
class FetchCommentsMessage |
||||||
|
{ |
||||||
|
private string $coordinate; |
||||||
|
|
||||||
|
public function __construct(string $coordinate) |
||||||
|
{ |
||||||
|
$this->coordinate = $coordinate; |
||||||
|
} |
||||||
|
|
||||||
|
public function getCoordinate(): string |
||||||
|
{ |
||||||
|
return $this->coordinate; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
@ -0,0 +1,67 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
namespace App\MessageHandler; |
||||||
|
|
||||||
|
use App\Message\FetchCommentsMessage; |
||||||
|
use App\Service\NostrClient; |
||||||
|
use App\Service\NostrLinkParser; |
||||||
|
use App\Service\RedisCacheService; |
||||||
|
use Psr\Log\LoggerInterface; |
||||||
|
use Symfony\Component\Messenger\Attribute\AsMessageHandler; |
||||||
|
use Symfony\Component\Mercure\HubInterface; |
||||||
|
use Symfony\Component\Mercure\Update; |
||||||
|
|
||||||
|
#[AsMessageHandler] |
||||||
|
class FetchCommentsHandler |
||||||
|
{ |
||||||
|
public function __construct( |
||||||
|
private readonly NostrClient $nostrClient, |
||||||
|
private readonly NostrLinkParser $nostrLinkParser, |
||||||
|
private readonly RedisCacheService $redisCacheService, |
||||||
|
private readonly HubInterface $hub, |
||||||
|
private readonly LoggerInterface $logger |
||||||
|
) {} |
||||||
|
|
||||||
|
public function __invoke(FetchCommentsMessage $message): void |
||||||
|
{ |
||||||
|
$coordinate = $message->getCoordinate(); |
||||||
|
$comments = $this->nostrClient->getComments($coordinate); |
||||||
|
|
||||||
|
// Collect all pubkeys: authors and zappers |
||||||
|
$allPubKeys = []; |
||||||
|
foreach ($comments as $c) { |
||||||
|
$allPubKeys[] = $c->pubkey; |
||||||
|
if ($c->kind == 9735) { |
||||||
|
$tags = $c->tags ?? []; |
||||||
|
foreach ($tags as $tag) { |
||||||
|
if ($tag[0] === 'p' && isset($tag[1])) { |
||||||
|
$allPubKeys[] = $tag[1]; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
$allPubKeys = array_unique($allPubKeys); |
||||||
|
$authorsMetadata = $this->redisCacheService->getMultipleMetadata($allPubKeys); |
||||||
|
$this->logger->info('Fetched ' . count($comments) . ' comments for coordinate: ' . $coordinate); |
||||||
|
$this->logger->info('Fetched ' . count($authorsMetadata) . ' profiles for ' . count($allPubKeys) . ' pubkeys'); |
||||||
|
|
||||||
|
usort($comments, fn($a, $b) => ($b->created_at ?? 0) <=> ($a->created_at ?? 0)); |
||||||
|
// Optionally, reuse parseNostrLinks and parseZaps logic here if needed |
||||||
|
// For now, just send the raw comments array |
||||||
|
$data = [ |
||||||
|
'coordinate' => $coordinate, |
||||||
|
'comments' => $comments, |
||||||
|
'profiles' => $authorsMetadata |
||||||
|
]; |
||||||
|
try { |
||||||
|
$topic = "/comments/" . $coordinate; |
||||||
|
$update = new Update($topic, json_encode($data), false); |
||||||
|
$this->logger->info('Publishing comments update for coordinate: ' . $coordinate); |
||||||
|
$this->hub->publish($update); |
||||||
|
} catch (\Exception $e) { |
||||||
|
// Handle exception (log it, etc.) |
||||||
|
$this->logger->error('Error publishing comments update: ' . $e->getMessage()); |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
|
} |
||||||
Loading…
Reference in new issue