Browse Source

More media. Optimize fetching of events by id - test run.

imwald
Nuša Pukšič 3 months ago
parent
commit
6e1402cd78
  1. 3
      config/services.yaml
  2. 15
      docker/cron/media_discovery.sh
  3. 2
      src/Command/CacheMediaDiscoveryCommand.php
  4. 20
      src/Controller/Administration/MagazineAdminController.php
  5. 11
      src/Controller/ArticleController.php
  6. 24
      src/Controller/DefaultController.php
  7. 4
      src/Controller/MediaDiscoveryController.php
  8. 4
      src/Service/Nip05VerificationService.php
  9. 42
      src/Service/NostrClient.php
  10. 2
      src/Service/NostrLinkParser.php
  11. 2
      src/Service/RedisCacheService.php
  12. 4
      src/Twig/Components/SearchComponent.php
  13. 16
      src/Twig/Filters.php
  14. 2
      src/Util/CommonMark/NostrSchemeExtension/NostrEventRenderer.php
  15. 26
      src/Util/CommonMark/NostrSchemeExtension/NostrSchemeParser.php
  16. 168
      src/Util/NostrPhp/TweakedRequest.php
  17. 2
      templates/components/ReadingListDropdown.html.twig
  18. 45
      templates/event/_kind20_picture.html.twig
  19. 125
      templates/event/index.html.twig
  20. 6
      templates/pages/author.html.twig

3
config/services.yaml

@ -69,3 +69,6 @@ services: @@ -69,3 +69,6 @@ services:
App\Command\NostrEventFromYamlDefinitionCommand:
arguments:
$itemPersister: '@fos_elastica.object_persister.articles'
App\Util\NostrPhp\TweakedRequest: ~
swentel\nostr\Request\Request: '@App\Util\NostrPhp\TweakedRequest'

15
docker/cron/media_discovery.sh

@ -2,5 +2,18 @@ @@ -2,5 +2,18 @@
set -e
export PATH="/usr/local/bin:/usr/bin:/bin"
# Run Symfony commands sequentially
LOG_PREFIX="[media_discovery.sh]"
TIMESTAMP() { date '+%Y-%m-%d %H:%M:%S'; }
# Log start
echo "$(TIMESTAMP) $LOG_PREFIX STARTING media discovery cache update" >&2
# Run Symfony command
php /var/www/html/bin/console app:cache-media-discovery
EXIT_CODE=$?
if [ $EXIT_CODE -eq 0 ]; then
echo "$(TIMESTAMP) $LOG_PREFIX FINISHED successfully (exit code: $EXIT_CODE)" >&2
else
echo "$(TIMESTAMP) $LOG_PREFIX ERROR (exit code: $EXIT_CODE)" >&2
fi

2
src/Command/CacheMediaDiscoveryCommand.php

@ -21,7 +21,7 @@ use Symfony\Contracts\Cache\CacheInterface; @@ -21,7 +21,7 @@ use Symfony\Contracts\Cache\CacheInterface;
)]
class CacheMediaDiscoveryCommand extends Command
{
private const int CACHE_TTL = 32500; // 9ish hours in seconds
private const CACHE_TTL = 32500; // 9ish hours in seconds
// Hardcoded topic to hashtag mapping (same as controller)
private const TOPIC_HASHTAGS = [

20
src/Controller/Administration/MagazineAdminController.php

@ -103,11 +103,6 @@ class MagazineAdminController extends AbstractController @@ -103,11 +103,6 @@ class MagazineAdminController extends AbstractController
// 1) Get magazine events directly from database using indexed queries
$magazineEvents = $this->getMagazineEvents($em);
// Fallback: if no magazines found in DB, try Redis as backup
if (empty($magazineEvents)) {
return $this->getFallbackMagazinesFromRedis($redis, $redisCache, $em);
}
// 2) Get all category events in one query
$categoryCoordinates = $this->extractCategoryCoordinates($magazineEvents);
$categoryEvents = $this->getCategoryEventsByCoordinates($em, $categoryCoordinates);
@ -139,12 +134,22 @@ class MagazineAdminController extends AbstractController @@ -139,12 +134,22 @@ class MagazineAdminController extends AbstractController
private function filterTopLevelMagazines(array $magazineEvents): array
{
$topLevelMagazines = [];
// Sort by createdAt descending to keep latest in case of duplicates
usort($magazineEvents, fn($a, $b) => $b->getCreatedAt() <=> $a->getCreatedAt());
$seenSlugs = [];
foreach ($magazineEvents as $event) {
$hasSubIndexes = false;
$hasDirectArticles = false;
$isOldDuplicate = false;
foreach ($event->getTags() as $tag) {
// Keep a list of slugs, so you can skip if already there
if ($tag[0] === 'd' && isset($tag[1])) {
if (in_array($tag[1], $seenSlugs)) {
$isOldDuplicate = true;
}
$seenSlugs[] = $tag[1];
}
if ($tag[0] === 'a' && isset($tag[1])) {
$coord = $tag[1];
@ -161,11 +166,10 @@ class MagazineAdminController extends AbstractController @@ -161,11 +166,10 @@ class MagazineAdminController extends AbstractController
// Only include magazines that have sub-indexes but no direct articles
// This identifies top-level magazines that organize other indexes
if ($hasSubIndexes && !$hasDirectArticles) {
if ($hasSubIndexes && !$hasDirectArticles && !$isOldDuplicate) {
$topLevelMagazines[] = $event;
}
}
return $topLevelMagazines;
}

11
src/Controller/ArticleController.php

@ -97,10 +97,10 @@ class ArticleController extends AbstractController @@ -97,10 +97,10 @@ class ArticleController extends AbstractController
$cacheKey = 'article_' . $article->getEventId();
$cacheItem = $articlesCache->getItem($cacheKey);
if (!$cacheItem->isHit()) {
//if (!$cacheItem->isHit()) {
$cacheItem->set($converter->convertToHTML($article->getContent()));
$articlesCache->save($cacheItem);
}
//}
$key = new Key();
$npub = $key->convertPublicKeyToBech32($article->getPubkey());
@ -155,7 +155,7 @@ class ArticleController extends AbstractController @@ -155,7 +155,7 @@ class ArticleController extends AbstractController
usort($articles, function ($a, $b) {
return $b->getCreatedAt() <=> $a->getCreatedAt();
});
$article = array_pop($articles);
$article = array_shift($articles);
}
if ($article->getPubkey() === null) {
@ -186,6 +186,7 @@ class ArticleController extends AbstractController @@ -186,6 +186,7 @@ class ArticleController extends AbstractController
Request $request,
EntityManagerInterface $entityManager,
NostrClient $nostrClient,
CacheItemPoolInterface $articlesCache,
CsrfTokenManagerInterface $csrfTokenManager
): JsonResponse {
try {
@ -256,6 +257,10 @@ class ArticleController extends AbstractController @@ -256,6 +257,10 @@ class ArticleController extends AbstractController
$entityManager->persist($article);
$entityManager->flush();
// Clear relevant caches
$cacheKey = 'article_' . $article->getEventId();
$articlesCache->delete($cacheKey);
// Optionally publish to Nostr relays
try {
// Get user's relays or use default ones

24
src/Controller/DefaultController.php

@ -12,6 +12,7 @@ use App\Util\CommonMark\Converter; @@ -12,6 +12,7 @@ use App\Util\CommonMark\Converter;
use Doctrine\ORM\EntityManagerInterface;
use Elastica\Collapse;
use Elastica\Query;
use Elastica\Query\BoolQuery;
use Elastica\Query\Terms;
use Exception;
use FOS\ElasticaBundle\Finder\FinderInterface;
@ -19,6 +20,7 @@ use Psr\Cache\CacheItemPoolInterface; @@ -19,6 +20,7 @@ use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException;
use swentel\nostr\Key\Key;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Tests\Compiler\K;
use Symfony\Component\HttpFoundation\ParameterBag;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
@ -56,12 +58,26 @@ class DefaultController extends AbstractController @@ -56,12 +58,26 @@ class DefaultController extends AbstractController
$cacheKey = 'latest_articles_list_' . $env ; // Use env to differentiate cache between environments
$cacheItem = $articlesCache->getItem($cacheKey);
$key = new Key();
$excludedPubkeys = [
$key->convertToHex('npub10akm29ejpdns52ca082skmc3hr75wmv3ajv4987c9lgyrfynrmdqduqwlx'), // Discreet Log (News Bot)
$key->convertToHex('npub1etsrcjz24fqewg4zmjze7t5q8c6rcwde5zdtdt4v3t3dz2navecscjjz94'), // Bitcoin Magazine (News Bot)
$key->convertToHex('npub1m7szwpud3jh2k3cqe73v0fd769uzsj6rzmddh4dw67y92sw22r3sk5m3ys'), // No Bullshit Bitcoin (News Bot)
$key->convertToHex('npub13wke9s6njrmugzpg6mqtvy2d49g4d6t390ng76dhxxgs9jn3f2jsmq82pk'), // TFTC (News Bot)
$key->convertToHex('npub13uvnw9qehqkds68ds76c4nfcn3y99c2rl9z8tr0p34v7ntzsmmzspwhh99'), // Batcoinz (Just annoying)
$key->convertToHex('npub1fls5au5fxj6qj0t36sage857cs4tgfpla0ll8prshlhstagejtkqc9s2yl'), // AGORA Marketplace - feed 𝚋𝚘𝚝 (Just annoying)
];
if (!$cacheItem->isHit()) {
// Query all articles and sort by created_at descending
$query = new Query();
$boolQuery = new BoolQuery();
$boolQuery->addMustNot(new Query\Terms('pubkey', $excludedPubkeys));
$query = new Query($boolQuery);
$query->setSize(50);
$query->setSort(['createdAt' => ['order' => 'desc']]);
// Use collapse to deduplicate by slug field
$collapse = new Collapse();
$collapse->setFieldname('slug');
@ -134,7 +150,8 @@ class DefaultController extends AbstractController @@ -134,7 +150,8 @@ class DefaultController extends AbstractController
// Query the database for the category event by slug using native SQL
$sql = "SELECT e.* FROM event e
WHERE e.tags::jsonb @> ?::jsonb
LIMIT 1";
ORDER BY e.created_at DESC
";
$conn = $entityManager->getConnection();
$result = $conn->executeQuery($sql, [
@ -143,6 +160,7 @@ class DefaultController extends AbstractController @@ -143,6 +160,7 @@ class DefaultController extends AbstractController
$eventData = $result->fetchAssociative();
if ($eventData === false) {
throw new Exception('Category not found');
}
@ -166,6 +184,8 @@ class DefaultController extends AbstractController @@ -166,6 +184,8 @@ class DefaultController extends AbstractController
}
}
dump($tags);die;
if (!empty($coordinates)) {
// Extract slugs for elasticsearch query
$slugs = array_map(function($coordinate) {

4
src/Controller/MediaDiscoveryController.php

@ -12,8 +12,8 @@ use Symfony\Contracts\Cache\ItemInterface; @@ -12,8 +12,8 @@ use Symfony\Contracts\Cache\ItemInterface;
class MediaDiscoveryController extends AbstractController
{
private const int CACHE_TTL = 10800; // 3 hours in seconds
private const int MAX_DISPLAY_EVENTS = 42;
private const CACHE_TTL = 10800; // 3 hours in seconds
private const MAX_DISPLAY_EVENTS = 42;
// Hardcoded topic to hashtag mapping
private const TOPIC_HASHTAGS = [

4
src/Service/Nip05VerificationService.php

@ -8,8 +8,8 @@ use Symfony\Contracts\Cache\ItemInterface; @@ -8,8 +8,8 @@ use Symfony\Contracts\Cache\ItemInterface;
readonly class Nip05VerificationService
{
private const int CACHE_TTL = 3600; // 1 hour
private const int REQUEST_TIMEOUT = 5; // 5 seconds
private const CACHE_TTL = 3600; // 1 hour
private const REQUEST_TIMEOUT = 5; // 5 seconds
public function __construct(
private CacheInterface $redisCache,

42
src/Service/NostrClient.php

@ -5,6 +5,7 @@ namespace App\Service; @@ -5,6 +5,7 @@ namespace App\Service;
use App\Entity\Article;
use App\Enum\KindsEnum;
use App\Factory\ArticleFactory;
use App\Util\NostrPhp\TweakedRequest;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\Persistence\ManagerRegistry;
use nostriphant\NIP19\Data;
@ -26,7 +27,7 @@ class NostrClient @@ -26,7 +27,7 @@ class NostrClient
/**
* List of reputable relays in descending order of reputation
*/
private const array REPUTABLE_RELAYS = [
private const REPUTABLE_RELAYS = [
'wss://theforest.nostr1.com',
'wss://relay.damus.io',
'wss://relay.primal.net',
@ -44,8 +45,7 @@ class NostrClient @@ -44,8 +45,7 @@ class NostrClient
{
$this->defaultRelaySet = new RelaySet();
$this->defaultRelaySet->addRelay(new Relay('wss://theforest.nostr1.com')); // public aggregator relay
$this->defaultRelaySet->addRelay(new Relay('wss://relay.damus.io')); // public aggregator relay
$this->defaultRelaySet->addRelay(new Relay('wss://relay.primal.net')); // public aggregator relay
$this->defaultRelaySet->addRelay(new Relay('wss://nostr.land')); // public aggregator relay
}
/**
@ -271,6 +271,26 @@ class NostrClient @@ -271,6 +271,26 @@ class NostrClient
{
$this->logger->info('Getting event by ID', ['event_id' => $eventId, 'relays' => $relays]);
// Merge relays with reputable ones
$allRelays = array_unique(array_merge($relays, self::REPUTABLE_RELAYS));
// Loop over reputable relays and bail as soon as you get a valid event back
foreach ($allRelays as $reputableRelay) {
$this->logger->info('Trying reputable relay first', ['relay' => $reputableRelay]);
$request = $this->createNostrRequest(
kinds: [],
filters: ['ids' => [$eventId], 'limit' => 1],
relaySet: $this->createRelaySet([$reputableRelay]),
stopGap: $eventId
);
$events = $this->processResponse($request->send(), function($event) {
$this->logger->debug('Received event', ['event' => $event]);
return $event;
});
if (!empty($events)) {
return $events[0];
}
}
// Use provided relays or default if empty
$relaySet = empty($relays) ? $this->defaultRelaySet : $this->createRelaySet($relays);
@ -355,18 +375,6 @@ class NostrClient @@ -355,18 +375,6 @@ class NostrClient
return !empty($events) ? $events[0] : null;
}
/**
* Fetch a note by its ID
*
* @param string $noteId The note ID
* @return object|null The note or null if not found
* @throws \Exception
*/
public function getNoteById(string $noteId): ?object
{
return $this->getEventById($noteId);
}
private function saveLongFormContent(mixed $filtered): void
{
foreach ($filtered as $wrapper) {
@ -862,7 +870,7 @@ class NostrClient @@ -862,7 +870,7 @@ class NostrClient
return $articlesMap;
}
private function createNostrRequest(array $kinds, array $filters = [], ?RelaySet $relaySet = null): Request
private function createNostrRequest(array $kinds, array $filters = [], ?RelaySet $relaySet = null, $stopGap = null ): TweakedRequest
{
$subscription = new Subscription();
$filter = new Filter();
@ -884,7 +892,7 @@ class NostrClient @@ -884,7 +892,7 @@ class NostrClient
}
$requestMessage = new RequestMessage($subscription->getId(), [$filter]);
return new Request($relaySet ?? $this->defaultRelaySet, $requestMessage);
return (new TweakedRequest($relaySet ?? $this->defaultRelaySet, $requestMessage))->stopOnEventId($stopGap);
}
private function processResponse(array $response, callable $eventHandler): array

2
src/Service/NostrLinkParser.php

@ -7,7 +7,7 @@ use Psr\Log\LoggerInterface; @@ -7,7 +7,7 @@ use Psr\Log\LoggerInterface;
readonly class NostrLinkParser
{
private const string NOSTR_LINK_PATTERN = '/(?:nostr:)(nevent1[a-z0-9]+|naddr1[a-z0-9]+|nprofile1[a-z0-9]+|note1[a-z0-9]+|npub1[a-z0-9]+)/';
private const NOSTR_LINK_PATTERN = '/(?:nostr:)(nevent1[a-z0-9]+|naddr1[a-z0-9]+|nprofile1[a-z0-9]+|note1[a-z0-9]+|npub1[a-z0-9]+)/';
private const URL_PATTERN = '/https?:\/\/[\w\-\.\?\,\'\/\\\+&%@\?\$#_=:\(\)~;]+/i';

2
src/Service/RedisCacheService.php

@ -248,7 +248,7 @@ readonly class RedisCacheService @@ -248,7 +248,7 @@ readonly class RedisCacheService
usort($nzines, function ($a, $b) {
return $b->getCreatedAt() <=> $a->getCreatedAt();
});
$nzine = array_pop($nzines);
$nzine = array_shift($nzines);
$this->logger->info('Magazine lookup', ['mag' => $slug, 'found' => json_encode($nzine)]);

4
src/Twig/Components/SearchComponent.php

@ -48,8 +48,8 @@ final class SearchComponent @@ -48,8 +48,8 @@ final class SearchComponent
#[LiveProp(writable: true)]
public bool $selectMode = false;
private const string SESSION_KEY = 'last_search_results';
private const string SESSION_QUERY_KEY = 'last_search_query';
private const SESSION_KEY = 'last_search_results';
private const SESSION_QUERY_KEY = 'last_search_query';
public function __construct(
private readonly FinderInterface $finder,

16
src/Twig/Filters.php

@ -4,8 +4,9 @@ declare(strict_types=1); @@ -4,8 +4,9 @@ declare(strict_types=1);
namespace App\Twig;
use BitWasp\Bech32\Exception\Bech32Exception;
use swentel\nostr\Nip19\Nip19Helper;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
use Twig\TwigFilter;
class Filters extends AbstractExtension
@ -15,7 +16,8 @@ class Filters extends AbstractExtension @@ -15,7 +16,8 @@ class Filters extends AbstractExtension
return [
new TwigFilter('shortenNpub', [$this, 'shortenNpub']),
new TwigFilter('linkify', [$this, 'linkify'], ['is_safe' => ['html']]),
new TwigFilter('mentionify', [$this, 'mentionify'], ['is_safe' => ['html']])
new TwigFilter('mentionify', [$this, 'mentionify'], ['is_safe' => ['html']]),
new TwigFilter('nEncode', [$this, 'nEncode']),
];
}
@ -59,4 +61,14 @@ class Filters extends AbstractExtension @@ -59,4 +61,14 @@ class Filters extends AbstractExtension
$text
);
}
public function nEncode(string $eventId): string
{
$nip19 = new Nip19Helper();
try {
return $nip19->encodeNote($eventId);
} catch (Bech32Exception) {
return $eventId; // Return original if encoding fails
}
}
}

2
src/Util/CommonMark/NostrSchemeExtension/NostrEventRenderer.php

@ -16,7 +16,7 @@ class NostrEventRenderer implements NodeRendererInterface @@ -16,7 +16,7 @@ class NostrEventRenderer implements NodeRendererInterface
throw new \InvalidArgumentException('Incompatible inline node type: ' . get_class($node));
}
if ($node->getType() === 'nevent') {
if ($node->getType() === 'nevent' || $node->getType() === 'note') {
// Construct the local link URL from the special part
$url = '/e/' . $node->getSpecial();
} else if ($node->getType() === 'naddr') {

26
src/Util/CommonMark/NostrSchemeExtension/NostrSchemeParser.php

@ -10,6 +10,7 @@ use League\CommonMark\Parser\InlineParserContext; @@ -10,6 +10,7 @@ use League\CommonMark\Parser\InlineParserContext;
use nostriphant\NIP19\Bech32;
use nostriphant\NIP19\Data\NAddr;
use nostriphant\NIP19\Data\NEvent;
use nostriphant\NIP19\Data\Note;
use nostriphant\NIP19\Data\NProfile;
use nostriphant\NIP19\Data\NPub;
use Twig\Environment;
@ -32,7 +33,9 @@ class NostrSchemeParser implements InlineParserInterface @@ -32,7 +33,9 @@ class NostrSchemeParser implements InlineParserInterface
public function getMatchDefinition(): InlineParserMatch
{
return InlineParserMatch::regex('nostr:[0-9a-zA-Z]+');
return InlineParserMatch::regex(
'nostr:(?:npub1|nprofile1|note1|nevent1|naddr1)[^\\s<>()\\[\\]{}"\'`.,;:!?]*'
);
}
public function parse(InlineParserContext $inlineContext): bool
@ -63,6 +66,25 @@ class NostrSchemeParser implements InlineParserInterface @@ -63,6 +66,25 @@ class NostrSchemeParser implements InlineParserInterface
$decodedProfile = $decoded->data;
$inlineContext->getContainer()->appendChild(new NostrMentionLink(null, $decodedProfile->pubkey));
break;
case 'note':
/** @var Note $decodedEvent */
$decodedEvent = $decoded->data;
// Fetch the actual event data using the same logic as EventController
$event = $this->nostrClient->getEventById($decodedEvent->data);
// If note is kind 20
// Render the embedded picture card
if (!$event || $event->kind !== 20) {
// Fallback to simple link if event not found or not kind 20
$inlineContext->getContainer()->appendChild(new NostrSchemeData('note', $bechEncoded, [], null, null));
break;
}
$pictureCardHtml = $this->twig->render('/event/_kind20_picture.html.twig', [
'event' => $event,
'embed' => true
]);
// Create a new node type for embedded HTML content
$inlineContext->getContainer()->appendChild(new NostrEmbeddedCard($pictureCardHtml));
break;
case 'nevent':
/** @var NEvent $decodedEvent */
$decodedEvent = $decoded->data;
@ -109,7 +131,7 @@ class NostrSchemeParser implements InlineParserInterface @@ -109,7 +131,7 @@ class NostrSchemeParser implements InlineParserInterface
}
} catch (\Exception $e) {
// dump($e->getMessage());
dump($e->getMessage());
return false;
}

168
src/Util/NostrPhp/TweakedRequest.php

@ -0,0 +1,168 @@ @@ -0,0 +1,168 @@
<?php
declare(strict_types=1);
namespace App\Util\NostrPhp;
use swentel\nostr\Message\AuthMessage;
use swentel\nostr\Message\CloseMessage;
use swentel\nostr\MessageInterface;
use swentel\nostr\Nip42\AuthEvent;
use swentel\nostr\Relay\Relay;
use swentel\nostr\Relay\RelaySet;
use swentel\nostr\RelayResponse\RelayResponse;
use swentel\nostr\RequestInterface;
use swentel\nostr\Sign\Sign;
use WebSocket\Client as WsClient;
use WebSocket\Message\Pong;
use WebSocket\Message\Text;
/**
* A deterministic "stop on first matching EVENT" request.
* Implements RequestInterface so we can DI-substitute it for the vendor Request.
*/
final class TweakedRequest implements RequestInterface
{
private RelaySet $relays;
private string $payload;
private array $responses = [];
/** Optional: when set, CLOSE & disconnect immediately once this id arrives */
private ?string $stopOnEventId = null;
public function __construct(Relay|RelaySet $relay, MessageInterface $message)
{
if ($relay instanceof RelaySet) {
$this->relays = $relay;
} else {
$set = new RelaySet();
$set->setRelays([$relay]);
$this->relays = $set;
}
$this->payload = $message->generate();
}
public function stopOnEventId(?string $hexId): self
{
$this->stopOnEventId = $hexId;
return $this;
}
/** @return array<string, array|RelayResponse> */
public function send(): array
{
$result = [];
foreach ($this->relays->getRelays() as $relay) {
$this->responses = []; // reset per relay
try {
if (!$relay->isConnected()) {
$relay->connect();
}
$client = $relay->getClient();
$client->setTimeout(15); // seconds per receive call (keep it small if you want responsiveness)
// Send subscription payload
$client->text($this->payload);
// Loop until: first match, EOSE/CLOSED/ERROR, or socket ends
while ($resp = $client->receive()) {
if ($resp instanceof \WebSocket\Message\Ping) {
$client->text((new Pong())->getPayload());
continue;
}
if (!$resp instanceof Text) {
continue;
}
$decoded = json_decode($resp->getContent());
$relayResponse = RelayResponse::create($decoded);
$this->responses[] = $relayResponse;
// Early exit on matching EVENT
if ($relayResponse->type === 'EVENT') {
// Safest: decode again to array to grab [ "EVENT", subId, event ]
$raw = json_decode($resp->getContent(), true);
$sub = $raw[1] ?? null;
$evt = $raw[2] ?? null;
$evtId = is_array($evt) ? ($evt['id'] ?? null) : null;
if ($this->stopOnEventId !== null && $evtId === $this->stopOnEventId) {
if ($sub) {
$this->sendClose($client, $sub);
}
$relay->disconnect();
$result[$relay->getUrl()] = $this->responses;
// stop the outer foreach too (we're done)
return $result;
}
}
// Tear-down conditions
if ($relayResponse->type === 'EOSE') {
$sub = $relayResponse->subscriptionId ?? null;
if ($sub) {
$this->sendClose($client, $sub);
}
$relay->disconnect();
break;
}
if ($relayResponse->type === 'NOTICE' && str_starts_with($relayResponse->message ?? '', 'ERROR:')) {
$relay->disconnect();
break;
}
if ($relayResponse->type === 'CLOSED') {
$relay->disconnect();
break;
}
// NIP-42: if relay requests AUTH, perform it once, then continue.
if ($relayResponse->type === 'OK' && isset($relayResponse->message) && str_starts_with($relayResponse->message, 'auth-required:')) {
$this->performAuth($relay, $client);
// After AUTH, re-send the original payload
$client->text($this->payload);
// continue loop
}
}
// Save what we got for this relay
$result[$relay->getUrl()] = $this->responses;
} catch (\Throwable $e) {
$result[$relay->getUrl()][] = ['ERROR', '', false, $e->getMessage()];
// best-effort disconnect
try { $relay->disconnect(); } catch (\Throwable) {}
}
}
return $result;
}
private function sendClose(WsClient $client, string $subscriptionId): void
{
try {
$close = new CloseMessage($subscriptionId);
$client->text($close->generate());
} catch (\Throwable) {}
}
/** Very lightweight NIP-42 auth flow: sign challenge and send AUTH + resume. */
private function performAuth(Relay $relay, WsClient $client): void
{
// NOTE: This reuses the vendor types, but uses a dummy secret. You should inject your real sec key.
if (!isset($_SESSION['challenge'])) {
return;
}
try {
$authEvent = new AuthEvent($relay->getUrl(), $_SESSION['challenge']);
$sec = '0000000000000000000000000000000000000000000000000000000000000001'; // TODO inject your real sec
(new Sign())->signEvent($authEvent, $sec);
$authMsg = new AuthMessage($authEvent);
$client->text($authMsg->generate());
} catch (\Throwable) {
// ignore and continue; some relays won’t require it
}
}
}

2
templates/components/ReadingListDropdown.html.twig

@ -16,7 +16,7 @@ @@ -16,7 +16,7 @@
data-reading-list-dropdown-target="dropdown"
data-action="click->reading-list-dropdown#toggleDropdown"
aria-expanded="false">
📚 Add to Reading List
Add to Reading List
</button>
<ul class="dropdown-menu"
aria-labelledby="readingListDropdown"

45
templates/event/_kind20_picture.html.twig

@ -8,6 +8,11 @@ @@ -8,6 +8,11 @@
{% endif %}
{% endfor %}
{% set isEmbed = false %}
{% if embed is defined and embed %}
{% set isEmbed = true %}
{% endif %}
{% if title %}
<h2 class="picture-title">{{ title }}</h2>
{% endif %}
@ -61,8 +66,21 @@ @@ -61,8 +66,21 @@
{% endif %}
{% endfor %}
{# Alt is also own tag #}
{% for tag in event.tags %}
{% if tag[0] == 'alt' %}
{% set altText = tag[1] %}
{% endif %}
{% endfor %}
{# Display the image with fallbacks and annotations #}
{% if imageUrl %}
<div class="picture-item">
<figure class="media">
{% if isEmbed %}
<a href="{{ path('nevent', {nevent: event.id|nEncode }) }}" aria-label="View standalone">
{% endif %}
<picture>
{% for fallback in fallbacks %}
<source srcset="{{ fallback }}" />
@ -74,6 +92,16 @@ @@ -74,6 +92,16 @@
class="picture-image"
onerror="if(this.nextSibling) this.src=this.nextSibling.srcset; else this.parentElement.innerHTML='<p class=error>Image failed to load</p>';" />
</picture>
{% if isEmbed %}
</a>
{% endif %}
<figcaption class="picture-description">
<twig:Atoms:Content :content="event.content" />
{% if isEmbed %}
<span class="credits">Image by <twig:Molecules:UserFromNpub :ident="event.pubkey" class="credits" /></span>
{% endif %}
</figcaption>
</figure>
{# Display annotated users #}
{% if annotatedUsers|length > 0 %}
@ -88,23 +116,12 @@ @@ -88,23 +116,12 @@
{% endfor %}
</div>
{% endif %}
{% if altText %}
<p class="picture-alt">{{ altText }}</p>
{% endif %}
</div>
{% endif %}
{% endif %}
{% endfor %}
</div>
{# Description from content #}
{% if event.content %}
<div class="picture-description mt-1">
<twig:Atoms:Content :content="event.content" />
</div>
{% endif %}
{# Location data #}
{% set location = null %}
{% set geohash = null %}
@ -125,6 +142,7 @@ @@ -125,6 +142,7 @@
{% endif %}
{# Hashtags #}
{% if not isEmbed %}
{% set hashtags = [] %}
{% for tag in event.tags %}
{% if tag[0] == 't' %}
@ -133,10 +151,11 @@ @@ -133,10 +151,11 @@
{% endfor %}
{% if hashtags|length > 0 %}
<div class="picture-hashtags">
<div class="tags">
{% for hashtag in hashtags %}
<span class="hashtag">#{{ hashtag }}</span>
<span class="tag">#{{ hashtag }}</span>
{% endfor %}
</div>
{% endif %}
{% endif %}
</div>

125
templates/event/index.html.twig

@ -81,22 +81,25 @@ @@ -81,22 +81,25 @@
{% endblock %}
{% block body %}
<div class="container">
<div class="event-container">
<div class="event-header">
<article class="w-container">
<div class="card">
<div class="card-header">
<h1 class="card-title">{{ event.title ?? 'Untitled' }}</h1>
</div>
{% if author %}
{% if author.image is defined %}
<img src="{{ author.image }}" class="avatar" alt="{{ author.name }}" onerror="this.style.display = 'none'" />
{% endif %}
<twig:Molecules:UserFromNpub ident="{{ event.pubkey }}" />
<hr />
{% endif %}
<div class="event-meta">
<span class="event-date">{{ event.created_at|date('F j, Y - H:i') }}</span>
<div class="byline">
<span>
{{ 'text.byline'|trans }} <a href="{{ path('author-redirect', {'pubkey': event.pubkey}) }}">
<twig:Molecules:UserFromNpub :ident="event.pubkey" />
</a>
</span>
<span>
<small>{{ event.created_at|date('F j, Y') }}</small>
</span>
</div>
{% endif %}
</div>
<div class="card-body">
{# NIP-68 Picture Event (kind 20) #}
{% if event.kind == 20 %}
{% include 'event/_kind20_picture.html.twig' %}
@ -109,7 +112,14 @@ @@ -109,7 +112,14 @@
<twig:Atoms:Content :content="event.content" />
</div>
{% endif %}
</div>
</article>
<pre>
{{ event|json_encode(constant('JSON_PRETTY_PRINT')) }}
</pre>
<div class="container">
{% if nostrLinks is defined and nostrLinks|length > 0 %}
<div class="nostr-links">
<h4>Referenced Nostr Links</h4>
@ -163,7 +173,6 @@ @@ -163,7 +173,6 @@
{% endif %}
</div>
</div>
</div>
{% endblock %}
{% block stylesheets %}
@ -191,17 +200,6 @@ @@ -191,17 +200,6 @@
}
/* NIP-68 Picture Event Styles */
.picture-event {
padding: 1rem;
}
.picture-title {
font-size: 1.8rem;
font-weight: bold;
margin-bottom: 1rem;
color: #333;
}
.content-warning {
background-color: #fff3cd;
border: 2px solid #ffc107;
@ -225,63 +223,6 @@ @@ -225,63 +223,6 @@
background-color: #e0a800;
}
.picture-gallery {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
.picture-item {
position: relative;
border-radius: 8px;
overflow: hidden;
background-color: #f5f5f5;
}
.picture-image {
width: 100%;
height: auto;
display: block;
object-fit: cover;
border-radius: 8px;
}
.annotated-users {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.user-tag {
position: absolute;
background-color: rgba(0, 0, 0, 0.7);
color: white;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.9rem;
pointer-events: all;
cursor: pointer;
transform: translate(-50%, -50%);
}
.picture-alt {
font-size: 0.9rem;
color: #666;
margin-top: 0.5rem;
font-style: italic;
}
.picture-description {
font-size: 1.1rem;
line-height: 1.6;
margin-bottom: 1rem;
color: #333;
}
.picture-location {
display: flex;
align-items: center;
@ -304,26 +245,6 @@ @@ -304,26 +245,6 @@
cursor: help;
}
.picture-hashtags {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 1rem;
}
.hashtag {
background-color: #e7f3ff;
color: #0066cc;
padding: 0.3rem 0.8rem;
border-radius: 20px;
font-size: 0.9rem;
font-weight: 500;
}
.hashtag:hover {
background-color: #d0e7ff;
}
.picture-source {
display: flex;
align-items: center;

6
templates/pages/author.html.twig

@ -7,6 +7,12 @@ @@ -7,6 +7,12 @@
{% endif %}
<h1><twig:Atoms:NameOrNpub :author="author" :npub="npub"></twig:Atoms:NameOrNpub></h1>
{% if author.bot is defined %}
<div class="tags">
<span class="tag">bot</span>
</div>
{% endif %}
{% if author.nip05 is defined %}
{% if author.nip05 is iterable %}
{% for nip05Value in author.nip05 %}

Loading…
Cancel
Save