Browse Source

Highlights

imwald
Nuša Pukšič 2 months ago
parent
commit
325a18b440
  1. 1
      assets/app.js
  2. 112
      assets/styles/04-pages/highlights.css
  3. 179
      src/Controller/HighlightsController.php
  4. 56
      src/Service/NostrClient.php
  5. 5
      src/Twig/Filters.php
  6. 3
      templates/layout.html.twig
  7. 98
      templates/pages/highlights.html.twig

1
assets/app.js

@ -43,6 +43,7 @@ import './styles/04-pages/admin.css';
import './styles/04-pages/analytics.css'; import './styles/04-pages/analytics.css';
import './styles/04-pages/author-media.css'; import './styles/04-pages/author-media.css';
import './styles/04-pages/forum.css'; import './styles/04-pages/forum.css';
import './styles/04-pages/highlights.css';
// 05 - Utilities (last for highest specificity) // 05 - Utilities (last for highest specificity)
import './styles/05-utilities/utilities.css'; import './styles/05-utilities/utilities.css';

112
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;
}
}

179
src/Controller/HighlightsController.php

@ -0,0 +1,179 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Service\NostrClient;
use nostriphant\NIP19\Bech32;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
class HighlightsController extends AbstractController
{
private const CACHE_TTL = 3600; // 1 hour in seconds
private const MAX_DISPLAY_HIGHLIGHTS = 50;
public function __construct(
private readonly NostrClient $nostrClient,
private readonly LoggerInterface $logger,
) {}
#[Route('/highlights', name: 'highlights')]
public function index(CacheInterface $cache): Response
{
try {
// Cache key for highlights
$cacheKey = 'global_article_highlights';
// $cache->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;
}
}
}

56
src/Service/NostrClient.php

@ -939,6 +939,62 @@ class NostrClient
return $articlesMap; 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 private function createNostrRequest(array $kinds, array $filters = [], ?RelaySet $relaySet = null, $stopGap = null ): TweakedRequest
{ {
$subscription = new Subscription(); $subscription = new Subscription();

5
src/Twig/Filters.php

@ -5,16 +5,11 @@ declare(strict_types=1);
namespace App\Twig; namespace App\Twig;
use App\Entity\Article; use App\Entity\Article;
use App\Entity\Event as EventEntity;
use BitWasp\Bech32\Exception\Bech32Exception; use BitWasp\Bech32\Exception\Bech32Exception;
use Exception; use Exception;
use swentel\nostr\Event\Event; use swentel\nostr\Event\Event;
use swentel\nostr\Key\Key; use swentel\nostr\Key\Key;
use swentel\nostr\Nip19\Nip19Helper; 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\Extension\AbstractExtension;
use Twig\TwigFilter; use Twig\TwigFilter;

3
templates/layout.html.twig

@ -20,6 +20,9 @@
<li> <li>
<a href="{{ path('media-discovery') }}">Multimedia</a> <a href="{{ path('media-discovery') }}">Multimedia</a>
</li> </li>
<li>
<a href="{{ path('highlights') }}">Highlights</a>
</li>
<li> <li>
<a href="{{ path('lists') }}">Lists</a> <a href="{{ path('lists') }}">Lists</a>
</li> </li>

98
templates/pages/highlights.html.twig

@ -0,0 +1,98 @@
{% extends 'layout.html.twig' %}
{% block body %}
<twig:Atoms:PageHeading
heading="Article Highlights"
tagline="Noteworthy passages highlighted by the community"
/>
<div class="highlights-container w-container">
{% if error is defined %}
<div class="error-message">
<p>{{ error }}</p>
</div>
{% endif %}
{% if highlights|length > 0 %}
<div class="highlights-stats hidden">
<span><strong>{{ total }}</strong> highlights found</span>
<span>From the last 7 days</span>
</div>
<div class="highlights-grid">
{% for highlight in highlights %}
<div class="highlight-card">
<div class="highlight-header">
<div class="highlight-author">
{% if highlight.pubkey %}
<twig:Molecules:UserFromNpub
ident="{{ highlight.pubkey }}"
:compact="true"
/>
{% else %}
<span>Anonymous</span>
{% endif %}
</div>
<div class="highlight-date">
{{ highlight.created_at|date('M j, Y') }}
</div>
</div>
<div class="highlight-content">
{% 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 = '<span class="highlight-mark">' ~ highlight.content ~ '</span>' %}
{# Replace occurrences in rendered HTML and output raw HTML #}
{% set rendered = htmlContext|replace({ (highlight.content): wrapper }) %}
{{ rendered|raw }}
{% else %}
<div class="context-text">{{ htmlContext|raw }}</div>
<div><span class="highlight-mark">{{ highlight.content }}</span></div>
{% endif %}
{% else %}
<span class="highlight-mark">{{ highlight.content }}</span>
{% endif %}
</div>
<div class="highlight-footer">
{% if highlight.naddr is defined and highlight.naddr %}
{# Use naddr to link to article so it gets fetched from relays #}
<a href="{{ path('article-naddr', {naddr: highlight.naddr}) }}"
class="article-reference">
{% if highlight.article_title %}
{{ highlight.article_title }}
{% else %}
View original article
{% endif %}
</a>
{% elseif highlight.article_ref %}
{# Fallback: show article reference but no link if naddr generation failed #}
<span class="article-reference" style="opacity: 0.6; cursor: default;">
{% if highlight.article_title %}
{{ highlight.article_title }}
{% else %}
Article reference: {{ highlight.article_ref }}
{% endif %}
</span>
{% elseif highlight.url %}
<a href="{{ highlight.url }}"
class="article-reference"
target="_blank"
rel="noopener noreferrer">
View source
</a>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="no-highlights">
<p>No highlights found. Check back later!</p>
</div>
{% endif %}
</div>
{% endblock %}
Loading…
Cancel
Save