From 325a18b440d90d957c0ff7674c4b9a1692f5039b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nu=C5=A1a=20Puk=C5=A1i=C4=8D?= Date: Mon, 3 Nov 2025 20:26:31 +0100 Subject: [PATCH] Highlights --- assets/app.js | 1 + assets/styles/04-pages/highlights.css | 112 +++++++++++++++ src/Controller/HighlightsController.php | 179 ++++++++++++++++++++++++ src/Service/NostrClient.php | 56 ++++++++ src/Twig/Filters.php | 5 - templates/layout.html.twig | 3 + templates/pages/highlights.html.twig | 98 +++++++++++++ 7 files changed, 449 insertions(+), 5 deletions(-) create mode 100644 assets/styles/04-pages/highlights.css create mode 100644 src/Controller/HighlightsController.php create mode 100644 templates/pages/highlights.html.twig diff --git a/assets/app.js b/assets/app.js index 0772389..a5c9e0a 100644 --- a/assets/app.js +++ b/assets/app.js @@ -43,6 +43,7 @@ import './styles/04-pages/admin.css'; import './styles/04-pages/analytics.css'; import './styles/04-pages/author-media.css'; import './styles/04-pages/forum.css'; +import './styles/04-pages/highlights.css'; // 05 - Utilities (last for highest specificity) import './styles/05-utilities/utilities.css'; diff --git a/assets/styles/04-pages/highlights.css b/assets/styles/04-pages/highlights.css new file mode 100644 index 0000000..512960d --- /dev/null +++ b/assets/styles/04-pages/highlights.css @@ -0,0 +1,112 @@ +.highlights-container { + max-width: 1200px; + margin: 0 auto; + padding: 2rem 1rem; +} + +.highlights-grid { + column-count: 3; + column-gap: 2rem; + margin-top: 2rem; +} + +.highlight-card { + background: var(--color-bg-secondary, #f9f9f9); + padding: var(--spacing-3); + transition: transform 0.2s; + margin-bottom: 2rem; + display: inline-block; + width: 100%; +} + +.highlight-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; + padding-bottom: 0.75rem; +} + +.highlight-author { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.9rem; + color: var(--color-text-secondary, #666); +} + +.highlight-date { + font-size: 0.85rem; + color: var(--color-text-muted, #999); +} + +.highlight-content { + font-size: 1.1rem; + line-height: 1.3; + color: var(--color-text-primary, #333); +} + +.highlight-mark { + background: var(--color-accent-300); + padding: 0.1em 0.2em; + font-weight: 500; +} +.context-text { + margin-bottom: 0.5rem; + font-style: normal; +} + +.highlight-footer { + margin-top: 1rem; + padding-top: 1rem; +} + +.article-reference { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.9rem; + color: var(--color-link, #0066cc); + text-decoration: none; + transition: color 0.2s; +} + +.article-reference:hover { + color: var(--color-link-hover, #004499); + text-decoration: underline; +} + +.no-highlights { + text-align: center; + padding: 4rem 2rem; + color: var(--color-text-secondary, #666); + font-size: 1.1rem; +} + +.error-message { + background: #fee; + border: 1px solid #fcc; + padding: 1rem; + margin: 1rem 0; + color: #c00; +} +.highlights-stats { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem; + background: var(--color-bg-secondary, #f9f9f9); + margin-bottom: 1.5rem; +} + +@media (max-width: 768px) { + .highlight-card { + margin-bottom: 1.5rem; + } +} + +@media (min-width: 769px) and (max-width: 1200px) { + .highlights-grid { + column-count: 2; + } +} diff --git a/src/Controller/HighlightsController.php b/src/Controller/HighlightsController.php new file mode 100644 index 0000000..daa77e4 --- /dev/null +++ b/src/Controller/HighlightsController.php @@ -0,0 +1,179 @@ +delete($cacheKey); + // Get highlights from cache or fetch fresh + $highlights = $cache->get($cacheKey, function (ItemInterface $item) { + $item->expiresAfter(self::CACHE_TTL); + + try { + // Fetch highlights that reference articles (kind 30023) + $events = $this->nostrClient->getArticleHighlights(self::MAX_DISPLAY_HIGHLIGHTS); + + // Process and enrich the highlights + return $this->processHighlights($events); + } catch (\Exception $e) { + $this->logger->error('Failed to fetch highlights', [ + 'error' => $e->getMessage() + ]); + return []; + } + }); + + return $this->render('pages/highlights.html.twig', [ + 'highlights' => $highlights, + 'total' => count($highlights), + ]); + + } catch (\Exception $e) { + $this->logger->error('Error loading highlights page', [ + 'error' => $e->getMessage() + ]); + + return $this->render('pages/highlights.html.twig', [ + 'highlights' => [], + 'total' => 0, + 'error' => 'Unable to load highlights at this time. Please try again later.', + ]); + } + } + + /** + * Process highlights to extract metadata + */ + private function processHighlights(array $events): array + { + $processed = []; + + foreach ($events as $event) { + $highlight = [ + 'id' => $event->id ?? null, + 'content' => $event->content ?? '', + 'created_at' => $event->created_at ?? time(), + 'pubkey' => $event->pubkey ?? null, + 'tags' => $event->tags ?? [], + 'article_ref' => null, + 'article_title' => null, + 'article_author' => null, + 'context' => null, + 'url' => null, + 'naddr' => null, + ]; + + $relayHints = []; + + // Extract metadata from tags + foreach ($event->tags ?? [] as $tag) { + if (!is_array($tag) || count($tag) < 2) { + continue; + } + + switch ($tag[0]) { + case 'a': // Article reference (kind:pubkey:identifier) + case 'A': + $highlight['article_ref'] = $tag[1] ?? null; + // Get relay hint if available + if (isset($tag[2]) && str_starts_with($tag[2], 'wss://')) { + $relayHints[] = $tag[2]; + } + // Parse to check if it's an article (kind 30023) + $parts = explode(':', $tag[1] ?? '', 3); + if (count($parts) === 3 && $parts[0] === '30023') { + $highlight['article_author'] = $parts[1]; + } + break; + case 'context': + $highlight['context'] = $tag[1] ?? null; + break; + case 'r': // URL reference + if (!$highlight['url']) { + $highlight['url'] = $tag[1] ?? null; + } + // Also collect relay hints from r tags + if (isset($tag[1]) && str_starts_with($tag[1], 'wss://')) { + $relayHints[] = $tag[1]; + } + break; + case 'title': + $highlight['article_title'] = $tag[1] ?? null; + break; + } + } + + // Only include highlights that reference articles (kind 30023) + if ($highlight['article_ref'] && str_starts_with($highlight['article_ref'], '30023:')) { + // Generate naddr from the coordinate + $highlight['naddr'] = $this->generateNaddr($highlight['article_ref'], $relayHints); + $processed[] = $highlight; + } + } + + // Sort by created_at descending (newest first) + usort($processed, fn($a, $b) => $b['created_at'] <=> $a['created_at']); + + return $processed; + } + + /** + * Generate naddr from coordinate (kind:pubkey:identifier) and relay hints + */ + private function generateNaddr(string $coordinate, array $relayHints = []): ?string + { + $parts = explode(':', $coordinate, 3); + if (count($parts) !== 3) { + $this->logger->debug('Invalid coordinate format', ['coordinate' => $coordinate]); + return null; + } + + try { + $kind = (int)$parts[0]; + $pubkey = $parts[1]; + $identifier = $parts[2]; + + $naddr = Bech32::naddr( + kind: $kind, + pubkey: $pubkey, + identifier: $identifier, + relays: $relayHints + ); + + return (string)$naddr; + + } catch (\Throwable $e) { + $this->logger->warning('Failed to generate naddr', [ + 'coordinate' => $coordinate, + 'error' => $e->getMessage() + ]); + return null; + } + } +} + diff --git a/src/Service/NostrClient.php b/src/Service/NostrClient.php index e14453b..81a0b6d 100644 --- a/src/Service/NostrClient.php +++ b/src/Service/NostrClient.php @@ -939,6 +939,62 @@ class NostrClient return $articlesMap; } + /** + * Get article highlights (NIP-84) - kind 9802 events that reference articles + * @throws \Exception + */ + public function getArticleHighlights(int $limit = 50): array + { + $this->logger->info('Fetching article highlights from default relays'); + + // Use relay pool to send request + $subscription = new Subscription(); + $subscriptionId = $subscription->setId(); + $filter = new Filter(); + $filter->setKinds([9802]); // NIP-84 highlights + $filter->setLimit($limit); + $filter->setSince(strtotime('-7 days')); // Last 7 days + + $requestMessage = new RequestMessage($subscriptionId, [$filter]); + + // Get default relay URLs + $relayUrls = $this->relayPool->getDefaultRelays(); + + // Use the relay pool to send the request + $responses = $this->relayPool->sendToRelays( + $relayUrls, + fn() => $requestMessage, + 30, + $subscriptionId + ); + + // Process the response and deduplicate by eventId + $uniqueEvents = []; + $this->processResponse($responses, function($event) use (&$uniqueEvents) { + // Filter to only include highlights that reference articles + $hasArticleRef = false; + foreach ($event->tags ?? [] as $tag) { + if (is_array($tag) && count($tag) >= 2) { + if (in_array($tag[0], ['a', 'A'])) { + // Check if it references a kind 30023 (article) + if (str_starts_with($tag[1] ?? '', '30023:')) { + $hasArticleRef = true; + break; + } + } + } + } + + if ($hasArticleRef) { + $this->logger->debug('Received article highlight event', ['event_id' => $event->id]); + $uniqueEvents[$event->id] = $event; + } + return null; + }); + + return array_values($uniqueEvents); + } + private function createNostrRequest(array $kinds, array $filters = [], ?RelaySet $relaySet = null, $stopGap = null ): TweakedRequest { $subscription = new Subscription(); diff --git a/src/Twig/Filters.php b/src/Twig/Filters.php index 8c5e0a6..02207fe 100644 --- a/src/Twig/Filters.php +++ b/src/Twig/Filters.php @@ -5,16 +5,11 @@ declare(strict_types=1); namespace App\Twig; use App\Entity\Article; -use App\Entity\Event as EventEntity; use BitWasp\Bech32\Exception\Bech32Exception; use Exception; use swentel\nostr\Event\Event; use swentel\nostr\Key\Key; use swentel\nostr\Nip19\Nip19Helper; -use Symfony\Component\Serializer\Encoder\JsonEncoder; -use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer; -use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; -use Symfony\Component\Serializer\Serializer; use Twig\Extension\AbstractExtension; use Twig\TwigFilter; diff --git a/templates/layout.html.twig b/templates/layout.html.twig index 2b695d4..4f28af6 100644 --- a/templates/layout.html.twig +++ b/templates/layout.html.twig @@ -20,6 +20,9 @@
  • Multimedia
  • +
  • + Highlights +
  • Lists
  • diff --git a/templates/pages/highlights.html.twig b/templates/pages/highlights.html.twig new file mode 100644 index 0000000..7f348b1 --- /dev/null +++ b/templates/pages/highlights.html.twig @@ -0,0 +1,98 @@ +{% extends 'layout.html.twig' %} + +{% block body %} + + +
    + {% if error is defined %} +
    +

    {{ error }}

    +
    + {% endif %} + + {% if highlights|length > 0 %} + + +
    + {% for highlight in highlights %} +
    +
    +
    + {% if highlight.pubkey %} + + {% else %} + Anonymous + {% endif %} +
    +
    + {{ highlight.created_at|date('M j, Y') }} +
    +
    + +
    + {% if highlight.context %} + {# Render markdown to HTML first, then wrap highlight substring in a span. #} + {% set htmlContext = highlight.context|markdown_to_html %} + {% if highlight.content in highlight.context %} + {% set wrapper = '' ~ highlight.content ~ '' %} + {# Replace occurrences in rendered HTML and output raw HTML #} + {% set rendered = htmlContext|replace({ (highlight.content): wrapper }) %} + {{ rendered|raw }} + {% else %} +
    {{ htmlContext|raw }}
    +
    {{ highlight.content }}
    + {% endif %} + {% else %} + {{ highlight.content }} + {% endif %} +
    + + +
    + {% endfor %} +
    + {% else %} +
    +

    No highlights found. Check back later!

    +
    + {% endif %} +
    +{% endblock %} +