Browse Source

Highlights

imwald
Nuša Pukšič 2 months ago
parent
commit
0696b63d95
  1. 90
      assets/controllers/highlights_toggle_controller.js
  2. 33
      assets/styles/04-pages/highlights.css
  3. 37
      migrations/Version20251107102457.php
  4. 20
      src/Controller/ArticleController.php
  5. 53
      src/Controller/HighlightsController.php
  6. 146
      src/Entity/Highlight.php
  7. 98
      src/Repository/HighlightRepository.php
  8. 268
      src/Service/HighlightService.php
  9. 41
      src/Service/NostrClient.php
  10. 24
      templates/pages/article.html.twig

90
assets/controllers/highlights_toggle_controller.js

@ -0,0 +1,90 @@ @@ -0,0 +1,90 @@
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
static targets = ['button'];
connect() {
this.articleMain = this.element.querySelector('.article-main');
if (!this.articleMain) return;
const highlightsData = this.articleMain.getAttribute('data-highlights');
if (highlightsData) {
try {
this.highlights = JSON.parse(highlightsData);
this.applyHighlights();
} catch (e) {
console.error('Failed to parse highlights data:', e);
}
}
// Check if user has a saved preference
const enabled = localStorage.getItem('highlights-enabled') === 'true';
this.setHighlightsVisibility(enabled);
}
applyHighlights() {
if (!this.highlights || this.highlights.length === 0) return;
// Get all text nodes in the article
const walker = document.createTreeWalker(
this.articleMain,
NodeFilter.SHOW_TEXT,
null,
false
);
const textNodes = [];
let node;
while (node = walker.nextNode()) {
// Skip if parent is already a highlight mark
if (node.parentElement.classList.contains('article-highlight')) {
continue;
}
textNodes.push(node);
}
// For each highlight, find and wrap matching text
this.highlights.forEach((highlight, index) => {
const searchText = highlight.content.trim();
if (!searchText) return;
textNodes.forEach(textNode => {
const text = textNode.textContent;
const startIndex = text.indexOf(searchText);
if (startIndex !== -1) {
// Split the text node and wrap the match
const range = document.createRange();
range.setStart(textNode, startIndex);
range.setEnd(textNode, startIndex + searchText.length);
const mark = document.createElement('mark');
mark.className = 'article-highlight';
mark.setAttribute('data-highlights-toggle-target', 'highlight');
mark.setAttribute('title', 'Highlighted by others (' + new Date(highlight.created_at * 1000).toLocaleDateString() + ')');
try {
range.surroundContents(mark);
} catch (e) {
// If surroundContents fails (e.g., spans multiple nodes), try extraction
try {
const extracted = range.extractContents();
mark.appendChild(extracted);
range.insertNode(mark);
} catch (e2) {
console.warn('Failed to highlight text:', searchText, e2);
}
}
}
});
});
}
toggle() {
if (!this.hasButtonTarget) return;
const currentlyEnabled = this.buttonTarget.getAttribute('aria-pressed') === 'true';
const newState = !currentlyEnabled;
this.setHighlightsVisibility(newState);
// Save preference
localStorage.setItem('highlights-enabled', newState.toString());
}
setHighlightsVisibility(enabled) {
if (!this.hasButtonTarget) return;
this.buttonTarget.setAttribute('aria-pressed', enabled.toString());
const highlightElements = this.element.querySelectorAll('.article-highlight');
if (enabled) {
this.buttonTarget.classList.add('active');
highlightElements.forEach(el => el.classList.add('visible'));
} else {
this.buttonTarget.classList.remove('active');
highlightElements.forEach(el => el.classList.remove('visible'));
}
}
}

33
assets/styles/04-pages/highlights.css

@ -99,6 +99,39 @@ @@ -99,6 +99,39 @@
margin-bottom: 1.5rem;
}
/* Article inline highlights */
.article-highlight {
background: transparent;
border-bottom: 2px solid transparent;
padding: 0.1em 0;
transition: all 0.3s ease;
cursor: help;
}
.article-highlight.visible {
background: linear-gradient(to bottom,
rgba(255, 237, 74, 0.3) 0%,
rgba(255, 237, 74, 0.5) 100%);
border-bottom: 2px solid rgba(255, 200, 0, 0.6);
}
.article-highlight.visible:hover {
background: linear-gradient(to bottom,
rgba(255, 237, 74, 0.5) 0%,
rgba(255, 237, 74, 0.7) 100%);
border-bottom-color: rgba(255, 200, 0, 0.8);
}
/* Toggle button active state */
.btn[aria-pressed="true"] {
background-color: rgba(255, 237, 74, 0.3);
border-color: rgba(255, 200, 0, 0.6);
}
.btn[aria-pressed="true"]:hover {
background-color: rgba(255, 237, 74, 0.5);
}
@media (max-width: 768px) {
.highlights-grid {
column-count: 1;

37
migrations/Version20251107102457.php

@ -0,0 +1,37 @@ @@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20251107102457 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE highlight (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, event_id VARCHAR(255) NOT NULL, article_coordinate TEXT NOT NULL, content TEXT NOT NULL, pubkey VARCHAR(255) NOT NULL, created_at INT NOT NULL, context TEXT DEFAULT NULL, cached_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, raw_event JSON DEFAULT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE UNIQUE INDEX UNIQ_C998D83471F7E88B ON highlight (event_id)');
$this->addSql('CREATE INDEX idx_article_coordinate ON highlight (article_coordinate)');
$this->addSql('CREATE INDEX idx_event_id ON highlight (event_id)');
$this->addSql('CREATE INDEX idx_created_at ON highlight (created_at)');
$this->addSql('COMMENT ON COLUMN nzine.last_fetched_at IS \'\'');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('DROP TABLE highlight');
$this->addSql('COMMENT ON COLUMN nzine.last_fetched_at IS \'(DC2Type:datetime_immutable)\'');
}
}

20
src/Controller/ArticleController.php

@ -6,6 +6,7 @@ use App\Dto\AdvancedMetadata; @@ -6,6 +6,7 @@ use App\Dto\AdvancedMetadata;
use App\Entity\Article;
use App\Enum\KindsEnum;
use App\Form\EditorType;
use App\Service\HighlightService;
use App\Service\NostrClient;
use App\Service\Nostr\NostrEventBuilder;
use App\Service\Nostr\NostrEventParser;
@ -72,7 +73,8 @@ class ArticleController extends AbstractController @@ -72,7 +73,8 @@ class ArticleController extends AbstractController
EntityManagerInterface $entityManager,
RedisCacheService $redisCacheService,
CacheItemPoolInterface $articlesCache,
Converter $converter
Converter $converter,
HighlightService $highlightService
): Response
{
@ -126,13 +128,27 @@ class ArticleController extends AbstractController @@ -126,13 +128,27 @@ class ArticleController extends AbstractController
$canonical = $this->generateUrl('article-slug', ['slug' => $article->getSlug()], 0);
// Fetch highlights using the caching service
$highlights = [];
try {
$articleCoordinate = '30023:' . $article->getPubkey() . ':' . $article->getSlug();
error_log('ArticleController: Looking for highlights with coordinate: ' . $articleCoordinate);
$highlights = $highlightService->getHighlightsForArticle($articleCoordinate);
error_log('ArticleController: Found ' . count($highlights) . ' highlights');
} catch (\Exception $e) {
// Log but don't fail the page if highlights can't be fetched
// Highlights are optional enhancement
error_log('ArticleController: Failed to fetch highlights: ' . $e->getMessage());
}
return $this->render('pages/article.html.twig', [
'article' => $article,
'author' => $author,
'npub' => $npub,
'content' => $cacheItem->get(),
'canEdit' => $canEdit,
'canonical' => $canonical
'canonical' => $canonical,
'highlights' => $highlights
]);
}

53
src/Controller/HighlightsController.php

@ -4,6 +4,7 @@ declare(strict_types=1); @@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Controller;
use App\Service\HighlightService;
use App\Service\NostrClient;
use nostriphant\NIP19\Bech32;
use Psr\Log\LoggerInterface;
@ -20,6 +21,7 @@ class HighlightsController extends AbstractController @@ -20,6 +21,7 @@ class HighlightsController extends AbstractController
public function __construct(
private readonly NostrClient $nostrClient,
private readonly HighlightService $highlightService,
private readonly LoggerInterface $logger,
) {}
@ -38,7 +40,10 @@ class HighlightsController extends AbstractController @@ -38,7 +40,10 @@ class HighlightsController extends AbstractController
// Fetch highlights that reference articles (kind 30023)
$events = $this->nostrClient->getArticleHighlights(self::MAX_DISPLAY_HIGHLIGHTS);
// Process and enrich the highlights
// Save raw events to database first (group by article)
$this->saveHighlightsToDatabase($events);
// Process and enrich the highlights for display
return $this->processHighlights($events);
} catch (\Exception $e) {
$this->logger->error('Failed to fetch highlights', [
@ -66,6 +71,52 @@ class HighlightsController extends AbstractController @@ -66,6 +71,52 @@ class HighlightsController extends AbstractController
}
}
/**
* Save highlights to database grouped by article coordinate
*/
private function saveHighlightsToDatabase(array $events): void
{
// Group events by article coordinate
$eventsByArticle = [];
foreach ($events as $event) {
// Extract article coordinate from tags
foreach ($event->tags ?? [] as $tag) {
if (!is_array($tag) || count($tag) < 2) {
continue;
}
if (in_array($tag[0], ['a', 'A'])) {
$coordinate = $tag[1] ?? null;
if ($coordinate && str_starts_with($coordinate, '30023:')) {
if (!isset($eventsByArticle[$coordinate])) {
$eventsByArticle[$coordinate] = [];
}
$eventsByArticle[$coordinate][] = $event;
break;
}
}
}
}
// Save each article's highlights to database
foreach ($eventsByArticle as $coordinate => $articleEvents) {
try {
$this->highlightService->saveEventsToDatabase($coordinate, $articleEvents);
$this->logger->debug('Saved highlights to database', [
'coordinate' => $coordinate,
'count' => count($articleEvents)
]);
} catch (\Exception $e) {
$this->logger->warning('Failed to save highlights to database', [
'coordinate' => $coordinate,
'error' => $e->getMessage()
]);
}
}
}
/**
* Process highlights to extract metadata
*/

146
src/Entity/Highlight.php

@ -0,0 +1,146 @@ @@ -0,0 +1,146 @@
<?php
namespace App\Entity;
use App\Repository\HighlightRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
/**
* Entity storing article highlights (NIP-84, kind 9802)
* Cached from Nostr relays for performance
*/
#[ORM\Entity(repositoryClass: HighlightRepository::class)]
#[ORM\Index(columns: ['article_coordinate'], name: 'idx_article_coordinate')]
#[ORM\Index(columns: ['event_id'], name: 'idx_event_id')]
#[ORM\Index(columns: ['created_at'], name: 'idx_created_at')]
class Highlight
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255, unique: true)]
private ?string $eventId = null;
#[ORM\Column(type: Types::TEXT)]
private ?string $articleCoordinate = null;
#[ORM\Column(type: Types::TEXT)]
private ?string $content = null;
#[ORM\Column(length: 255)]
private ?string $pubkey = null;
#[ORM\Column(type: Types::INTEGER)]
private ?int $createdAt = null;
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $context = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
private ?\DateTimeImmutable $cachedAt = null;
#[ORM\Column(type: Types::JSON, nullable: true)]
private ?array $rawEvent = null;
public function __construct()
{
$this->cachedAt = new \DateTimeImmutable();
}
public function getId(): ?int
{
return $this->id;
}
public function getEventId(): ?string
{
return $this->eventId;
}
public function setEventId(string $eventId): static
{
$this->eventId = $eventId;
return $this;
}
public function getArticleCoordinate(): ?string
{
return $this->articleCoordinate;
}
public function setArticleCoordinate(string $articleCoordinate): static
{
$this->articleCoordinate = $articleCoordinate;
return $this;
}
public function getContent(): ?string
{
return $this->content;
}
public function setContent(string $content): static
{
$this->content = $content;
return $this;
}
public function getPubkey(): ?string
{
return $this->pubkey;
}
public function setPubkey(string $pubkey): static
{
$this->pubkey = $pubkey;
return $this;
}
public function getCreatedAt(): ?int
{
return $this->createdAt;
}
public function setCreatedAt(int $createdAt): static
{
$this->createdAt = $createdAt;
return $this;
}
public function getContext(): ?string
{
return $this->context;
}
public function setContext(?string $context): static
{
$this->context = $context;
return $this;
}
public function getCachedAt(): ?\DateTimeImmutable
{
return $this->cachedAt;
}
public function setCachedAt(\DateTimeImmutable $cachedAt): static
{
$this->cachedAt = $cachedAt;
return $this;
}
public function getRawEvent(): ?array
{
return $this->rawEvent;
}
public function setRawEvent(?array $rawEvent): static
{
$this->rawEvent = $rawEvent;
return $this;
}
}

98
src/Repository/HighlightRepository.php

@ -0,0 +1,98 @@ @@ -0,0 +1,98 @@
<?php
namespace App\Repository;
use App\Entity\Highlight;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class HighlightRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Highlight::class);
}
/**
* Find highlights for a specific article coordinate
*/
public function findByArticleCoordinate(string $articleCoordinate): array
{
$qb = $this->createQueryBuilder('h')
->where('h.articleCoordinate = :coordinate')
->setParameter('coordinate', $articleCoordinate)
->orderBy('h.createdAt', 'DESC');
// Debug: log the query and parameters
error_log('Querying highlights for coordinate: ' . $articleCoordinate);
error_log('SQL: ' . $qb->getQuery()->getSQL());
return $qb->getQuery()->getResult();
}
/**
* Debug: Get all unique article coordinates in the database
*/
public function getAllArticleCoordinates(): array
{
$results = $this->createQueryBuilder('h')
->select('DISTINCT h.articleCoordinate')
->getQuery()
->getResult();
return array_map(fn($r) => $r['articleCoordinate'], $results);
}
/**
* Check if we need to refresh highlights for an article
* Returns true if cache is older than the specified hours
*/
public function needsRefresh(string $articleCoordinate, int $hoursOld = 24): bool
{
$cutoff = new \DateTimeImmutable("-{$hoursOld} hours");
$count = $this->createQueryBuilder('h')
->select('COUNT(h.id)')
->where('h.articleCoordinate = :coordinate')
->andWhere('h.cachedAt > :cutoff')
->setParameter('coordinate', $articleCoordinate)
->setParameter('cutoff', $cutoff)
->getQuery()
->getSingleScalarResult();
return $count === 0;
}
/**
* Get the most recent cache time for an article
*/
public function getLastCacheTime(string $articleCoordinate): ?\DateTimeImmutable
{
$result = $this->createQueryBuilder('h')
->select('h.cachedAt')
->where('h.articleCoordinate = :coordinate')
->setParameter('coordinate', $articleCoordinate)
->orderBy('h.cachedAt', 'DESC')
->setMaxResults(1)
->getQuery()
->getOneOrNullResult();
return $result ? $result['cachedAt'] : null;
}
/**
* Delete old highlights for an article (before refreshing)
*/
public function deleteOldHighlights(string $articleCoordinate, \DateTimeImmutable $before): int
{
return $this->createQueryBuilder('h')
->delete()
->where('h.articleCoordinate = :coordinate')
->andWhere('h.cachedAt < :before')
->setParameter('coordinate', $articleCoordinate)
->setParameter('before', $before)
->getQuery()
->execute();
}
}

268
src/Service/HighlightService.php

@ -0,0 +1,268 @@ @@ -0,0 +1,268 @@
<?php
namespace App\Service;
use App\Entity\Highlight;
use App\Repository\HighlightRepository;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
class HighlightService
{
private const CACHE_DURATION_HOURS = 24; // Refresh every 24 hours
private const TOP_OFF_DURATION_HOURS = 1; // Top off every hour
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly HighlightRepository $highlightRepository,
private readonly NostrClient $nostrClient,
private readonly LoggerInterface $logger
) {}
/**
* Get highlights for an article, using cache when possible
*/
public function getHighlightsForArticle(string $articleCoordinate): array
{
$this->logger->info('Getting highlights for article', [
'coordinate' => $articleCoordinate,
'coordinate_length' => strlen($articleCoordinate)
]);
// Check if we need a full refresh (cache is stale)
$needsFullRefresh = $this->highlightRepository->needsRefresh($articleCoordinate, self::CACHE_DURATION_HOURS);
$this->logger->info('Cache refresh check', [
'coordinate' => $articleCoordinate,
'needs_refresh' => $needsFullRefresh
]);
if ($needsFullRefresh) {
$this->logger->info('Full refresh needed for highlights', ['coordinate' => $articleCoordinate]);
return $this->fullRefresh($articleCoordinate);
}
// Check if we should top off with recent highlights
$lastCacheTime = $this->highlightRepository->getLastCacheTime($articleCoordinate);
$shouldTopOff = false;
if ($lastCacheTime) {
$hoursSinceLastCache = (new \DateTimeImmutable())->getTimestamp() - $lastCacheTime->getTimestamp();
$shouldTopOff = ($hoursSinceLastCache / 3600) >= self::TOP_OFF_DURATION_HOURS;
}
if ($shouldTopOff) {
$this->logger->info('Topping off highlights with recent data', ['coordinate' => $articleCoordinate]);
$this->topOffHighlights($articleCoordinate);
}
// Return cached highlights
$cached = $this->getCachedHighlights($articleCoordinate);
$this->logger->info('Returning cached highlights', [
'coordinate' => $articleCoordinate,
'count' => count($cached)
]);
return $cached;
}
/**
* Get cached highlights from database
*/
private function getCachedHighlights(string $articleCoordinate): array
{
$highlights = $this->highlightRepository->findByArticleCoordinate($articleCoordinate);
$this->logger->info('Found highlights in database', [
'coordinate' => $articleCoordinate,
'count' => count($highlights)
]);
return array_map(function (Highlight $highlight) {
return [
'content' => $highlight->getContent(),
'created_at' => $highlight->getCreatedAt(),
'pubkey' => $highlight->getPubkey(),
'context' => $highlight->getContext(),
];
}, $highlights);
}
/**
* Full refresh - fetch all highlights from relays and update cache
*/
private function fullRefresh(string $articleCoordinate): array
{
try {
// Fetch from Nostr
$highlightEvents = $this->nostrClient->getHighlightsForArticle($articleCoordinate);
// Clear old cache
$cutoff = new \DateTimeImmutable('-30 days');
$this->highlightRepository->deleteOldHighlights($articleCoordinate, $cutoff);
// Save new highlights
$this->saveHighlights($articleCoordinate, $highlightEvents);
// Return the fresh data
return $this->getCachedHighlights($articleCoordinate);
} catch (\Exception $e) {
$this->logger->error('Failed to refresh highlights', [
'coordinate' => $articleCoordinate,
'error' => $e->getMessage()
]);
// Return cached data even if stale
return $this->getCachedHighlights($articleCoordinate);
}
}
/**
* Top off - fetch only recent highlights to supplement cache
*/
private function topOffHighlights(string $articleCoordinate): void
{
try {
$lastCacheTime = $this->highlightRepository->getLastCacheTime($articleCoordinate);
// Fetch recent highlights (since last cache time)
$highlightEvents = $this->nostrClient->getHighlightsForArticle($articleCoordinate, 50);
// Filter to only new events (created after last cache)
if ($lastCacheTime) {
$cutoffTimestamp = $lastCacheTime->getTimestamp();
$highlightEvents = array_filter($highlightEvents, function ($event) use ($cutoffTimestamp) {
return ($event->created_at ?? 0) > $cutoffTimestamp;
});
}
if (!empty($highlightEvents)) {
$this->logger->info('Adding new highlights to cache', [
'coordinate' => $articleCoordinate,
'count' => count($highlightEvents)
]);
$this->saveHighlights($articleCoordinate, $highlightEvents);
}
} catch (\Exception $e) {
$this->logger->warning('Failed to top off highlights', [
'coordinate' => $articleCoordinate,
'error' => $e->getMessage()
]);
// Non-critical, just log and continue
}
}
/**
* Save highlight events to database
*/
private function saveHighlights(string $articleCoordinate, array $highlightEvents): void
{
$this->logger->info('Saving highlights to database', [
'coordinate' => $articleCoordinate,
'event_count' => count($highlightEvents)
]);
$saved = 0;
$updated = 0;
foreach ($highlightEvents as $event) {
// Check if this event already exists
$existing = $this->highlightRepository->findOneBy(['eventId' => $event->id]);
if ($existing) {
// Update cached_at timestamp
$existing->setCachedAt(new \DateTimeImmutable());
$updated++;
continue;
}
// Create new highlight entity
$highlight = new Highlight();
$highlight->setEventId($event->id);
$highlight->setArticleCoordinate($articleCoordinate);
$highlight->setContent($event->content ?? '');
$highlight->setPubkey($event->pubkey ?? '');
$highlight->setCreatedAt($event->created_at ?? time());
// Extract context from tags if available
foreach ($event->tags ?? [] as $tag) {
if (is_array($tag) && count($tag) >= 2 && $tag[0] === 'context') {
$highlight->setContext($tag[1]);
break;
}
}
// Store raw event for potential future use
$highlight->setRawEvent((array) $event);
if (!empty($highlight->getContent())) {
$this->entityManager->persist($highlight);
$saved++;
$this->logger->debug('Persisting highlight', [
'event_id' => $event->id,
'coordinate' => $articleCoordinate,
'content_length' => strlen($highlight->getContent())
]);
}
}
$this->entityManager->flush();
$this->logger->info('Highlights saved to database', [
'coordinate' => $articleCoordinate,
'new_saved' => $saved,
'updated' => $updated
]);
}
/**
* Force refresh highlights for a specific article (for admin use)
*/
public function forceRefresh(string $articleCoordinate): array
{
return $this->fullRefresh($articleCoordinate);
}
/**
* Save events directly to database (used by HighlightsController)
* This is public so we can save highlights as we discover them
*/
public function saveEventsToDatabase(string $articleCoordinate, array $events): void
{
$this->saveHighlights($articleCoordinate, $events);
}
/**
* Debug method: Get all coordinates in database
*/
public function getAllStoredCoordinates(): array
{
return $this->highlightRepository->getAllArticleCoordinates();
}
/**
* Debug method: Get raw highlights from database
*/
public function getDebugInfo(string $articleCoordinate): array
{
$highlights = $this->highlightRepository->findByArticleCoordinate($articleCoordinate);
$lastCache = $this->highlightRepository->getLastCacheTime($articleCoordinate);
$needsRefresh = $this->highlightRepository->needsRefresh($articleCoordinate);
return [
'coordinate' => $articleCoordinate,
'count' => count($highlights),
'last_cache' => $lastCache?->format('Y-m-d H:i:s'),
'needs_refresh' => $needsRefresh,
'highlights' => array_map(fn($h) => [
'event_id' => $h->getEventId(),
'content' => substr($h->getContent(), 0, 100),
'pubkey' => substr($h->getPubkey(), 0, 16),
'created_at' => $h->getCreatedAt(),
'cached_at' => $h->getCachedAt()->format('Y-m-d H:i:s'),
], $highlights)
];
}
}

41
src/Service/NostrClient.php

@ -1004,6 +1004,47 @@ class NostrClient @@ -1004,6 +1004,47 @@ class NostrClient
return array_values($uniqueEvents);
}
/**
* Get highlights for a specific article
* @throws \Exception
*/
public function getHighlightsForArticle(string $articleCoordinate, int $limit = 100): array
{
$this->logger->info('Fetching highlights for article', ['coordinate' => $articleCoordinate]);
// Use relay pool to send request
$subscription = new Subscription();
$subscriptionId = $subscription->setId();
$filter = new Filter();
$filter->setKinds([9802]); // NIP-84 highlights
$filter->setLimit($limit);
// Add tag filter for the specific article coordinate
$filter->setTags(['a' => [$articleCoordinate]]);
$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) {
$this->logger->debug('Received highlight event for article', ['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();

24
templates/pages/article.html.twig

@ -16,7 +16,7 @@ @@ -16,7 +16,7 @@
<twig:Organisms:MagazineHero :mag="mag" :magazine="magazine" />
{% endif %}
<article class="w-container">
<article class="w-container" data-controller="highlights-toggle">
<div class="article-actions">
<div data-controller="share-dropdown" class="dropdown share-dropdown" style="display:inline-block;position:relative;">
<button data-share-dropdown-target="button"
@ -74,6 +74,21 @@ @@ -74,6 +74,21 @@
</small>
{% endif %}
{# Highlights toggle button #}
{% if highlights is defined and highlights|length > 0 %}
<button data-highlights-toggle-target="button"
data-action="click->highlights-toggle#toggle"
class="btn btn-secondary"
type="button"
aria-pressed="false"
title="Toggle highlights">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" style="vertical-align:middle;">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
Highlights ({{ highlights|length }})
</button>
{% endif %}
</div>
@ -94,7 +109,7 @@ @@ -94,7 +109,7 @@
{% else %}
<small>
{# <twig:ux:icon name="heroicons:pencil" class="icon" /> #}
{{ article.createdAt|date('F j, Y') }}</small><br>
{{ article.createdAt|date('F j, Y') }}</small>
{% endif %}
</span>
</div>
@ -111,7 +126,10 @@ @@ -111,7 +126,10 @@
</div>
{% endif %}
<div class="article-main">
<div class="article-main"
{% if highlights is defined and highlights|length > 0 %}
data-highlights="{{ highlights|json_encode|e('html_attr') }}"
{% endif %}>
{{ content|raw }}
</div>

Loading…
Cancel
Save