Browse Source

Try out loading comments and previews for articles

imwald
Nuša Pukšič 8 months ago
parent
commit
f8c3511b97
  1. 52
      assets/controllers/nostr_preview_controller.js
  2. 3
      assets/styles/app.scss
  3. 52
      assets/styles/components/_nostr_previews.scss
  4. 4
      assets/styles/layout.css
  5. 1
      composer.json
  6. 46
      composer.lock
  7. 105
      src/Controller/ArticleController.php
  8. 224
      src/Service/NostrClient.php
  9. 61
      src/Service/NostrLinkParser.php
  10. 28
      src/Twig/Components/Atoms/Content.php
  11. 16
      src/Twig/Components/Molecules/NostrPreview.php
  12. 39
      src/Twig/Components/Organisms/Comments.php
  13. 2
      src/Twig/Filters.php
  14. 9
      symfony.lock
  15. 1
      templates/components/Atoms/Content.html.twig
  16. 13
      templates/components/Molecules/NostrPreview.html.twig
  17. 83
      templates/components/Molecules/NostrPreviewContent.html.twig
  18. 14
      templates/components/Organisms/Comments.html.twig
  19. 2
      templates/pages/article.html.twig

52
assets/controllers/nostr_preview_controller.js

@ -0,0 +1,52 @@ @@ -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 = '<div class="text-center my-2"><div class="spinner-border spinner-border-sm text-secondary" role="status"></div> Loading preview...</div>';
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 = `<div class="alert alert-warning">Unable to load preview for ${this.fullMatchValue}</div>`;
}
}
}

3
assets/styles/app.scss

@ -0,0 +1,3 @@ @@ -0,0 +1,3 @@
// ...existing code...
@import "components/nostr_previews";
// ...existing code...

52
assets/styles/components/_nostr_previews.scss

@ -0,0 +1,52 @@ @@ -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);
}
}

4
assets/styles/layout.css

@ -106,9 +106,9 @@ main { @@ -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 {

1
composer.json

@ -22,6 +22,7 @@ @@ -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",

46
composer.lock generated

@ -4,7 +4,7 @@ @@ -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 @@ @@ -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",

105
src/Controller/ArticleController.php

@ -7,10 +7,11 @@ use App\Enum\KindsEnum; @@ -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 @@ -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 @@ -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 @@ -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 = '<span>Error fetching preview: ' . htmlspecialchars($e->getMessage()) . '</span>';
}
}
return new Response(
$html,
Response::HTTP_OK,
['Content-Type' => 'text/html']
);
}
/**
* Create new article

224
src/Service/NostrClient.php

@ -7,6 +7,7 @@ use App\Enum\KindsEnum; @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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;
}
}
}

61
src/Service/NostrLinkParser.php

@ -0,0 +1,61 @@ @@ -0,0 +1,61 @@
<?php
namespace App\Service;
use nostriphant\NIP19\Bech32;
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]+)/';
public function __construct(
private LoggerInterface $logger
) {}
/**
* Parse content for Nostr links and return structured data
*
* @param string $content The content to parse
* @return array Array of detected Nostr links with their type and decoded data
*/
public function parseLinks(string $content): array
{
$links = [];
// Improved regular expression to match all nostr: links
// This will find all occurrences including multiple links in the same text
if (preg_match_all(self::NOSTR_LINK_PATTERN, $content, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE)) {
foreach ($matches as $match) {
$fullMatch = $match[0][0];
$identifier = $match[1][0];
$position = $match[0][1]; // Position in the text
try {
$decoded = new Bech32($identifier);
$links[] = [
'type' => $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;
}
}

28
src/Twig/Components/Atoms/Content.php

@ -0,0 +1,28 @@ @@ -0,0 +1,28 @@
<?php
namespace App\Twig\Components\Atoms;
use App\Util\CommonMark\Converter;
use League\CommonMark\Exception\CommonMarkException;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[AsTwigComponent]
class Content
{
public string $parsed = '';
public function __construct(
private readonly Converter $converter
) {
}
/**
*/
public function mount($content): void
{
try {
$this->parsed = $this->converter->convertToHtml($content);
} catch (CommonMarkException) {
$this->parsed = $content;
}
}
}

16
src/Twig/Components/Molecules/NostrPreview.php

@ -0,0 +1,16 @@ @@ -0,0 +1,16 @@
<?php
namespace App\Twig\Components\Molecules;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[AsTwigComponent]
final class NostrPreview
{
public array $preview;
public function mount(array $preview): void
{
$this->preview = $preview;
}
}

39
src/Twig/Components/Organisms/Comments.php

@ -3,15 +3,21 @@ @@ -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 @@ -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;
}
}
}
}

2
src/Twig/Filters.php

@ -48,7 +48,7 @@ class Filters extends AbstractExtension @@ -48,7 +48,7 @@ class Filters extends AbstractExtension
'/@(?<npub>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(
'<a href="/p/%s" class="mention-link">@%s</a>',

9
symfony.lock

@ -1,4 +1,13 @@ @@ -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": {

1
templates/components/Atoms/Content.html.twig

@ -0,0 +1 @@ @@ -0,0 +1 @@
<div>{{ parsed|raw }}</div>

13
templates/components/Molecules/NostrPreview.html.twig

@ -0,0 +1,13 @@ @@ -0,0 +1,13 @@
<div class="nostr-preview"
data-controller="nostr-preview"
data-nostr-preview-identifier-value="{{ preview.identifier }}"
data-nostr-preview-type-value="{{ preview.type }}"
data-nostr-preview-decoded-value="{{ preview.data|json_encode }}"
data-nostr-preview-full-match-value="{{ preview.full_match }}">
<div data-nostr-preview-target="container">
<div class="text-center my-2">
<div class="spinner-border spinner-border-sm text-secondary" role="status"></div>
<span class="ms-2">Loading preview...</span>
</div>
</div>
</div>

83
templates/components/Molecules/NostrPreviewContent.html.twig

@ -0,0 +1,83 @@ @@ -0,0 +1,83 @@
{% if preview.type == 'naddr' %}
<div class="card nostr-address-preview">
{% for tag in preview.tags %}
{% if tag[0] == 'title' %}
<div class="card-header">
<h5 class="card-title">{{ tag[1] }}</h5>
</div>
{% endif %}
{% if tag[0] == 'summary' %}
<p class="card-text">{{ tag[1] }}</p>
{% endif %}
{% endfor %}
</div>
{% elseif preview.type == 'nevent' %}
{# If kind is 9802 - Highlight #}
{% if preview.kind == 9802 %}
<div class="card nostr-highlight-preview">
<div class="card-header d-flex justify-content-between align-items-center">
<div>
<twig:Molecules:UserFromNpub ident="{{ preview.pubkey }}" />
</div>
<span class="badge bg-warning">Highlight</span>
</div>
<div class="card-body">
<p>{{ preview.content }}</p>
{% if preview.tags is defined and preview.tags|length > 0 %}
<blockquote class="card-text">
{% for tag in preview.tags %}
{% if tag[0] == 'textquoteselector' %}
{% for i in 1..tag|length-1 %}
<span class="quoted-text">{{ tag[i] }}</span>
{% if not loop.last %}
<br>
{% endif %}
{% endfor %}
{% endif %}
{% endfor %}
</blockquote>
{% endif %}
</div>
</div>
{% else %}
<div class="card nostr-event-preview">
<div class="card-header d-flex justify-content-between align-items-center">
<div>
<twig:Molecules:UserFromNpub ident="{{ preview.author }}" />
</div>
{% if preview.type == 'event' %}
<span class="badge bg-secondary">Article</span>
{% else %}
<span class="badge bg-info">Note</span>
{% endif %}
</div>
<div class="card-body">
{% if preview.type == 'event' %}
<h5 class="card-title">{{ preview.title }}</h5>
<p class="card-text">
{% 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 %}
</p>
{% else %}
<p class="card-text">{{ preview.content|length > 280 ? preview.content|slice(0, 280) ~ '...' : preview.content }}</p>
{% endif %}
</div>
<div class="card-footer text-muted">
<small>{{ preview.event.created_at is defined ? preview.event.created_at|date('F j Y') : '' }}</small>
</div>
</div>
{% endif %}
{% elseif preview.type == 'nprofile' %}
<div class="card nostr-profile-preview">
<div class="card-body d-flex">
<h5 class="card-title">{{ preview.display_name ?: preview.name }} </h5>
<small class="text-muted">@{{ preview.npub|shortenNpub }}</small>
{% if preview.about %}
<p class="card-text">{{ preview.about|length > 150 ? preview.about|slice(0, 150) ~ '...' : preview.about }}</p>
{% endif %}
</div>
</div>
{% endif %}

14
templates/components/Organisms/Comments.html.twig

@ -6,8 +6,20 @@ @@ -6,8 +6,20 @@
<small>{{ item.created_at|date('F j Y') }}</small>
</div>
<div class="card-body">
<p>{{ item.content }}</p>
<twig:Atoms:Content content="{{ item.content }}" />
</div>
{# Display Nostr link previews if links detected #}
{% if commentLinks[item.id] is defined and commentLinks[item.id]|length > 0 %}
<div class="card-footer nostr-previews mt-3">
<div class="preview-container">
{% for link in commentLinks[item.id] %}
<div class="mb-3">
<twig:Molecules:NostrPreview preview="{{ link }}" />
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
{% endfor %}
</div>

2
templates/pages/article.html.twig

@ -74,7 +74,7 @@ @@ -74,7 +74,7 @@
{# <pre>#}
{# {{ article.content }}#}
{# </pre>#}
<twig:Organisms:Comments current="30023:{{ article.pubkey }}:{{ article.slug }}"></twig:Organisms:Comments>
<twig:Organisms:Comments current="30023:{{ article.pubkey }}:{{ article.slug|e }}"></twig:Organisms:Comments>
{% endblock %}
{% block aside %}

Loading…
Cancel
Save