Browse Source

Fix previews in comments

(cherry picked from commit 915d8883d4773bbf5216d595d7987d0f9dffe866)
imwald
Nuša Pukšič 5 months ago
parent
commit
c686cec5b8
  1. 1
      assets/app.js
  2. 76
      assets/controllers/nostr_preview_controller.js
  3. 2
      assets/styles/article.css
  4. 1
      assets/styles/card.css
  5. 2
      assets/styles/notice.css
  6. 11
      assets/styles/og.css
  7. 24
      assets/styles/theme.css
  8. 32
      src/Controller/DefaultController.php
  9. 86
      src/Service/NostrLinkParser.php
  10. 8
      templates/components/Molecules/Card.html.twig
  11. 14
      templates/components/Molecules/NostrPreview.html.twig
  12. 21
      templates/components/Molecules/OgPreview.html.twig
  13. 2
      templates/components/Organisms/Comments.html.twig

1
assets/app.js

@ -12,6 +12,7 @@ import './styles/layout.css';
import './styles/button.css'; import './styles/button.css';
import './styles/card.css'; import './styles/card.css';
import './styles/article.css'; import './styles/article.css';
import './styles/og.css';
import './styles/form.css'; import './styles/form.css';
import './styles/notice.css'; import './styles/notice.css';
import './styles/spinner.css'; import './styles/spinner.css';

76
assets/controllers/nostr_preview_controller.js

@ -16,34 +16,56 @@ export default class extends Controller {
async fetchPreview() { async fetchPreview() {
try { 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>'; 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); if (this.typeValue === 'url' && this.fullMatchValue) {
const data = { // Fetch OG preview for plain URLs
identifier: this.identifierValue, fetch("/og-preview/", {
type: this.typeValue, method: "POST",
decoded: this.decodedValue headers: {
}; "Content-Type": "application/json"
fetch("/preview/", { },
method: "POST", body: JSON.stringify({ url: this.fullMatchValue })
headers: { })
"Content-Type": "application/json" .then(res => {
}, if (!res.ok) {
body: JSON.stringify(data) throw new Error(`HTTP error! status: ${res.status}`);
}) }
.then(res => { return res.text();
if (!res.ok) { })
throw new Error(`HTTP error! status: ${res.status}`); .then(data => {
} this.containerTarget.innerHTML = data;
return res.text(); })
}) .catch(error => {
.then(data => { console.error("Error:", error);
console.log(data); this.containerTarget.innerHTML = `<div class="alert alert-warning">Unable to load OG preview for ${this.fullMatchValue}</div>`;
this.containerTarget.innerHTML = data; });
}) } else {
.catch(error => { // Fallback to Nostr preview
console.error("Error:", error); 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 => {
this.containerTarget.innerHTML = data;
})
.catch(error => {
console.error("Error:", error);
});
}
} catch (error) { } catch (error) {
console.error('Error fetching Nostr preview:', error); console.error('Error fetching Nostr preview:', error);
this.containerTarget.innerHTML = `<div class="alert alert-warning">Unable to load preview for ${this.fullMatchValue}</div>`; this.containerTarget.innerHTML = `<div class="alert alert-warning">Unable to load preview for ${this.fullMatchValue}</div>`;

2
assets/styles/article.css

@ -33,7 +33,7 @@
align-items: baseline; align-items: baseline;
margin: 2rem 0; margin: 2rem 0;
padding-top: 0.5rem; padding-top: 0.5rem;
border-top: 1px solid #8e4585; border-top: 1px solid var(--color-border);
font-size: 1rem; font-size: 1rem;
} }

1
assets/styles/card.css

@ -62,6 +62,7 @@ h2.card-title {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
margin-bottom: 10px;
} }
.card.comment .metadata p { .card.comment .metadata p {

2
assets/styles/notice.css

@ -9,6 +9,6 @@
} }
.notice.info { .notice.info {
background-color: rgba(95, 115, 85, 0.15); /* Light version of --color-primary */ background-color: var(--color-bg-light); /* Light version of --color-primary */
color: var(--color-text); /* Use theme text color for better contrast */ color: var(--color-text); /* Use theme text color for better contrast */
} }

11
assets/styles/og.css

@ -0,0 +1,11 @@
.og-preview-card {
max-width: 100%;
padding: 20px;
margin: 10px 0;
background-color: var(--color-bg);
}
.og-preview-image {
width: 100%;
height: auto;
}

24
assets/styles/theme.css

@ -1,6 +1,7 @@
@import url('https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,200..800;1,6..72,200..800&family=Playfair+Display:ital,wght@0,400..900;1,400..900&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,200..800;1,6..72,200..800&family=Playfair+Display:ital,wght@0,400..900;1,400..900&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Montserrat&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Montserrat&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Lobster&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Lobster&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@600;700&display=swap');
:root { :root {
@ -28,3 +29,26 @@
--color-text-contrast: #f4f1ee; --color-text-contrast: #f4f1ee;
--brand-color: black; --brand-color: black;
} }
[data-theme="space"] {
--color-bg: #141120; /* Deep violet-black */
--color-bg-light: #1e1a2e; /* Slightly lighter for contrast */
--color-bg-primary: #251634; /* Rich purple base (darkened brand tone) */
--color-text: #f4f0fc; /* Near-white with a lavender hue */
--color-text-mid: #c9b9eb; /* Soft pastel lavender */
--color-text-contrast: #0c0712; /* Near-black for contrast */
--color-primary: #c095f4; /* Vibrant purple (the new brand) */
--color-primary-light: #c9b9eb; /* Lighter variant for hovers/effects */
--color-secondary: #e8bb8d; /* Warm tan/peach for complementary balance */
--color-secondary-light: #f4d6b6; /* Lighter peach tone */
--color-secondary-bg: #fbebdb; /* Ultra-light background variant */
--color-border: #322c44; /* Subtle purple-gray */
--brand-color: rgb(147, 51, 234);
--brand-font: 'Poppins', sans-serif; /* A classic, refined branding font */
}

32
src/Controller/DefaultController.php

@ -10,6 +10,7 @@ use Exception;
use FOS\ElasticaBundle\Finder\FinderInterface; use FOS\ElasticaBundle\Finder\FinderInterface;
use Psr\Cache\InvalidArgumentException; use Psr\Cache\InvalidArgumentException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
use Symfony\Contracts\Cache\CacheInterface; use Symfony\Contracts\Cache\CacheInterface;
@ -160,4 +161,35 @@ class DefaultController extends AbstractController
'index' => $catIndex 'index' => $catIndex
]); ]);
} }
/**
* OG Preview endpoint for URLs
*/
#[Route('/og-preview/', name: 'og_preview', methods: ['POST'])]
public function ogPreview(RequestStack $requestStack): Response
{
$request = $requestStack->getCurrentRequest();
$data = json_decode($request->getContent(), true);
$url = $data['url'] ?? null;
if (!$url) {
return new Response('<div class="alert alert-warning">No URL provided.</div>', 400);
}
try {
$embed = new \Embed\Embed();
$info = $embed->get($url);
if (!$info) {
throw new \Exception('No OG data found');
}
return $this->render('components/Molecules/OgPreview.html.twig', [
'og' => [
'title' => $info->title,
'description' => $info->description,
'image' => $info->image,
'url' => $url
]
]);
} catch (\Exception $e) {
return new Response('<div class="alert alert-warning">Unable to load OG preview for ' . htmlspecialchars($url) . '</div>', 200);
}
}
} }

86
src/Service/NostrLinkParser.php

@ -7,8 +7,9 @@ use Psr\Log\LoggerInterface;
readonly class NostrLinkParser 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 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 URL_PATTERN = '/https?:\/\/[\w\-\.\?\,\'\/\\\+&%@\?\$#_=:\(\)~;]+/i';
public function __construct( public function __construct(
private LoggerInterface $logger private LoggerInterface $logger
@ -23,36 +24,95 @@ readonly class NostrLinkParser
public function parseLinks(string $content): array public function parseLinks(string $content): array
{ {
$links = []; $links = [];
$links = array_merge(
$this->parseUrlsWithNostrIds($content),
$this->parseBareNostrIdentifiers($content)
);
// Sort by position to maintain the original order in the text
usort($links, fn($a, $b) => $a['position'] <=> $b['position']);
return $links;
}
private function parseUrlsWithNostrIds(string $content): array
{
$links = [];
if (preg_match_all(self::URL_PATTERN, $content, $urlMatches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE)) {
foreach ($urlMatches as $urlMatch) {
$url = $urlMatch[0][0];
$position = $urlMatch[0][1];
$nostrId = null;
$nostrType = null;
$nostrData = null;
if (preg_match(self::NOSTR_LINK_PATTERN, $url, $nostrMatch)) {
$nostrId = $nostrMatch[1];
try {
$decoded = new Bech32($nostrId);
$nostrType = $decoded->type;
$nostrData = $decoded->data;
} catch (\Exception $e) {
$this->logger->info('Failed to decode Nostr identifier in URL', [
'identifier' => $nostrId,
'error' => $e->getMessage()
]);
}
}
$links[] = [
'type' => $nostrType ?? 'url',
'identifier' => $nostrId,
'full_match' => $url,
'position' => $position,
'data' => $nostrData,
'is_url' => true
];
}
}
return $links;
}
private function parseBareNostrIdentifiers(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)) { if (preg_match_all(self::NOSTR_LINK_PATTERN, $content, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE)) {
// If match starts with nostr:, continue otherwise check if part of URL
if (!(str_starts_with($matches[0][0][0], 'nostr:'))) {
// Check if the match is part of a URL, as path or query parameter
$urlPattern = '/https?:\/\/[\w\-.?,\'\/+&%$#@_=:()~;]+/i';
foreach ($matches as $key => $match) {
$position = $match[0][1];
// Check if the match is preceded by a URL
$precedingContent = substr($content, 0, $position);
if (preg_match($urlPattern, $precedingContent)) {
// If the match is preceded by a URL, skip it
unset($matches[$key]);
}
}
}
foreach ($matches as $match) { foreach ($matches as $match) {
$fullMatch = $match[0][0];
$identifier = $match[1][0]; $identifier = $match[1][0];
$position = $match[0][1]; // Position in the text $position = $match[0][1];
// This check will be handled in parseLinks by sorting and merging
try { try {
$decoded = new Bech32($identifier); $decoded = new Bech32($identifier);
$links[] = [ $links[] = [
'type' => $decoded->type, 'type' => $decoded->type,
'identifier' => $identifier, 'identifier' => $identifier,
'full_match' => $fullMatch, 'full_match' => $match[0][0],
'position' => $position, 'position' => $position,
'data' => $decoded->data 'data' => $decoded->data,
'is_url' => false
]; ];
} catch (\Exception $e) { } catch (\Exception $e) {
// If decoding fails, skip this identifier
$this->logger->info('Failed to decode Nostr identifier', [ $this->logger->info('Failed to decode Nostr identifier', [
'identifier' => $identifier, 'identifier' => $identifier,
'error' => $e->getMessage() '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; return $links;

8
templates/components/Molecules/Card.html.twig

@ -17,9 +17,11 @@
</div> </div>
<div class="card-body"> <div class="card-body">
<h2 class="card-title">{{ article.title }}</h2> <h2 class="card-title">{{ article.title }}</h2>
<p class="lede"> {% if article.summary %}
{{ article.summary }} <p class="lede">
</p> {{ article.summary }}
</p>
{% endif %}
</div> </div>
</a> </a>
<div class="card-footer"></div> <div class="card-footer"></div>

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

@ -9,5 +9,19 @@
<div class="spinner-border spinner-border-sm text-secondary" role="status"></div> <div class="spinner-border spinner-border-sm text-secondary" role="status"></div>
<span class="ms-2">Loading preview...</span> <span class="ms-2">Loading preview...</span>
</div> </div>
{% if preview.full_match is defined and preview.full_match %}
<div class="nostr-preview-link mt-2">
<a href="{{ preview.full_match }}" target="_blank" rel="noopener noreferrer">{{ preview.full_match }}</a>
</div>
{% endif %}
{% if preview.data is not null %}
<div class="nostr-preview-details mt-2">
{# Example: show kind if available #}
{% if preview.data.kind is defined %}
<span class="badge bg-info">Kind: {{ preview.data.kind }}</span>
{% endif %}
{# Add more event details here as needed #}
</div>
{% endif %}
</div> </div>
</div> </div>

21
templates/components/Molecules/OgPreview.html.twig

@ -0,0 +1,21 @@
<div class="og-preview-card">
{% if og.image %}
<div class="og-preview-image mb-2">
<a href="{{ og.url }}" target="_blank" rel="noopener noreferrer">
<img src="{{ og.image }}" alt="OG image for {{ og.title }}" />
</a>
</div>
{% endif %}
<div class="og-preview-content">
{% if og.title %}
<a href="{{ og.url }}" target="_blank" rel="noopener noreferrer"><small>{{ og.url }}</small></a>
{% endif %}
<div class="og-preview-title">
<a href="{{ og.url }}" target="_blank" rel="noopener noreferrer"><b>{{ og.title ?: og.url }}</b></a>
</div>
{% if og.description %}
<div class="og-preview-description text-muted">{{ og.description }}</div>
{% endif %}
</div>
</div>

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

@ -27,7 +27,7 @@
<div class="card-footer nostr-previews mt-3"> <div class="card-footer nostr-previews mt-3">
<div class="preview-container"> <div class="preview-container">
{% for link in commentLinks[item.id] %} {% for link in commentLinks[item.id] %}
<div class="mb-3"> <div>
<twig:Molecules:NostrPreview preview="{{ link }}" /> <twig:Molecules:NostrPreview preview="{{ link }}" />
</div> </div>
{% endfor %} {% endfor %}

Loading…
Cancel
Save