From e6ce1e7a99adf56c4688605bdd8dac1c8b6bca9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nu=C5=A1a=20Puk=C5=A1i=C4=8D?= Date: Thu, 11 Dec 2025 11:48:55 +0100 Subject: [PATCH] Follows --- src/Controller/EventController.php | 18 +++- src/Controller/FollowsController.php | 149 +++++++++++++++++++++++++++ src/Service/NostrClient.php | 56 ++++++++++ 3 files changed, 222 insertions(+), 1 deletion(-) create mode 100644 src/Controller/FollowsController.php diff --git a/src/Controller/EventController.php b/src/Controller/EventController.php index 1d0d9de..6176318 100644 --- a/src/Controller/EventController.php +++ b/src/Controller/EventController.php @@ -94,11 +94,27 @@ class EventController extends AbstractController $authorMetadata = $redisCacheService->getMetadata($event->pubkey); + // Batch fetch profiles for follow pack events (kind 39089) + $followPackProfiles = []; + if (isset($event->kind) && $event->kind == 39089 && isset($event->tags)) { + $pubkeys = []; + foreach ($event->tags as $tag) { + if (is_array($tag) && $tag[0] === 'p' && isset($tag[1])) { + $pubkeys[] = $tag[1]; + } + } + if (!empty($pubkeys)) { + $logger->info('Batch fetching follow pack profiles', ['count' => count($pubkeys)]); + $followPackProfiles = $redisCacheService->getMultipleMetadata($pubkeys); + } + } + // Render template with the event data and extracted Nostr links $response = $this->render('event/index.html.twig', [ 'event' => $event, 'author' => $authorMetadata, - 'nostrLinks' => $nostrLinks + 'nostrLinks' => $nostrLinks, + 'followPackProfiles' => $followPackProfiles ]); // Add HTTP caching headers for request-level caching diff --git a/src/Controller/FollowsController.php b/src/Controller/FollowsController.php new file mode 100644 index 0000000..e09fa21 --- /dev/null +++ b/src/Controller/FollowsController.php @@ -0,0 +1,149 @@ +getUser(); + + // If user is not logged in, show a notice + if (!$user) { + return $this->render('follows/index.html.twig', [ + 'isLoggedIn' => false, + 'articles' => [], + 'authorsMetadata' => [], + ]); + } + + // Get user's pubkey in hex format + $pubkeyHex = null; + try { + $key = new Key(); + $pubkeyHex = $key->convertToHex($user->getUserIdentifier()); + } catch (\Throwable $e) { + $logger->error('Failed to convert user npub to hex', [ + 'error' => $e->getMessage(), + 'npub' => $user->getUserIdentifier() + ]); + + return $this->render('follows/index.html.twig', [ + 'isLoggedIn' => true, + 'articles' => [], + 'authorsMetadata' => [], + 'error' => 'Unable to process user credentials' + ]); + } + + // Fetch the user's follow list from relays using NostrClient + $followedPubkeys = []; + try { + $followedPubkeys = $nostrClient->getUserFollows($pubkeyHex); + $logger->info('Fetched follow list from relays', [ + 'user_pubkey' => $pubkeyHex, + 'follows_count' => count($followedPubkeys) + ]); + } catch (\Throwable $e) { + $logger->error('Failed to fetch follow list from relays', [ + 'error' => $e->getMessage(), + 'pubkey' => $pubkeyHex + ]); + + return $this->render('follows/index.html.twig', [ + 'isLoggedIn' => true, + 'articles' => [], + 'authorsMetadata' => [], + 'error' => 'Unable to fetch your follow list from relays' + ]); + } + + $articles = []; + $authorsMetadata = []; + + // If user follows people, get their articles + if (!empty($followedPubkeys)) { + $articleRepo = $em->getRepository(Article::class); + + // Query articles from followed authors, ordered by creation date + $qb = $articleRepo->createQueryBuilder('a'); + $qb->where($qb->expr()->in('a.pubkey', ':pubkeys')) + ->setParameter('pubkeys', $followedPubkeys) + ->orderBy('a.createdAt', 'DESC') + ->setMaxResults(50); // Limit to latest 50 articles + + $articles = $qb->getQuery()->getResult(); + + // Collect unique author pubkeys + $authorPubkeys = []; + foreach ($articles as $article) { + $authorPubkeys[] = $article->getPubkey(); + } + $authorPubkeys = array_unique($authorPubkeys); + + // Batch fetch metadata for all authors using NostrClient + try { + $metadataEvents = $nostrClient->getMetadataForPubkeys($authorPubkeys); + + foreach ($metadataEvents as $pubkey => $event) { + try { + $metadata = json_decode($event->content, false); + $authorsMetadata[$pubkey] = $metadata; + } catch (\Throwable $e) { + $logger->warning('Failed to decode author metadata', [ + 'pubkey' => $pubkey, + 'error' => $e->getMessage() + ]); + // Create basic metadata object + $authorsMetadata[$pubkey] = (object)[ + 'name' => substr($pubkey, 0, 8) . '...' + ]; + } + } + + // Add fallback metadata for any authors not found + foreach ($authorPubkeys as $pubkey) { + if (!isset($authorsMetadata[$pubkey])) { + $authorsMetadata[$pubkey] = (object)[ + 'name' => substr($pubkey, 0, 8) . '...' + ]; + } + } + } catch (\Throwable $e) { + $logger->error('Failed to fetch author metadata', [ + 'error' => $e->getMessage() + ]); + // Create basic metadata for all authors as fallback + foreach ($authorPubkeys as $pubkey) { + $authorsMetadata[$pubkey] = (object)[ + 'name' => substr($pubkey, 0, 8) . '...' + ]; + } + } + } + + return $this->render('follows/index.html.twig', [ + 'isLoggedIn' => true, + 'articles' => $articles, + 'authorsMetadata' => $authorsMetadata, + 'followCount' => count($followedPubkeys), + ]); + } +} + diff --git a/src/Service/NostrClient.php b/src/Service/NostrClient.php index 2d5fa0a..19fdac1 100644 --- a/src/Service/NostrClient.php +++ b/src/Service/NostrClient.php @@ -1411,6 +1411,62 @@ class NostrClient return $tTags; } + /** + * Fetch user's follow list (kind 3 event) from relays + * + * @param string $pubkey Hex-encoded pubkey + * @return array Array of followed pubkeys extracted from 'p' tags, or empty array if not found + * @throws \Exception + */ + public function getUserFollows(string $pubkey): array + { + $this->logger->info('Fetching follow list for pubkey', ['pubkey' => $pubkey]); + + // Use relay pool with local relay prioritized + $relayUrls = $this->relayPool->ensureLocalRelayInList([ + 'wss://theforest.nostr1.com', + 'wss://nostr.land', + 'wss://relay.primal.net', + 'wss://purplepag.es' + ]); + $relaySet = $this->createRelaySet($relayUrls); + + $request = $this->createNostrRequest( + kinds: [KindsEnum::FOLLOWS->value], + filters: ['authors' => [$pubkey], 'limit' => 1], + relaySet: $relaySet + ); + + $events = $this->processResponse($request->send(), function($received) { + $this->logger->info('Received follow list event', ['event' => $received]); + return $received; + }); + + if (empty($events)) { + $this->logger->info('No follow list found for pubkey', ['pubkey' => $pubkey]); + return []; + } + + // Sort by created_at descending and take the most recent + usort($events, fn($a, $b) => $b->created_at <=> $a->created_at); + $latestFollowEvent = $events[0]; + + // Extract followed pubkeys from 'p' tags + $followedPubkeys = []; + foreach ($latestFollowEvent->tags as $tag) { + if (is_array($tag) && isset($tag[0]) && $tag[0] === 'p' && isset($tag[1])) { + $followedPubkeys[] = $tag[1]; + } + } + + $this->logger->info('Follow list fetched successfully', [ + 'pubkey' => $pubkey, + 'follows_count' => count($followedPubkeys) + ]); + + return $followedPubkeys; + } + /** * Batch fetch metadata for multiple pubkeys * Queries local relay first, then fallback to reputable relays for any missing metadata.