diff --git a/assets/controllers/nostr_preview_controller.js b/assets/controllers/nostr_preview_controller.js new file mode 100644 index 0000000..09d4348 --- /dev/null +++ b/assets/controllers/nostr_preview_controller.js @@ -0,0 +1,52 @@ +import { Controller } from '@hotwired/stimulus'; + +export default class extends Controller { + static values = { + identifier: String, + type: String, + decoded: String, + fullMatch: String + } + + static targets = ['container'] + + async connect() { + await this.fetchPreview(); + } + + async fetchPreview() { + try { + // Show loading indicator + this.containerTarget.innerHTML = '
Loading preview...
'; + console.log(this.decodedValue); + const data = { + identifier: this.identifierValue, + type: this.typeValue, + decoded: this.decodedValue + }; + fetch("/preview/", { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify(data) + }) + .then(res => { + if (!res.ok) { + throw new Error(`HTTP error! status: ${res.status}`); + } + return res.text(); + }) + .then(data => { + console.log(data); + this.containerTarget.innerHTML = data; + }) + .catch(error => { + console.error("Error:", error); + }); + } catch (error) { + console.error('Error fetching Nostr preview:', error); + this.containerTarget.innerHTML = `
Unable to load preview for ${this.fullMatchValue}
`; + } + } +} diff --git a/assets/styles/app.scss b/assets/styles/app.scss new file mode 100644 index 0000000..a313524 --- /dev/null +++ b/assets/styles/app.scss @@ -0,0 +1,3 @@ +// ...existing code... +@import "components/nostr_previews"; +// ...existing code... diff --git a/assets/styles/components/_nostr_previews.scss b/assets/styles/components/_nostr_previews.scss new file mode 100644 index 0000000..fe3dc65 --- /dev/null +++ b/assets/styles/components/_nostr_previews.scss @@ -0,0 +1,52 @@ +.nostr-preview { + margin-top: 0.5rem; + + .nostr-event-preview, .nostr-profile-preview { + border-left: 3px solid #6c5ce7; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + } + + .nostr-profile-preview { + border-left-color: #00b894; + } + + .card-title { + margin-bottom: 0.5rem; + font-size: 1rem; + } + + .card-text { + font-size: 0.9rem; + } + + .card-footer { + padding: 0.5rem 1rem; + } +} + +.nostr-previews { + h6 { + font-size: 0.9rem; + margin-bottom: 1rem; + } + + .preview-container { + // For multiple previews + max-height: 500px; + overflow-y: auto; + padding-right: 10px; + } +} + +// Style for nostr links in text +.nostr-link { + color: #6c5ce7; + background-color: rgba(108, 92, 231, 0.1); + padding: 0 3px; + border-radius: 3px; + text-decoration: none; + + &:hover { + background-color: rgba(108, 92, 231, 0.2); + } +} diff --git a/assets/styles/layout.css b/assets/styles/layout.css index 849c366..d362377 100644 --- a/assets/styles/layout.css +++ b/assets/styles/layout.css @@ -106,9 +106,9 @@ main { .user-menu { position: fixed; top: 150px; - width: 21vw; + width: calc(21vw - 10px); min-width: 150px; - max-width: 280px; + max-width: 270px; } .user-nav { diff --git a/composer.json b/composer.json index ed674fe..d9d28be 100644 --- a/composer.json +++ b/composer.json @@ -22,6 +22,7 @@ "laminas/laminas-diactoros": "^3.6", "league/commonmark": "^2.7", "league/html-to-markdown": "*", + "nostriphant/nip-19": "^2.0", "phpdocumentor/reflection-docblock": "^5.6", "phpstan/phpdoc-parser": "^2.0", "runtime/frankenphp-symfony": "^0.2.0", diff --git a/composer.lock b/composer.lock index d870050..753839c 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "caedc29506c52d87b3e4e4217fe34eb8", + "content-hash": "1252f7c24acd549dc08852b1cfdd5e69", "packages": [ { "name": "bacon/bacon-qr-code", @@ -3089,6 +3089,50 @@ }, "time": "2025-03-30T21:06:30+00:00" }, + { + "name": "nostriphant/nip-19", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/nostriphant/nip-19.git", + "reference": "5b362ab27e428f666f021743dddde9fecfe895c5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nostriphant/nip-19/zipball/5b362ab27e428f666f021743dddde9fecfe895c5", + "reference": "5b362ab27e428f666f021743dddde9fecfe895c5", + "shasum": "" + }, + "require": { + "php": "^8.3" + }, + "require-dev": { + "pestphp/pest": "^2.35" + }, + "type": "library", + "autoload": { + "psr-4": { + "nostriphant\\NIP19\\": "src/", + "nostriphant\\NIP19Tests\\": "tests/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Rik Meijer", + "email": "rik@nostriphant.dev" + } + ], + "description": "Nostr NIP-19 bech32-encoded entities in PHP", + "support": { + "issues": "https://github.com/nostriphant/nip-19/issues", + "source": "https://github.com/nostriphant/nip-19/tree/2.0" + }, + "time": "2024-11-26T15:37:43+00:00" + }, { "name": "nyholm/dsn", "version": "2.0.1", diff --git a/src/Controller/ArticleController.php b/src/Controller/ArticleController.php index 58f5a61..cab07c4 100644 --- a/src/Controller/ArticleController.php +++ b/src/Controller/ArticleController.php @@ -7,10 +7,11 @@ use App\Enum\KindsEnum; use App\Form\EditorType; use App\Service\NostrClient; use App\Service\RedisCacheService; -use App\Util\Bech32\Bech32Decoder; use App\Util\CommonMark\Converter; use Doctrine\ORM\EntityManagerInterface; use League\CommonMark\Exception\CommonMarkException; +use nostriphant\NIP19\Bech32; +use nostriphant\NIP19\Data\NAddr; use Psr\Cache\CacheItemPoolInterface; use Psr\Cache\InvalidArgumentException; use swentel\nostr\Key\Key; @@ -27,52 +28,31 @@ class ArticleController extends AbstractController * @throws \Exception */ #[Route('/article/{naddr}', name: 'article-naddr')] - public function naddr(NostrClient $nostrClient, Bech32Decoder $bech32Decoder, $naddr) + public function naddr(NostrClient $nostrClient, $naddr) { - // decode naddr - list($hrp, $tlv) = $bech32Decoder->decodeAndParseNostrBech32($naddr); - if ($hrp !== 'naddr') { + $decoded = new Bech32($naddr); + + if ($decoded->type !== 'naddr') { throw new \Exception('Invalid naddr'); } - foreach ($tlv as $item) { - // d tag - if ($item['type'] === 0) { - $slug = implode('', array_map('chr', $item['value'])); - } - // relays - if ($item['type'] === 1) { - $relays[] = implode('', array_map('chr', $item['value'])); - } - // author - if ($item['type'] === 2) { - $str = ''; - foreach ($item['value'] as $byte) { - $str .= str_pad(dechex($byte), 2, '0', STR_PAD_LEFT); - } - $author = $str; - } - if ($item['type'] === 3) { - // big-endian integer - $intValue = 0; - foreach ($item['value'] as $byte) { - $intValue = ($intValue << 8) | $byte; - } - $kind = $intValue; - } - } + /** @var NAddr $data */ + $data = $decoded->data; + $slug = $data->identifier; + $relays = $data->relays; + $author = $data->pubkey; + $kind = $data->kind; if ($kind !== KindsEnum::LONGFORM->value) { throw new \Exception('Not a long form article'); } - if (empty($relays ?? [])) { + if (empty($relays)) { // get author npub relays from their config $relays = $nostrClient->getNpubRelays($author); } - $nostrClient->getLongFormFromNaddr($slug, $relays ?? null, $author, $kind); - + $nostrClient->getLongFormFromNaddr($slug, $relays, $author, $kind); if ($slug) { return $this->redirectToRoute('article-slug', ['slug' => $slug]); } @@ -92,6 +72,10 @@ class ArticleController extends AbstractController Converter $converter ): Response { + + set_time_limit(300); // 5 minutes + ini_set('max_execution_time', '300'); + $article = null; // check if an item with same eventId already exists in the db $repository = $entityManager->getRepository(Article::class); @@ -133,6 +117,59 @@ class ArticleController extends AbstractController ]); } + /** + * Fetch complete event to show as preview + * POST data contains an object with request params + */ + #[Route('/preview/', name: 'article-preview-event', methods: ['POST'])] + public function articlePreviewEvent( + Request $request, + NostrClient $nostrClient, + RedisCacheService $redisCacheService, + CacheItemPoolInterface $articlesCache + ): Response { + $data = $request->getContent(); + // descriptor is an object with properties type, identifier and data + // if type === 'nevent', identifier is the event id + // if type === 'naddr', identifier is the naddr + // if type === 'nprofile', identifier is the npub + $descriptor = json_decode($data); + $previewData = []; + + // if nprofile, get from redis cache + if ($descriptor->type === 'nprofile') { + $hint = json_decode($descriptor->decoded); + $key = new Key(); + $npub = $key->convertPublicKeyToBech32($hint->pubkey); + $metadata = $redisCacheService->getMetadata($npub); + $metadata->npub = $npub; + $metadata->pubkey = $hint->pubkey; + $metadata->type = 'nprofile'; + // Render the NostrPreviewContent component with the preview data + $html = $this->renderView('components/Molecules/NostrPreviewContent.html.twig', [ + 'preview' => $metadata + ]); + } else { + // For nevent or naddr, fetch the event data + try { + $previewData = $nostrClient->getEventFromDescriptor($descriptor); + $previewData->type = $descriptor->type; // Add type to the preview data + // Render the NostrPreviewContent component with the preview data + $html = $this->renderView('components/Molecules/NostrPreviewContent.html.twig', [ + 'preview' => $previewData + ]); + } catch (\Exception $e) { + $html = 'Error fetching preview: ' . htmlspecialchars($e->getMessage()) . ''; + } + } + + + return new Response( + $html, + Response::HTTP_OK, + ['Content-Type' => 'text/html'] + ); + } /** * Create new article diff --git a/src/Service/NostrClient.php b/src/Service/NostrClient.php index 996e1e6..027dd71 100644 --- a/src/Service/NostrClient.php +++ b/src/Service/NostrClient.php @@ -7,6 +7,7 @@ use App\Enum\KindsEnum; use App\Factory\ArticleFactory; use Doctrine\ORM\EntityManagerInterface; use Doctrine\Persistence\ManagerRegistry; +use nostriphant\NIP19\Data; use Psr\Log\LoggerInterface; use swentel\nostr\Event\Event; use swentel\nostr\Filter\Filter; @@ -43,7 +44,8 @@ class NostrClient { $this->defaultRelaySet = new RelaySet(); $this->defaultRelaySet->addRelay(new Relay('wss://theforest.nostr1.com')); // public aggregator relay - $this->defaultRelaySet->addRelay(new Relay('wss://thecitadel.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 } /** @@ -220,7 +222,7 @@ class NostrClient // Filter out relays that exist in the REPUTABLE_RELAYS list $relayList = array_filter($relayList, function ($relay) { // in array REPUTABLE_RELAYS - return in_array($relay, self::REPUTABLE_RELAYS) && str_starts_with($relay, 'wss:') && !str_contains($relay, 'localhost'); + return str_starts_with($relay, 'wss:') && !str_contains($relay, 'localhost'); }); $relaySet = $this->createRelaySet($relayList); } @@ -294,6 +296,113 @@ class NostrClient } } + /** + * Get event by its ID + * + * @param string $eventId The event ID + * @param array $relays Optional array of relay URLs to query + * @return object|null The event or null if not found + * @throws \Exception + */ + public function getEventById(string $eventId, array $relays = []): ?object + { + $this->logger->info('Getting event by ID', ['event_id' => $eventId]); + + // Use provided relays or default if empty + $relaySet = empty($relays) ? $this->defaultRelaySet : $this->createRelaySet($relays); + + // Create request using the helper method + $request = $this->createNostrRequest( + kinds: [], // Leave empty to accept any kind + filters: ['ids' => [$eventId]], + relaySet: $relaySet + ); + + // Process the response + $events = $this->processResponse($request->send(), function($event) { + return $event; + }); + + if (empty($events)) { + return null; + } + + // Return the first matching event + return $events[0]; + } + + /** + * Fetch event by naddr + * + * @param array $decoded Decoded naddr data + * @return object|null The event or null if not found + * @throws \Exception + */ + public function getEventByNaddr(array $decoded): ?object + { + $this->logger->info('Getting event by naddr', ['decoded' => $decoded]); + + // Extract required fields from decoded data + $kind = $decoded['kind'] ?? 30023; // Default to long-form content + $pubkey = $decoded['pubkey'] ?? ''; + $identifier = $decoded['identifier'] ?? ''; + $relays = $decoded['relays'] ?? []; + + if (empty($pubkey) || empty($identifier)) { + return null; + } + + // Try author's relays first + $authorRelays = empty($relays) ? $this->getTopReputableRelaysForAuthor($pubkey) : $relays; + $relaySet = $this->createRelaySet($authorRelays); + + // Create request using the helper method + $request = $this->createNostrRequest( + kinds: [$kind], + filters: [ + 'authors' => [$pubkey], + 'tag' => ['#d', [$identifier]] + ], + relaySet: $relaySet + ); + + // Process the response + $events = $this->processResponse($request->send(), function($event) { + return $event; + }); + + if (!empty($events)) { + return $events[0]; + } + + // Try default relays as fallback + $request = $this->createNostrRequest( + kinds: [$kind], + filters: [ + 'authors' => [$pubkey], + 'tag' => ['#d', [$identifier]] + ] + ); + + $events = $this->processResponse($request->send(), function($event) { + return $event; + }); + + 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) { @@ -347,13 +456,13 @@ class NostrClient $this->logger->info('Getting comments for coordinate', ['coordinate' => $coordinate]); // Get author from coordinate, then relays - $parts = explode(':', $coordinate); - if (count($parts) !== 3) { + $parts = explode(':', $coordinate, 3); + if (count($parts) < 3) { throw new \InvalidArgumentException('Invalid coordinate format, expected kind:pubkey:identifier'); } $kind = (int)$parts[0]; $pubkey = $parts[1]; - $identifier = $parts[2]; + $identifier = end($parts); // Get relays for the author $authorRelays = $this->getTopReputableRelaysForAuthor($pubkey); // Turn into a relaySet @@ -362,7 +471,7 @@ class NostrClient // Create request using the helper method $request = $this->createNostrRequest( kinds: [KindsEnum::COMMENTS->value, KindsEnum::TEXT_NOTE->value], - filters: ['tag' => ['#a', [$coordinate], '#p', [$pubkey]]], + filters: ['tag' => ['#A', [$coordinate]]], relaySet: $relaySet ); @@ -370,15 +479,8 @@ class NostrClient $uniqueEvents = []; $this->processResponse($request->send(), function($event) use (&$uniqueEvents, $pubkey) { $this->logger->debug('Received comment event', ['event_id' => $event->id]); - // If event has p tag with the pubkey, it's a comment - // Loop tags, look for 'p' tag - foreach ($event->tags as $tag) { - if ($tag[0] === 'p' && $tag[1] === $pubkey) { - $uniqueEvents[$event->id] = $event; - break; - } - } - return null; // We'll handle the collection ourselves + $uniqueEvents[$event->id] = $event; + return null; }); return array_values($uniqueEvents); @@ -649,31 +751,63 @@ class NostrClient private function processResponse(array $response, callable $eventHandler): array { $results = []; - foreach ($response as $relayRes) { - $this->logger->warning('Response from relay', $response); + foreach ($response as $relayUrl => $relayRes) { + // Skip if the relay response is an Exception + if ($relayRes instanceof \Exception) { + $this->logger->error('Relay error', [ + 'relay' => $relayUrl, + 'error' => $relayRes->getMessage() + ]); + continue; + } + + $this->logger->debug('Processing relay response', [ + 'relay' => $relayUrl, + 'response' => $relayRes + ]); + foreach ($relayRes as $item) { try { + if (!is_object($item)) { + $this->logger->warning('Invalid response item', [ + 'relay' => $relayUrl, + 'item' => $item + ]); + continue; + } + switch ($item->type) { case 'EVENT': - $this->logger->info('Processing event', ['event' => $item->event]); + $this->logger->debug('Processing event', [ + 'relay' => $relayUrl, + 'event_id' => $item->event->id ?? 'unknown' + ]); $result = $eventHandler($item->event); if ($result !== null) { $results[] = $result; } break; case 'AUTH': - $this->logger->warning('Relay requires authentication', ['response' => $item]); + $this->logger->warning('Relay requires authentication', [ + 'relay' => $relayUrl, + 'response' => $item + ]); break; case 'ERROR': case 'NOTICE': - $this->logger->error('Relay error/notice', ['response' => $item]); + $this->logger->warning('Relay error/notice', [ + 'relay' => $relayUrl, + 'type' => $item->type, + 'message' => $item->message ?? 'No message' + ]); break; } } catch (\Exception $e) { - $this->logger->error('Error processing event', [ - 'exception' => $e->getMessage(), - 'event' => $item + $this->logger->error('Error processing event from relay', [ + 'relay' => $relayUrl, + 'error' => $e->getMessage() ]); + continue; // Skip this item but continue processing others } } } @@ -698,4 +832,48 @@ class NostrClient } } } + + /** + * @param mixed $descriptor + * @return Event|null + */ + public function getEventFromDescriptor(mixed $descriptor): ?\stdClass + { + // Descriptor is an stdClass with properties: type and decoded + if (is_object($descriptor) && isset($descriptor->type, $descriptor->decoded)) { + // construct a request from the descriptor to fetch the event + /** @var Data $ata */ + $data = json_decode($descriptor->decoded); + // If id is set, search by id and kind + if (isset($data->id)) { + $request = $this->createNostrRequest( + kinds: [$data->kind], + filters: ['e' => [$data->id]], + relaySet: $this->defaultRelaySet + ); + } else { + $request = $this->createNostrRequest( + kinds: [$data->kind], + filters: ['authors' => [$data->pubkey], 'd' => [$data->identifier]], + relaySet: $this->defaultRelaySet + ); + } + + $events = $this->processResponse($request->send(), function($received) { + $this->logger->info('Getting event', ['item' => $received]); + return $received; + }); + + if (!empty($events)) { + // Return the first event found + return $events[0]; + } else { + $this->logger->warning('No events found for descriptor', ['descriptor' => $descriptor]); + return null; + } + } else { + $this->logger->error('Invalid descriptor format', ['descriptor' => $descriptor]); + return null; + } + } } diff --git a/src/Service/NostrLinkParser.php b/src/Service/NostrLinkParser.php new file mode 100644 index 0000000..1f82bd6 --- /dev/null +++ b/src/Service/NostrLinkParser.php @@ -0,0 +1,61 @@ + $decoded->type, + 'identifier' => $identifier, + 'full_match' => $fullMatch, + 'position' => $position, + 'data' => $decoded->data + ]; + } catch (\Exception $e) { + // If decoding fails, skip this identifier + $this->logger->info('Failed to decode Nostr identifier', [ + 'identifier' => $identifier, + 'error' => $e->getMessage() + ]); + continue; + } + } + + // Sort by position to maintain the original order in the text + usort($links, fn($a, $b) => $a['position'] <=> $b['position']); + } + + return $links; + } + +} diff --git a/src/Twig/Components/Atoms/Content.php b/src/Twig/Components/Atoms/Content.php new file mode 100644 index 0000000..f2ddbe1 --- /dev/null +++ b/src/Twig/Components/Atoms/Content.php @@ -0,0 +1,28 @@ +parsed = $this->converter->convertToHtml($content); + } catch (CommonMarkException) { + $this->parsed = $content; + } + } +} diff --git a/src/Twig/Components/Molecules/NostrPreview.php b/src/Twig/Components/Molecules/NostrPreview.php new file mode 100644 index 0000000..f2d7127 --- /dev/null +++ b/src/Twig/Components/Molecules/NostrPreview.php @@ -0,0 +1,16 @@ +preview = $preview; + } +} diff --git a/src/Twig/Components/Organisms/Comments.php b/src/Twig/Components/Organisms/Comments.php index 61f3b4d..cdb3db0 100644 --- a/src/Twig/Components/Organisms/Comments.php +++ b/src/Twig/Components/Organisms/Comments.php @@ -3,15 +3,21 @@ namespace App\Twig\Components\Organisms; use App\Service\NostrClient; +use App\Service\NostrLinkParser; use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; #[AsTwigComponent] final class Comments { public array $list = []; + public array $commentLinks = []; + public array $processedContent = []; - public function __construct(private readonly NostrClient $nostrClient) - { + public function __construct( + private readonly NostrClient $nostrClient, + private readonly NostrLinkParser $nostrLinkParser + + ) { } /** @@ -19,7 +25,34 @@ final class Comments */ public function mount($current): void { - // fetch comments, kind 1111 + // Fetch comments $this->list = $this->nostrClient->getComments($current); + + // Parse Nostr links in comments but don't fetch previews + $this->parseNostrLinks(); + } + + /** + * Parse Nostr links in comments for client-side loading + */ + private function parseNostrLinks(): void + { + foreach ($this->list as $comment) { + $content = $comment->content ?? ''; + if (empty($content)) { + continue; + } + + // Store the original content + $this->processedContent[$comment->id] = $content; + + // Parse the content for Nostr links + $links = $this->nostrLinkParser->parseLinks($content); + + if (!empty($links)) { + // Save the links for the client-side to fetch + $this->commentLinks[$comment->id] = $links; + } + } } } diff --git a/src/Twig/Filters.php b/src/Twig/Filters.php index 6377ce7..d6af724 100644 --- a/src/Twig/Filters.php +++ b/src/Twig/Filters.php @@ -48,7 +48,7 @@ class Filters extends AbstractExtension '/@(?npub1[0-9a-z]{10,})/i', function ($matches) { $npub = $matches['npub']; - $short = substr($npub, 0, 8) . '...' . substr($npub, -4); + $short = substr($npub, 0, 8) . '…' . substr($npub, -4); return sprintf( '@%s', diff --git a/symfony.lock b/symfony.lock index b93e9b6..bc0bc96 100644 --- a/symfony.lock +++ b/symfony.lock @@ -1,4 +1,13 @@ { + "doctrine/deprecations": { + "version": "1.1", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "1.0", + "ref": "87424683adc81d7dc305eefec1fced883084aab9" + } + }, "doctrine/doctrine-bundle": { "version": "2.13", "recipe": { diff --git a/templates/components/Atoms/Content.html.twig b/templates/components/Atoms/Content.html.twig new file mode 100644 index 0000000..a1ee806 --- /dev/null +++ b/templates/components/Atoms/Content.html.twig @@ -0,0 +1 @@ +
{{ parsed|raw }}
diff --git a/templates/components/Molecules/NostrPreview.html.twig b/templates/components/Molecules/NostrPreview.html.twig new file mode 100644 index 0000000..12ca3fd --- /dev/null +++ b/templates/components/Molecules/NostrPreview.html.twig @@ -0,0 +1,13 @@ +
+
+
+
+ Loading preview... +
+
+
diff --git a/templates/components/Molecules/NostrPreviewContent.html.twig b/templates/components/Molecules/NostrPreviewContent.html.twig new file mode 100644 index 0000000..c9d7918 --- /dev/null +++ b/templates/components/Molecules/NostrPreviewContent.html.twig @@ -0,0 +1,83 @@ +{% if preview.type == 'naddr' %} +
+ {% for tag in preview.tags %} + {% if tag[0] == 'title' %} +
+
{{ tag[1] }}
+
+ {% endif %} + {% if tag[0] == 'summary' %} +

{{ tag[1] }}

+ {% endif %} + {% endfor %} +
+{% elseif preview.type == 'nevent' %} + {# If kind is 9802 - Highlight #} + {% if preview.kind == 9802 %} +
+
+
+ +
+ Highlight +
+
+

{{ preview.content }}

+ {% if preview.tags is defined and preview.tags|length > 0 %} +
+ {% for tag in preview.tags %} + {% if tag[0] == 'textquoteselector' %} + {% for i in 1..tag|length-1 %} + {{ tag[i] }} + {% if not loop.last %} +
+ {% endif %} + {% endfor %} + {% endif %} + {% endfor %} +
+ {% endif %} +
+
+ {% else %} +
+
+
+ +
+ {% if preview.type == 'event' %} + Article + {% else %} + Note + {% endif %} +
+
+ {% if preview.type == 'event' %} +
{{ preview.title }}
+

+ {% if preview.event.summary is defined %} + {{ preview.event.summary }} + {% else %} + {{ preview.event.content|length > 150 ? preview.event.content|slice(0, 150) ~ '...' : preview.event.content }} + {% endif %} +

+ {% else %} +

{{ preview.content|length > 280 ? preview.content|slice(0, 280) ~ '...' : preview.content }}

+ {% endif %} +
+ +
+ {% endif %} +{% elseif preview.type == 'nprofile' %} +
+
+
{{ preview.display_name ?: preview.name }}
+ @{{ preview.npub|shortenNpub }} + {% if preview.about %} +

{{ preview.about|length > 150 ? preview.about|slice(0, 150) ~ '...' : preview.about }}

+ {% endif %} +
+
+{% endif %} diff --git a/templates/components/Organisms/Comments.html.twig b/templates/components/Organisms/Comments.html.twig index b342df3..f38a086 100644 --- a/templates/components/Organisms/Comments.html.twig +++ b/templates/components/Organisms/Comments.html.twig @@ -6,8 +6,20 @@ {{ item.created_at|date('F j Y') }}
-

{{ item.content }}

+
+ {# Display Nostr link previews if links detected #} + {% if commentLinks[item.id] is defined and commentLinks[item.id]|length > 0 %} + + {% endif %} {% endfor %} diff --git a/templates/pages/article.html.twig b/templates/pages/article.html.twig index 1969fc9..410e382 100644 --- a/templates/pages/article.html.twig +++ b/templates/pages/article.html.twig @@ -74,7 +74,7 @@ {#
#}
 {#        {{ article.content }}#}
 {#    
#} - + {% endblock %} {% block aside %}