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); // 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', [ '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.', ]); } } /** * 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 */ private function processHighlights(array $events): array { $processed = []; $pubkeys = []; 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, 'profile' => null, ]; if ($highlight['pubkey']) { $pubkeys[] = $highlight['pubkey']; } $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); // Parse naddr to create preview data for NostrPreview component if ($highlight['naddr']) { $highlight['preview'] = $this->createPreviewData($highlight['naddr']); } $processed[] = $highlight; } } // Sort & dedupe by article_ref usort($processed, fn($a, $b) => $b['created_at'] <=> $a['created_at']); $uniqueHighlights = []; foreach ($processed as $highlight) { $ref = $highlight['article_ref']; if (!isset($uniqueHighlights[$ref])) { $uniqueHighlights[$ref] = $highlight; } } // Batch fetch metadata for all pubkeys (local relay first, fallback to reputable relays for missing) $uniquePubkeys = array_values(array_unique(array_filter($pubkeys, fn($p) => is_string($p) && strlen($p) === 64))); if (!empty($uniquePubkeys)) { try { $metadataMap = $this->nostrClient->getMetadataForPubkeys($uniquePubkeys, true); // map pubkey => event foreach ($uniqueHighlights as &$highlight) { $pubkey = $highlight['pubkey']; if ($pubkey && isset($metadataMap[$pubkey])) { $metaEvent = $metadataMap[$pubkey]; // Parse metadata JSON content $profile = null; if (isset($metaEvent->content)) { $decoded = json_decode($metaEvent->content, true); if (is_array($decoded)) { $profile = [ 'name' => $decoded['name'] ?? ($decoded['display_name'] ?? null), 'display_name' => $decoded['display_name'] ?? null, 'picture' => $decoded['picture'] ?? null, 'banner' => $decoded['banner'] ?? null, 'about' => $decoded['about'] ?? null, 'website' => $decoded['website'] ?? null, 'nip05' => $decoded['nip05'] ?? null, ]; } } $highlight['profile'] = $profile; } } unset($highlight); } catch (\Throwable $e) { $this->logger->warning('Failed batch metadata enrichment for highlights', [ 'error' => $e->getMessage(), 'pubkeys_count' => count($uniquePubkeys) ]); } } return $uniqueHighlights; } /** * 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; } } /** * Create preview data structure for NostrPreview component */ private function createPreviewData(string $naddr): ?array { try { // Use NostrLinkParser to parse the naddr identifier $links = $this->nostrLinkParser->parseLinks("nostr:$naddr"); if (!empty($links)) { return $links[0]; } return null; } catch (\Exception $e) { $this->logger->debug('Failed to create preview data', [ 'naddr' => $naddr, 'error' => $e->getMessage() ]); return null; } } }