Browse Source

Overhaul converter

imwald
Nuša Pukšič 3 months ago
parent
commit
4eef42e975
  1. 202
      assets/styles/03-components/nostr-previews.css
  2. 4
      src/Controller/ArticleController.php
  3. 14
      src/Service/NostrClient.php
  4. 80
      src/Util/CommonMark/Converter.php
  5. 2
      templates/components/Organisms/Comments.html.twig
  6. 158
      templates/components/event_card.html.twig
  7. 175
      templates/event/index.html.twig
  8. 4
      templates/pages/article.html.twig
  9. 1
      templates/partial/_gallery.html.twig

202
assets/styles/03-components/nostr-previews.css

@ -54,3 +54,205 @@ @@ -54,3 +54,205 @@
.nostr-link:hover {
background-color: rgba(108, 92, 231, 0.2);
}
.event-container {
max-width: 800px;
margin: 2rem auto;
background: #fff;
border-radius: 8px;
}
.event-header {
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid #eee;
padding: 1rem;
}
.event-content {
padding: 1rem;
font-size: 1.1rem;
line-height: 1.6;
}
.event-content img {
max-width: 100%;
}
/* NIP-68 Picture Event Styles */
.content-warning {
background-color: #fff3cd;
border: 2px solid #ffc107;
padding: 1rem;
border-radius: 8px;
margin-bottom: 1rem;
text-align: center;
}
.btn-show-nsfw {
margin-top: 0.5rem;
padding: 0.5rem 1rem;
background-color: #ffc107;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: bold;
}
.btn-show-nsfw:hover {
background-color: #e0a800;
}
.picture-location {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
color: #555;
font-size: 0.95rem;
}
.location-icon {
font-size: 1.2rem;
}
.geohash {
background-color: #e9ecef;
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-family: monospace;
font-size: 0.85rem;
cursor: help;
}
.picture-source {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
font-size: 0.95rem;
}
.source-label {
font-weight: 600;
color: #495057;
flex-shrink: 0;
}
.source-link {
color: #0066cc;
text-decoration: none;
word-break: break-all;
}
.source-link:hover {
text-decoration: underline;
}
.nostr-links {
margin: 1.5rem 0;
padding: 1rem;
background-color: #f9f9f9;
border-radius: 4px;
}
.link-list {
list-style: none;
padding-left: 0;
}
.link-list li {
word-break: break-all;
}
.link-type {
color: #6c757d;
font-size: 0.9rem;
margin-left: 0.5rem;
}
.event-footer {
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 1rem;
border-top: 1px solid #eee;
}
.event-tags {
flex: 1;
}
.event-tags ul, .event-references ul {
list-style-type: none;
padding-left: 0;
}
.event-tags li, .event-references li {
margin-bottom: 0.5rem;
}
.error {
color: #dc3545;
padding: 1rem;
text-align: center;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.picture-gallery {
grid-template-columns: 1fr;
}
}
.embedded-event-card {
border: none;
border-radius: 0;
padding: 12px;
margin: 8px 0;
background: #f8f9fa;
}
.embedded-event-card .event-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.embedded-event-card .avatar-small {
width: 24px;
height: 24px;
border-radius: 50%;
}
.embedded-event-card .author-info {
flex: 1;
}
.embedded-event-card .nip05 {
color: #6c757d;
font-size: 0.85em;
}
.embedded-event-card .event-meta {
color: #6c757d;
font-size: 0.8em;
}
.embedded-event-card .event-content {
margin: 8px 0;
line-height: 1.4;
}
.embedded-event-card .event-footer {
text-align: right;
}
.embedded-event-card .view-full {
color: #007bff;
text-decoration: none;
font-size: 0.85em;
}

4
src/Controller/ArticleController.php

@ -99,10 +99,10 @@ class ArticleController extends AbstractController @@ -99,10 +99,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());

14
src/Service/NostrClient.php

@ -48,7 +48,8 @@ class NostrClient @@ -48,7 +48,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://aggr.nostr.land')); // aggregator relay, has AUTH
$this->defaultRelaySet->addRelay(new Relay('wss://nostr.land')); // aggregator relay, has AUTH
$this->defaultRelaySet->addRelay(new Relay('wss://relay.primal.net')); // profile aggregator
}
/**
@ -107,7 +108,7 @@ class NostrClient @@ -107,7 +108,7 @@ class NostrClient
public function getPubkeyMetadata($pubkey): \stdClass
{
$relaySet = $this->defaultRelaySet;
$relaySet->addRelay(new Relay('wss://profiles.nostr1.com')); // profile aggregator
// $relaySet->addRelay(new Relay('wss://profiles.nostr1.com')); // profile aggregator
$relaySet->addRelay(new Relay('wss://purplepag.es')); // profile aggregator
$this->logger->info('Getting metadata for pubkey ' . $pubkey );
$request = $this->createNostrRequest(
@ -476,8 +477,11 @@ class NostrClient @@ -476,8 +477,11 @@ class NostrClient
// Create request using the helper method
$request = $this->createNostrRequest(
kinds: [KindsEnum::COMMENTS->value, KindsEnum::ZAP_RECEIPT->value],
filters: ['tag' => ['#a', [$coordinate]]],
kinds: [
KindsEnum::COMMENTS->value,
// KindsEnum::ZAP_RECEIPT->value // Not yet
],
filters: ['tag' => ['#A', [$coordinate]]], // #A means root event
relaySet: $relaySet
);
@ -961,7 +965,7 @@ class NostrClient @@ -961,7 +965,7 @@ class NostrClient
foreach ($relayRes as $item) {
try {
if (!is_object($item)) {
$this->logger->warning('Invalid response item', [
$this->logger->warning('Invalid response item from ' . $relayUrl , [
'relay' => $relayUrl,
'item' => $item
]);

80
src/Util/CommonMark/Converter.php

@ -2,6 +2,7 @@ @@ -2,6 +2,7 @@
namespace App\Util\CommonMark;
use App\Enum\KindsEnum;
use App\Service\NostrClient;
use App\Service\RedisCacheService;
use App\Util\CommonMark\ImagesExtension\RawImageLinkExtension;
@ -45,9 +46,6 @@ readonly class Converter @@ -45,9 +46,6 @@ readonly class Converter
*/
public function convertToHTML(string $markdown): string
{
// Preprocess nostr: links for batching
$markdown = $this->preprocessNostrLinks($markdown);
// Check if the article has more than three headings
// Match all headings (from level 1 to 6)
preg_match_all('/^#+\s.*$/m', $markdown, $matches);
@ -68,7 +66,7 @@ readonly class Converter @@ -68,7 +66,7 @@ readonly class Converter
],
'embed' => [
'adapter' => new OscaroteroEmbedAdapter(), // See the "Adapter" documentation below
'allowed_domains' => ['youtube.com', 'x.com', 'github.com', 'fountain.fm'],
'allowed_domains' => ['youtube.com', 'x.com', 'github.com', 'fountain.fm', 'blossom.primal.net', 'i.nostr.build'],
'fallback' => 'link'
],
];
@ -92,16 +90,19 @@ readonly class Converter @@ -92,16 +90,19 @@ readonly class Converter
$converter = new MarkdownConverter($environment);
$content = html_entity_decode($markdown);
return $converter->convert($content);
$html = $converter->convert($content);
// Process nostr links after conversion to avoid re-processing HTML
return $this->processNostrLinks($html);
}
private function preprocessNostrLinks(string $markdown): string
private function processNostrLinks(string $content): string
{
// Find all nostr: links
preg_match_all('/nostr:(?:npub1|nprofile1|note1|nevent1|naddr1)[^\\s<>()\\[\\]{}"\'`.,;:!?]*/', $markdown, $matches);
preg_match_all('/nostr:(?:npub1|nprofile1|note1|nevent1|naddr1)[^\\s<>()\\[\\]{}"\'`.,;:!?]*/', $content, $matches);
if (empty($matches[0])) {
return $markdown;
return $content;
}
$links = array_unique($matches[0]);
@ -125,7 +126,7 @@ readonly class Converter @@ -125,7 +126,7 @@ readonly class Converter
case 'nprofile':
/** @var NProfile $object */
$object = $decoded->data;
$pubkeys[$object->pubkey] = $bechEncoded;
$pubkeys[$object->pubkey] = $this->nostrKeyUtil->hexToNpub($object->pubkey);
break;
case 'note':
/** @var Note $object */
@ -147,16 +148,6 @@ readonly class Converter @@ -147,16 +148,6 @@ readonly class Converter
}
}
// Fetch metadata in batch (actually, getMetadata is cached, so just prepare)
$metadata = [];
foreach (array_keys($pubkeys) as $hex) {
try {
$metadata[$hex] = $this->redisCacheService->getMetadata($hex);
} catch (\Exception $e) {
$metadata[$hex] = null;
}
}
// Fetch events in batch
$events = [];
if (!empty($eventIds)) {
@ -167,6 +158,26 @@ readonly class Converter @@ -167,6 +158,26 @@ readonly class Converter
}
}
// Collect pubkeys from events for metadata fetching
$eventPubkeys = [];
foreach ($events as $event) {
$eventPubkeys[$event->pubkey] = true;
}
// Fetch metadata in batch
$allHexes = array_unique(array_merge(array_keys($pubkeys), array_keys($eventPubkeys)));
$metadata = [];
try {
$fetchedMetadata = $this->redisCacheService->getMultipleMetadata($allHexes);
foreach ($allHexes as $hex) {
$metadata[$hex] = $fetchedMetadata[$hex] ?? null;
}
} catch (\Exception $e) {
foreach ($allHexes as $hex) {
$metadata[$hex] = null;
}
}
// Now, render each link
foreach ($links as $link) {
$bechEncoded = substr($link, 6);
@ -180,8 +191,8 @@ readonly class Converter @@ -180,8 +191,8 @@ readonly class Converter
}
}
// Replace in markdown
return str_replace(array_keys($replacements), array_values($replacements), $markdown);
// Replace in content
return str_replace(array_keys($replacements), array_values($replacements), $content);
}
private function renderNostrLink(Bech32 $decoded, string $bechEncoded, array $metadata, array $events): string
@ -190,14 +201,12 @@ readonly class Converter @@ -190,14 +201,12 @@ readonly class Converter
case 'npub':
$hex = $this->nostrKeyUtil->npubToHex($bechEncoded);
$profile = $metadata[$hex] ?? null;
if ($profile && isset($profile->name)) {
return '<a href="/profile/' . $bechEncoded . '" class="nostr-mention">@' . htmlspecialchars($profile->name) . '</a>';
} else {
return '<a href="/profile/' . $bechEncoded . '" class="nostr-mention">@' . substr($bechEncoded, 5, 8) . '…</a>';
}
$label = $profile && isset($profile->name) ? $profile->name : $this->labelFromKey($bechEncoded);
return '<a href="/p/' . $bechEncoded . '" class="nostr-mention">@' . htmlspecialchars($label) . '</a>';
case 'nprofile':
$object = $decoded->data;
return '<a href="/profile/' . $bechEncoded . '" class="nostr-mention">@' . substr($bechEncoded, 9, 8) . '…</a>';
$label = $this->labelFromKey($bechEncoded);
return '<a href="/p/' . $bechEncoded . '" class="nostr-mention">@' . htmlspecialchars($label) . '</a>';
case 'note':
$object = $decoded->data;
$event = $events[$object->data] ?? null;
@ -208,7 +217,7 @@ readonly class Converter @@ -208,7 +217,7 @@ readonly class Converter
]);
return $pictureCardHtml;
} else {
return '<a href="/event/' . $bechEncoded . '" class="nostr-link">' . $bechEncoded . '</a>';
return '<a href="/e/' . $bechEncoded . '" class="nostr-link">' . $bechEncoded . '</a>';
}
case 'nevent':
$object = $decoded->data;
@ -222,13 +231,24 @@ readonly class Converter @@ -222,13 +231,24 @@ readonly class Converter
]);
return $eventCardHtml;
} else {
return '<a href="/event/' . $bechEncoded . '" class="nostr-link">' . $bechEncoded . '</a>';
return '<a href="/e/' . $bechEncoded . '" class="nostr-link">' . $bechEncoded . '</a>';
}
case 'naddr':
return '<a href="/event/' . $bechEncoded . '" class="nostr-link">' . $bechEncoded . '</a>';
if ($decoded->kind === KindsEnum::LONGFORM->value) {
return '<a href="/article/' . $bechEncoded . '" class="nostr-link">' . $bechEncoded . '</a>';
} else {
return '<a href="/e/' . $bechEncoded . '" class="nostr-link">' . $bechEncoded . '</a>';
}
default:
return $bechEncoded;
}
}
private function labelFromKey(string $npub): string
{
$start = substr($npub, 0, 5);
$end = substr($npub, -5);
return $start . '...' . $end;
}
}

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

@ -7,7 +7,7 @@ @@ -7,7 +7,7 @@
{% if loading %}
<div class="comments-loading" data-comments-mercure-target="loading">Loading comments…</div>
{% endif %}
<div class="comments-list" data-comments-mercure-target="list" {% if loading %}style="display:none"{% endif %}>
<div class="comments" data-comments-mercure-target="list" {% if loading %}style="display:none"{% endif %}>
{% for item in list %}
<div class="card comment {% if item.kind is defined and item.kind == '9735' %}zap-comment{% endif %}">
<div class="metadata">

158
templates/components/event_card.html.twig

@ -2,9 +2,6 @@ @@ -2,9 +2,6 @@
<div class="embedded-event-card" data-nevent="{{ nevent }}">
<div class="event-header">
{% if author %}
{% if author.image is defined %}
<img src="{{ author.image }}" class="avatar-small" alt="{{ author.name }}" onerror="this.style.display = 'none'" />
{% endif %}
<div class="author-info">
<strong>{{ author.name ?? 'Anonymous' }}</strong>
</div>
@ -13,162 +10,13 @@ @@ -13,162 +10,13 @@
<span class="event-date">{{ event.created_at|date('M j, Y H:i') }}</span>
</div>
</div>
<div class="event-content">
{{ event.content|markdown_to_html|mentionify }}
</div>
{# Collect all imeta images into an array #}
{% set images = [] %}
{% for tag in event.tags %}
{% if tag[0] == 'imeta' %}
{% set imageUrl = null %}
{% set mimeType = null %}
{% set blurhash = null %}
{% set dimensions = null %}
{% set altText = null %}
{% set fallbacks = [] %}
{% set annotatedUsers = [] %}
{# Parse imeta tag parameters #}
{% for i in 1..(tag|length - 1) %}
{% set param = tag[i] %}
{% if param starts with 'url ' %}
{% set imageUrl = param[4:] %}
{% elseif param starts with 'm ' %}
{% set mimeType = param[2:] %}
{% elseif param starts with 'blurhash ' %}
{% set blurhash = param[9:] %}
{% elseif param starts with 'dim ' %}
{% set dimensions = param[4:] %}
{% elseif param starts with 'alt ' %}
{% set altText = param[4:] %}
{% elseif param starts with 'fallback ' %}
{% set fallbacks = fallbacks|merge([param[9:]]) %}
{% elseif param starts with 'annotate-user ' %}
{% set annotatedUsers = annotatedUsers|merge([param[14:]]) %}
{% endif %}
{% endfor %}
{# Alt is also own tag #}
{% for altTag in event.tags %}
{% if altTag[0] == 'alt' %}
{% set altText = altTag[1] %}
{% endif %}
{% endfor %}
{% set images = images|merge([{
'url': imageUrl,
'mimeType': mimeType,
'blurhash': blurhash,
'dimensions': dimensions,
'altText': altText,
'fallbacks': fallbacks,
'annotatedUsers': annotatedUsers
}]) %}
{% endif %}
{% endfor %}
{% if images|length > 0 %}
<div class="gallery-view" data-controller="gallery">
<div class="main-image-wrapper">
{% set main = images[0] %}
<figure class="media">
<picture>
<img src="{{ main.url }}"
alt="{{ main.altText|default('Picture') }}"
{% if main.dimensions %}data-dimensions="{{ main.dimensions }}"{% endif %}
{% if main.blurhash %}data-blurhash="{{ main.blurhash }}"{% endif %}
class="picture-image main-image"
data-gallery-target="mainImage"
/>
{% for fallback in main.fallbacks %}
<source srcset="{{ fallback }}" />
{% endfor %}
</picture>
{% if images|length > 1 %}
<div class="thumbnails" data-gallery-target="thumbnails">
{% for img in images %}
<img src="{{ img.url }}"
alt="{{ img.altText|default('Picture') }}"
class="thumbnail{% if loop.first %} selected{% endif %}"
data-gallery-target="thumbnail"
data-action="click->gallery#switch"
data-gallery-index="{{ loop.index0 }}"
{% if img.dimensions %}data-dimensions="{{ img.dimensions }}"{% endif %}
{% if img.blurhash %}data-blurhash="{{ img.blurhash }}"{% endif %}
/>
{% endfor %}
</div>
{% endif %}
</figure>
{# Display annotated users for main image #}
{% if main.annotatedUsers|length > 0 %}
<div class="annotated-users">
{% for userAnnotation in main.annotatedUsers %}
{% set parts = userAnnotation|split(':') %}
{% if parts|length == 3 %}
<div class="user-tag" data-left="{{ parts[1] }}" data-top="{{ parts[2] }}" style="left: {{ parts[1] }}%; top: {{ parts[2] }}%;">
<twig:Molecules:UserFromNpub ident="{{ parts[0] }}" />
</div>
{% endif %}
{% endfor %}
</div>
{% endif %}
</div>
<div class="event-content">
<twig:Atoms:Content :content="event.content" />
</div>
{% endif %}
{% include 'partial/_gallery.html.twig' with {event: event, isEmbed: true} %}
<div class="event-footer">
<a href="/e/{{ nevent }}" class="view-full">View full event</a>
</div>
</div>
<style>
.embedded-event-card {
border: none;
border-radius: 0;
padding: 12px;
margin: 8px 0;
background: #f8f9fa;
}
.embedded-event-card .event-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.embedded-event-card .avatar-small {
width: 24px;
height: 24px;
border-radius: 50%;
}
.embedded-event-card .author-info {
flex: 1;
}
.embedded-event-card .nip05 {
color: #6c757d;
font-size: 0.85em;
}
.embedded-event-card .event-meta {
color: #6c757d;
font-size: 0.8em;
}
.embedded-event-card .event-content {
margin: 8px 0;
line-height: 1.4;
}
.embedded-event-card .event-footer {
text-align: right;
}
.embedded-event-card .view-full {
color: #007bff;
text-decoration: none;
font-size: 0.85em;
}
</style>

175
templates/event/index.html.twig

@ -171,182 +171,7 @@ @@ -171,182 +171,7 @@
</div>
{% endif %}
{% if is_granted('ROLE_ADMIN') %}
<h2>Raw Event Data</h2>
<pre>
{{ event|json_encode(constant('JSON_PRETTY_PRINT')) }}
</pre>
<div class="event-tags">
{% if event.tags is defined and event.tags|length > 0 %}
<ul>
{% for tag in event.tags %}
<li>
<strong>{{ tag[0] }}:</strong> {{ tag[1] }}
{% if tag[2] is defined %}
<span>{{ tag[2] }}</span>
{% endif %}
{% if tag[3] is defined %}
<span>{{ tag[3] }}</span>
{% endif %}
</li>
{% endfor %}
</ul>
{% endif %}
</div>
{% endif %}
</div>
</div>
{% endblock %}
{% block stylesheets %}
{{ parent() }}
<style>
.event-container {
max-width: 800px;
margin: 2rem auto;
background: #fff;
border-radius: 8px;
}
.event-header {
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid #eee;
padding: 1rem;
}
.event-content {
padding: 1rem;
font-size: 1.1rem;
line-height: 1.6;
}
/* NIP-68 Picture Event Styles */
.content-warning {
background-color: #fff3cd;
border: 2px solid #ffc107;
padding: 1rem;
border-radius: 8px;
margin-bottom: 1rem;
text-align: center;
}
.btn-show-nsfw {
margin-top: 0.5rem;
padding: 0.5rem 1rem;
background-color: #ffc107;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: bold;
}
.btn-show-nsfw:hover {
background-color: #e0a800;
}
.picture-location {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
color: #555;
font-size: 0.95rem;
}
.location-icon {
font-size: 1.2rem;
}
.geohash {
background-color: #e9ecef;
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-family: monospace;
font-size: 0.85rem;
cursor: help;
}
.picture-source {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
font-size: 0.95rem;
}
.source-label {
font-weight: 600;
color: #495057;
flex-shrink: 0;
}
.source-link {
color: #0066cc;
text-decoration: none;
word-break: break-all;
}
.source-link:hover {
text-decoration: underline;
}
.nostr-links {
margin: 1.5rem 0;
padding: 1rem;
background-color: #f9f9f9;
border-radius: 4px;
}
.link-list {
list-style: none;
padding-left: 0;
}
.link-list li {
word-break: break-all;
}
.link-type {
color: #6c757d;
font-size: 0.9rem;
margin-left: 0.5rem;
}
.event-footer {
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 1rem;
border-top: 1px solid #eee;
}
.event-tags {
flex: 1;
}
.event-tags ul, .event-references ul {
list-style-type: none;
padding-left: 0;
}
.event-tags li, .event-references li {
margin-bottom: 0.5rem;
}
.error {
color: #dc3545;
padding: 1rem;
text-align: center;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.picture-gallery {
grid-template-columns: 1fr;
}
}
</style>
{% endblock %}

4
templates/pages/article.html.twig

@ -16,10 +16,6 @@ @@ -16,10 +16,6 @@
<twig:Organisms:MagazineHero :mag="mag" :magazine="magazine" />
{% endif %}
<pre>
{{ article.topics|json_encode(constant('JSON_PRETTY_PRINT')) }}
</pre>
<div class="article-actions">
{% if canEdit %}
<a class="btn btn-primary" href="{{ path('editor-edit-slug', {'slug': article.slug}) }}">Edit article</a>

1
templates/partial/_gallery.html.twig

@ -129,3 +129,4 @@ @@ -129,3 +129,4 @@
</div>
</div>
{% endif %}
</div>

Loading…
Cancel
Save