Browse Source

add event menus

imwald
Silberengel 6 days ago
parent
commit
f6703e2f4b
  1. 4
      assets/styles/app.css
  2. 86
      assets/styles/layout.css
  3. 31
      config/packages/monolog.yaml
  4. 2
      config/services.yaml
  5. 2
      config/unfold.yaml
  6. 88
      src/Controller/ArticleController.php
  7. 5
      src/Controller/AuthorController.php
  8. 27
      src/Controller/EventController.php
  9. 3
      src/Controller/FeaturedAuthorsController.php
  10. 13
      src/Controller/SeoController.php
  11. 21
      src/Dto/NostrShareMenuContext.php
  12. 73
      src/Nostr/Nip19Addressable.php
  13. 52
      src/Service/NostrPathHelper.php
  14. 348
      src/Service/NostrShareMenuBuilder.php
  15. 40
      src/Twig/NostrPathExtension.php
  16. 37
      src/Twig/NostrShareMenuExtension.php
  17. 3
      templates/components/Header.html.twig
  18. 2
      templates/components/Molecules/Card.html.twig
  19. 14
      templates/components/Molecules/NostrPreviewContent.html.twig
  20. 31
      templates/components/Molecules/NostrShareMenu.html.twig
  21. 4
      templates/components/Organisms/Comments.html.twig
  22. 4
      templates/components/Organisms/FeaturedList.html.twig
  23. 2
      templates/pages/article.html.twig
  24. 1
      templates/pages/author.html.twig
  25. 5
      templates/pages/featured_authors.html.twig
  26. 8
      templates/partial/author_profile_header.html.twig

4
assets/styles/app.css

@ -351,6 +351,10 @@ div:nth-child(odd) .featured-list {
flex-shrink: 0; flex-shrink: 0;
margin-left: 0; margin-left: 0;
} }
.header__end {
flex-shrink: 0;
}
} }
/* Fixed square + overflow clips to a true circle. Logo img is out-of-flow so /* Fixed square + overflow clips to a true circle. Logo img is out-of-flow so

86
assets/styles/layout.css

@ -77,6 +77,75 @@ nav a:hover {
font-size: 26px; font-size: 26px;
} }
/* Trailing tools: Nostr ⋯ menu + hamburger (mobile) */
.header__end {
display: flex;
flex-shrink: 0;
align-items: center;
gap: 0.4rem;
}
/* NIP-19 share menu (header) */
.nostr-share-menu {
position: relative;
list-style: none;
}
.nostr-share-menu__trigger {
min-width: 2.25rem;
font-size: 1.15rem;
line-height: 1.2;
padding: 0.2rem 0.45rem;
list-style: none;
}
.nostr-share-menu__trigger::-webkit-details-marker {
display: none;
}
.nostr-share-menu__list {
position: absolute;
z-index: 1002;
right: 0;
top: calc(100% + 4px);
margin: 0;
padding: 0.35rem 0;
min-width: 12rem;
list-style: none;
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
}
.nostr-share-menu__item {
margin: 0;
padding: 0;
}
.nostr-share-menu__action {
display: block;
width: 100%;
text-align: left;
padding: 0.45rem 0.75rem;
font: inherit;
color: var(--color-text, inherit);
text-decoration: none;
background: none;
border: none;
cursor: pointer;
border-radius: 0;
}
.nostr-share-menu__action:hover,
.nostr-share-menu__action:focus-visible {
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
}
a.nostr-share-menu__action {
color: var(--color-primary, inherit);
}
.header__logo { .header__logo {
display: flex; display: flex;
width: 100%; width: 100%;
@ -187,6 +256,23 @@ nav a:hover {
.header__mobile-account { .header__mobile-account {
display: none; display: none;
} }
/* Center the title; keep Nostr menu + hamburger on the right without shifting the brand. */
.header__logo {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
}
.header__brand {
grid-column: 2;
justify-self: center;
}
.header__end {
grid-column: 3;
justify-self: end;
}
} }
/* Main content */ /* Main content */

31
config/packages/monolog.yaml

@ -1,3 +1,7 @@
# Dev: debug → dev.log only; info and above → dev.log + stderr.
# Prod: debug–notice → prod.log only; warning+ → prod.log + stderr. Deprecations: stderr (JSON) only.
# Log rotation: Monolog’s rotating_file rolls daily and keeps the last N files (caps growth; not a strict MB cap).
monolog: monolog:
channels: channels:
- deprecation # Deprecations are logged in the dedicated "deprecation" channel when it exists - deprecation # Deprecations are logged in the dedicated "deprecation" channel when it exists
@ -5,19 +9,21 @@ monolog:
when@dev: when@dev:
monolog: monolog:
handlers: handlers:
# Group: write to the log file and stderr so `docker compose logs` shows app output. # Each member gets every record; level filters which are actually written.
main: main:
type: group type: group
members: [file, docker] members: [file, docker]
channels: ["!event"] channels: ["!event"]
file: file:
type: stream type: rotating_file
path: "%kernel.logs_dir%/%kernel.environment%.log" path: "%kernel.logs_dir%/%kernel.environment%.log"
level: debug level: debug
max_files: 14
docker: docker:
type: stream type: stream
path: "php://stderr" path: "php://stderr"
level: debug # Min level info: debug stays out of stderr (file only).
level: info
console: console:
type: console type: console
process_psr_3_messages: false process_psr_3_messages: false
@ -40,17 +46,20 @@ when@test:
when@prod: when@prod:
monolog: monolog:
handlers: handlers:
# No fingers_crossed: we split explicitly between file (all) and stderr (warning+ only).
main: main:
type: fingers_crossed type: group
action_level: error members: [file, stderr]
handler: nested channels: ["!deprecation", "!event"]
excluded_http_codes: [404, 405] file:
channels: ["!deprecation"] type: rotating_file
buffer_size: 50 # How many messages should be saved? Prevent memory leaks path: "%kernel.logs_dir%/%kernel.environment%.log"
nested: level: debug
max_files: 30
stderr:
type: stream type: stream
path: php://stderr path: php://stderr
level: debug level: warning
formatter: monolog.formatter.json formatter: monolog.formatter.json
console: console:
type: console type: console

2
config/services.yaml

@ -45,6 +45,8 @@ services:
arguments: arguments:
$footerLinksPath: '%footer_links%' $footerLinksPath: '%footer_links%'
tags: [ 'twig.extension' ] tags: [ 'twig.extension' ]
App\Twig\NostrShareMenuExtension:
tags: [ 'twig.extension' ]
# Nostr index snapshots: distinct key prefix from other cache.app users. # Nostr index snapshots: distinct key prefix from other cache.app users.
App\Service\MagazineIndexStore: App\Service\MagazineIndexStore:
arguments: arguments:

2
config/unfold.yaml

@ -35,6 +35,8 @@ parameters:
nip05_domain: 'blog.imwald.eu' nip05_domain: 'blog.imwald.eu'
# Base URL for "Open in Jumble" on author profile (trailing slash optional; npub is appended as /{npub}). # Base URL for "Open in Jumble" on author profile (trailing slash optional; npub is appended as /{npub}).
jumble_profile_users_base: 'https://jumble.imwald.eu/users' jumble_profile_users_base: 'https://jumble.imwald.eu/users'
# Base for event threads: {base}/{nevent1...} (NIP-19 nevent, not raw hex id).
jumble_feed_notes_base: 'https://jumble.imwald.eu/feed/notes'
# Comma-separated category #d slugs to fetch first in app:prewarm after the root (see MagazineRefresher). # Comma-separated category #d slugs to fetch first in app:prewarm after the root (see MagazineRefresher).
magazine_prewarm_prefer_slugs_empty: '' magazine_prewarm_prefer_slugs_empty: ''
magazine_prewarm_prefer_slugs: '%env(default:magazine_prewarm_prefer_slugs_empty:MAGAZINE_PREWARM_PREFER_SLUGS)%' magazine_prewarm_prefer_slugs: '%env(default:magazine_prewarm_prefer_slugs_empty:MAGAZINE_PREWARM_PREFER_SLUGS)%'

88
src/Controller/ArticleController.php

@ -272,7 +272,9 @@ class ArticleController extends AbstractController
$nostrClient->getLongFormFromNaddr($slug, $relays, $author, $kind); $nostrClient->getLongFormFromNaddr($slug, $relays, $author, $kind);
if ($slug) { if ($slug) {
return $this->redirectToRoute('article-slug', ['slug' => $slug]); $npub = (new Key())->convertPublicKeyToBech32((string) $author);
return $this->redirectToRoute('article', ['npub' => $npub, 'slug' => $slug], Response::HTTP_MOVED_PERMANENTLY);
} }
throw new \Exception('No article.'); throw new \Exception('No article.');
@ -283,13 +285,14 @@ class ArticleController extends AbstractController
*/ */
// Slug is the NIP-33 d-identifier and may contain "/"; default [^/]++ would break sitemap/URL generation. // Slug is the NIP-33 d-identifier and may contain "/"; default [^/]++ would break sitemap/URL generation.
#[Route( #[Route(
path: '/article/d/{slug}', path: '/p/{npub}/d/{slug}',
name: 'article-slug', name: 'article',
requirements: ['slug' => '.+'], requirements: ['npub' => '^npub1.*', 'slug' => '.+'],
options: ['utf8' => true], options: ['utf8' => true],
)] )]
public function article( public function article(
$slug, string $npub,
string $slug,
EntityManagerInterface $entityManager, EntityManagerInterface $entityManager,
CacheService $cacheService, CacheService $cacheService,
CacheItemPoolInterface $articlesCache, CacheItemPoolInterface $articlesCache,
@ -297,31 +300,76 @@ class ArticleController extends AbstractController
ArticleCommentThreadLoader $commentThreadLoader ArticleCommentThreadLoader $commentThreadLoader
): Response ): Response
{ {
$article = $this->loadLatestArticleBySlug($entityManager, $slug);
if ($article === null) {
throw $this->createNotFoundException('The article could not be found');
}
$key = new Key();
if ($key->convertToHex($npub) !== strtolower((string) $article->getPubkey())) {
throw $this->createNotFoundException('The article could not be found');
}
set_time_limit(300); // 5 minutes return $this->renderArticle(
ini_set('max_execution_time', '300'); $article,
$cacheService,
$articlesCache,
$converter,
$commentThreadLoader
);
}
$article = null; /**
// check if an item with same eventId already exists in the db * Legacy: /article/d/{slug} → 301 to /p/{npub}/d/{slug} (NIP-33 with author npub in path).
*/
#[Route(
path: '/article/d/{slug}',
name: 'article-legacy-redirect',
requirements: ['slug' => '.+'],
options: ['utf8' => true],
)]
public function articleLegacyRedirect(
string $slug,
EntityManagerInterface $entityManager,
): Response {
$article = $this->loadLatestArticleBySlug($entityManager, $slug);
if ($article === null) {
throw $this->createNotFoundException('The article could not be found');
}
$key = new Key();
$npub = $key->convertPublicKeyToBech32((string) $article->getPubkey());
return $this->redirectToRoute('article', ['npub' => $npub, 'slug' => $slug], Response::HTTP_MOVED_PERMANENTLY);
}
private function loadLatestArticleBySlug(EntityManagerInterface $entityManager, string $slug): ?Article
{
$repository = $entityManager->getRepository(Article::class); $repository = $entityManager->getRepository(Article::class);
$articles = $repository->findBy(['slug' => $slug]); $articles = $repository->findBy(['slug' => $slug]);
$revisions = count($articles); $revisions = \count($articles);
if ($revisions === 0) { if ($revisions === 0) {
throw $this->createNotFoundException('The article could not be found'); return null;
} }
if ($revisions > 1) { if ($revisions > 1) {
// sort articles by created at date
usort($articles, function ($a, $b) { usort($articles, function ($a, $b) {
return $b->getCreatedAt() <=> $a->getCreatedAt(); return $b->getCreatedAt() <=> $a->getCreatedAt();
}); });
// get the last article
$article = end($articles); return end($articles);
} else {
$article = $articles[0];
} }
return $articles[0];
}
private function renderArticle(
Article $article,
CacheService $cacheService,
CacheItemPoolInterface $articlesCache,
Converter $converter,
ArticleCommentThreadLoader $commentThreadLoader
): Response {
set_time_limit(300); // 5 minutes
ini_set('max_execution_time', '300');
$cacheKey = 'article_'.$article->getId(); $cacheKey = 'article_'.$article->getId();
$cacheItem = $articlesCache->getItem($cacheKey); $cacheItem = $articlesCache->getItem($cacheKey);
if (!$cacheItem->isHit()) { if (!$cacheItem->isHit()) {
@ -335,7 +383,7 @@ class ArticleController extends AbstractController
$kind = $article->getKind()?->value ?? 30023; $kind = $article->getKind()?->value ?? 30023;
$pubkey = (string) $article->getPubkey(); $pubkey = (string) $article->getPubkey();
$articleSlug = (string) ($article->getSlug() ?? $slug); $articleSlug = (string) $article->getSlug();
$coordinate = $kind.':'.$pubkey.':'.$articleSlug; $coordinate = $kind.':'.$pubkey.':'.$articleSlug;
$eid = $article->getEventId(); $eid = $article->getEventId();
$eid = ($eid !== null && $eid !== '' && self::isValidHexEventId($eid)) ? $eid : null; $eid = ($eid !== null && $eid !== '' && self::isValidHexEventId($eid)) ? $eid : null;
@ -518,11 +566,13 @@ class ArticleController extends AbstractController
$article = $cacheItem->get(); $article = $cacheItem->get();
$content = $converter->convertToHtml($article->getContent()); $content = $converter->convertToHtml($article->getContent());
$previewNpub = (new Key())->convertPublicKeyToBech32($currentPubkey);
return $this->render('pages/article.html.twig', [ return $this->render('pages/article.html.twig', [
'article' => $article, 'article' => $article,
'content' => $content, 'content' => $content,
'author' => $user->getMetadata(), 'author' => $user->getMetadata(),
'npub' => $previewNpub,
'comments_preloaded' => false, 'comments_preloaded' => false,
]); ]);
} }

5
src/Controller/AuthorController.php

@ -75,10 +75,6 @@ class AuthorController extends AbstractController
} }
$extraPayto = $profilePaymentLinks->collectPaytoUrisFromNipA3Kind10133Events($kind10133); $extraPayto = $profilePaymentLinks->collectPaytoUrisFromNipA3Kind10133Events($kind10133);
$jumbleBase = (string) $this->getParameter('jumble_profile_users_base');
$jumbleBase = rtrim($jumbleBase, '/');
$jumbleProfileHref = $jumbleBase !== '' ? $jumbleBase.'/'.$npub : null;
$profileNip05 = $profileIdentityLinks->buildNip05($author, $kind0Tags); $profileNip05 = $profileIdentityLinks->buildNip05($author, $kind0Tags);
$fa = $featuredAuthorRepository->findOneByPubkeyHex($pubkey); $fa = $featuredAuthorRepository->findOneByPubkeyHex($pubkey);
if ($fa !== null && $fa->isListed()) { if ($fa !== null && $fa->isListed()) {
@ -96,7 +92,6 @@ class AuthorController extends AbstractController
'profile_websites' => $profileIdentityLinks->buildWebsites($author, $kind0Tags), 'profile_websites' => $profileIdentityLinks->buildWebsites($author, $kind0Tags),
'profile_nip05' => $profileNip05, 'profile_nip05' => $profileNip05,
'profile_payment_links' => $profilePaymentLinks->buildPaymentRows($author, $kind0Tags, $extraPayto), 'profile_payment_links' => $profilePaymentLinks->buildPaymentRows($author, $kind0Tags, $extraPayto),
'jumble_profile_href' => $jumbleProfileHref,
]); ]);
} }

27
src/Controller/EventController.php

@ -6,6 +6,7 @@ namespace App\Controller;
use App\Service\NostrClient; use App\Service\NostrClient;
use App\Service\NostrLinkParser; use App\Service\NostrLinkParser;
use App\Service\NostrShareMenuBuilder;
use App\Service\CacheService; use App\Service\CacheService;
use Exception; use Exception;
use nostriphant\NIP19\Bech32; use nostriphant\NIP19\Bech32;
@ -13,6 +14,7 @@ use nostriphant\NIP19\Data;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use swentel\nostr\Key\Key; use swentel\nostr\Key\Key;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
@ -23,8 +25,14 @@ class EventController extends AbstractController
* @throws Exception * @throws Exception
*/ */
#[Route('/e/{nevent}', name: 'nevent', requirements: ['nevent' => '^nevent1.*'])] #[Route('/e/{nevent}', name: 'nevent', requirements: ['nevent' => '^nevent1.*'])]
public function index($nevent, NostrClient $nostrClient, CacheService $cacheService, NostrLinkParser $nostrLinkParser, LoggerInterface $logger): Response public function index(
{ $nevent,
Request $request,
NostrClient $nostrClient,
CacheService $cacheService,
NostrLinkParser $nostrLinkParser,
LoggerInterface $logger,
): Response {
$logger->info('Accessing event page', ['nevent' => $nevent]); $logger->info('Accessing event page', ['nevent' => $nevent]);
try { try {
@ -37,11 +45,15 @@ class EventController extends AbstractController
$data = $decoded->data; $data = $decoded->data;
$logger->info('Event data', ['data' => json_encode($data)]); $logger->info('Event data', ['data' => json_encode($data)]);
$relays = [];
// Sort which event type this is using $data->type // Sort which event type this is using $data->type
switch ($decoded->type) { switch ($decoded->type) {
case 'note': case 'note':
// Handle note (regular event) // Handle note (regular event)
$relays = $data->relays ?? []; $relays = $data->relays ?? [];
if (!\is_array($relays)) {
$relays = [];
}
$event = $nostrClient->getEventById($data->identifier, $relays); $event = $nostrClient->getEventById($data->identifier, $relays);
break; break;
@ -53,16 +65,23 @@ class EventController extends AbstractController
case 'nevent': case 'nevent':
// Handle nevent identifier (event with additional metadata) // Handle nevent identifier (event with additional metadata)
$relays = $data->relays ?? []; $relays = $data->relays ?? [];
if (!\is_array($relays)) {
$relays = [];
}
$event = $nostrClient->getEventById($data->id, $relays); $event = $nostrClient->getEventById($data->id, $relays);
break; break;
case 'naddr': case 'naddr':
// Handle naddr (parameterized replaceable event) // Handle naddr (parameterized replaceable event)
$relays = $data->relays ?? [];
if (!\is_array($relays)) {
$relays = [];
}
$decodedData = [ $decodedData = [
'kind' => $data->kind, 'kind' => $data->kind,
'pubkey' => $data->pubkey, 'pubkey' => $data->pubkey,
'identifier' => $data->identifier, 'identifier' => $data->identifier,
'relays' => $data->relays ?? [] 'relays' => $relays,
]; ];
$event = $nostrClient->getEventByNaddr($decodedData); $event = $nostrClient->getEventByNaddr($decodedData);
break; break;
@ -77,6 +96,8 @@ class EventController extends AbstractController
throw new NotFoundHttpException('Event not found'); throw new NotFoundHttpException('Event not found');
} }
NostrShareMenuBuilder::applyWireEventToRequest($request, $event, $relays);
// Parse event content for Nostr links // Parse event content for Nostr links
$nostrLinks = []; $nostrLinks = [];
if (isset($event->content)) { if (isset($event->content)) {

3
src/Controller/FeaturedAuthorsController.php

@ -30,7 +30,6 @@ final class FeaturedAuthorsController extends AbstractController
ParameterBagInterface $params, ParameterBagInterface $params,
): Response { ): Response {
$domain = trim((string) $params->get('nip05_domain')); $domain = trim((string) $params->get('nip05_domain'));
$jumbleBase = rtrim((string) $params->get('jumble_profile_users_base'), '/');
$keys = new Key(); $keys = new Key();
$authors = []; $authors = [];
foreach ($featuredAuthorRepository->findAllListedOrderByLocalPart() as $fa) { foreach ($featuredAuthorRepository->findAllListedOrderByLocalPart() as $fa) {
@ -38,7 +37,6 @@ final class FeaturedAuthorsController extends AbstractController
$bundle = $cacheService->getMetadataBundle($npub); $bundle = $cacheService->getMetadataBundle($npub);
$author = $bundle['content']; $author = $bundle['content'];
$kind0Tags = $bundle['kind0_tags']; $kind0Tags = $bundle['kind0_tags'];
$jumbleProfileHref = $jumbleBase !== '' ? $jumbleBase.'/'.$npub : null;
$kind10133 = []; $kind10133 = [];
try { try {
$kind10133 = $nostrClient->getKind10133PaymentTargetEventsForNpub($npub, 20); $kind10133 = $nostrClient->getKind10133PaymentTargetEventsForNpub($npub, 20);
@ -50,7 +48,6 @@ final class FeaturedAuthorsController extends AbstractController
'npub' => $npub, 'npub' => $npub,
'profile_websites' => $profileIdentityLinks->buildWebsites($author, $kind0Tags), 'profile_websites' => $profileIdentityLinks->buildWebsites($author, $kind0Tags),
'profile_payment_links' => $profilePaymentLinks->buildPaymentRows($author, $kind0Tags, $extraPayto), 'profile_payment_links' => $profilePaymentLinks->buildPaymentRows($author, $kind0Tags, $extraPayto),
'jumble_profile_href' => $jumbleProfileHref,
]; ];
} }

13
src/Controller/SeoController.php

@ -10,6 +10,7 @@ use App\Repository\ArticleRepository;
use App\Repository\FeaturedAuthorRepository; use App\Repository\FeaturedAuthorRepository;
use App\Service\MagazineContentService; use App\Service\MagazineContentService;
use App\Service\MagazineIndexStore; use App\Service\MagazineIndexStore;
use App\Service\NostrPathHelper;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\JsonResponse;
@ -31,6 +32,7 @@ final class SeoController extends AbstractController
private readonly MagazineIndexStore $magazineIndexStore, private readonly MagazineIndexStore $magazineIndexStore,
private readonly ParameterBagInterface $params, private readonly ParameterBagInterface $params,
private readonly FeaturedAuthorRepository $featuredAuthorRepository, private readonly FeaturedAuthorRepository $featuredAuthorRepository,
private readonly NostrPathHelper $nostrPathHelper,
) { ) {
} }
@ -57,8 +59,12 @@ final class SeoController extends AbstractController
$articles = $this->articleRepository->findPublishedForSyndication(8000); $articles = $this->articleRepository->findPublishedForSyndication(8000);
$bySlug = $this->dedupeArticlesByLatestRevision($articles); $bySlug = $this->dedupeArticlesByLatestRevision($articles);
foreach ($bySlug as $article) { foreach ($bySlug as $article) {
$loc = $this->nostrPathHelper->articleAbsoluteUrl($article);
if ($loc === '') {
continue;
}
$urls[] = [ $urls[] = [
'loc' => $this->absoluteUrlForRoute('article-slug', ['slug' => (string) $article->getSlug()]), 'loc' => $loc,
'lastmod' => $this->articleLastMod($article), 'lastmod' => $this->articleLastMod($article),
]; ];
} }
@ -277,7 +283,10 @@ final class SeoController extends AbstractController
if ($slug === '') { if ($slug === '') {
return ''; return '';
} }
$permalink = $this->absoluteUrlForRoute('article-slug', ['slug' => $slug]); $permalink = $this->nostrPathHelper->articleAbsoluteUrl($article);
if ($permalink === '') {
return '';
}
$title = (string) ($article->getTitle() ?? 'Untitled'); $title = (string) ($article->getTitle() ?? 'Untitled');
$tArticle = $this->articleLastMod($article); $tArticle = $this->articleLastMod($article);
$sum = (string) ($article->getSummary() ?? ''); $sum = (string) ($article->getSummary() ?? '');

21
src/Dto/NostrShareMenuContext.php

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Dto;
/**
* Nostr "⋯" share menu: copy npub; copy nevent or naddr (Jumble uses the same bech in /feed/notes/…).
* Addressable (NIP-33) long-form / index events: prefer naddr; one-off stateless events: nevent.
*/
final class NostrShareMenuContext
{
public function __construct(
/** NIP-19 npub. Null only in rare fallbacks. */
public ?string $npub,
public ?string $neventBech32,
public ?string $naddrBech32,
public string $jumbleHref,
) {
}
}

73
src/Nostr/Nip19Addressable.php

@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Nostr;
use App\Entity\Event;
use nostriphant\NIP19\Bech32;
/**
* NIP-33 / NIP-19 helpers: naddr for parameterized replaceable events (kind:pubkey:d).
*/
final class Nip19Addressable
{
/**
* NIP-33 replaceable kinds (30000–39999) use a `d` tag; encode as naddr, not nevent, for clients.
*/
public static function isParameterizedReplaceableKind(int $kind): bool
{
return $kind >= 30_000 && $kind < 40_000;
}
/**
* @param array<int, mixed> $tagRows
*/
public static function dTagFromTagRows(array $tagRows): ?string
{
foreach ($tagRows as $row) {
if (!\is_array($row) && !\is_object($row)) {
continue;
}
if (\is_object($row)) {
$row = (array) $row;
}
$row = array_values($row);
if ($row === []) {
continue;
}
if (strtolower((string) ($row[0] ?? '')) === 'd' && isset($row[1])) {
$d = (string) $row[1];
if ($d !== '') {
return $d;
}
}
}
return null;
}
public static function dTagFromEventEntity(Event $e): ?string
{
return self::dTagFromTagRows($e->getTags());
}
public static function naddrBech32(
int $kind,
string $pubkeyHex,
string $dIdentifier,
array $relays = [],
): string {
$pubkeyHex = strtolower($pubkeyHex);
if (64 !== \strlen($pubkeyHex) || !ctype_xdigit($pubkeyHex)) {
throw new \InvalidArgumentException('Invalid pubkey hex for naddr.');
}
return (string) Bech32::naddr(
kind: $kind,
pubkey: $pubkeyHex,
identifier: $dIdentifier,
relays: $relays,
);
}
}

52
src/Service/NostrPathHelper.php

@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\Article;
use swentel\nostr\Key\Key;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
/**
* Canonical /p/{npub}/d/{slug} links for long-form and helpers for templates.
*/
final class NostrPathHelper
{
public function __construct(
private readonly UrlGeneratorInterface $router,
) {
}
public function npubFromPubkeyHex(string $pubkeyHex): string
{
return (new Key())->convertPublicKeyToBech32($pubkeyHex);
}
public function articlePath(Article $article): string
{
$slug = (string) ($article->getSlug() ?? '');
if ($slug === '' || $article->getPubkey() === null) {
return '';
}
$npub = $this->npubFromPubkeyHex((string) $article->getPubkey());
return $this->router->generate('article', [
'npub' => $npub,
'slug' => $slug,
]);
}
public function articleAbsoluteUrl(Article $article): string
{
$slug = (string) ($article->getSlug() ?? '');
if ($slug === '' || $article->getPubkey() === null) {
return '';
}
return $this->router->generate('article', [
'npub' => $this->npubFromPubkeyHex((string) $article->getPubkey()),
'slug' => $slug,
], UrlGeneratorInterface::ABSOLUTE_URL);
}
}

348
src/Service/NostrShareMenuBuilder.php

@ -0,0 +1,348 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Dto\NostrShareMenuContext;
use App\Entity\Article;
use App\Entity\Event;
use App\Nostr\Nip19Addressable;
use App\Repository\ArticleRepository;
use nostriphant\NIP19\Bech32;
use swentel\nostr\Key\Key;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Request;
/**
* Resolves the header Nostr share menu (npub; naddr for addressables, else nevent; Jumble /feed/notes/…).
*/
final class NostrShareMenuBuilder
{
public const string ATTR_NPUB = 'nostr_share_npub';
public const string ATTR_NEVENT_BECH32 = 'nostr_share_nevent_bech32';
public const string ATTR_NADDR_BECH32 = 'nostr_share_naddr_bech32';
public static function applyWireEventToRequest(Request $request, object $event, array $relayHints = []): void
{
$pubkeyHex = strtolower((string) ($event->pubkey ?? ''));
if (64 !== \strlen($pubkeyHex) || !ctype_xdigit($pubkeyHex)) {
return;
}
$key = new Key();
$request->attributes->set(self::ATTR_NPUB, $key->convertPublicKeyToBech32($pubkeyHex));
$kind = (int) ($event->kind ?? 0);
$d = self::dTagFromWireEvent($event);
if (Nip19Addressable::isParameterizedReplaceableKind($kind) && $d !== null) {
$naddr = Nip19Addressable::naddrBech32($kind, $pubkeyHex, $d, $relayHints);
$request->attributes->set(self::ATTR_NADDR_BECH32, $naddr);
return;
}
$eventIdHex = strtolower((string) ($event->id ?? ''));
if (64 === \strlen($eventIdHex) && ctype_xdigit($eventIdHex)) {
$rebuilt = (string) Bech32::nevent(
id: $eventIdHex,
relays: $relayHints,
author: $pubkeyHex,
kind: $kind,
);
$request->attributes->set(self::ATTR_NEVENT_BECH32, $rebuilt);
}
}
/**
* @param list<mixed>|\ArrayObject<int, mixed> $event->tags
*/
private static function dTagFromWireEvent(object $event): ?string
{
if (!isset($event->tags)) {
return null;
}
$rows = $event->tags;
if ($rows instanceof \ArrayObject) {
$rows = $rows->getArrayCopy();
}
if (!\is_array($rows)) {
return null;
}
$norm = array_values(
array_map(
static function ($r) {
if (!\is_array($r) && !\is_object($r)) {
return $r;
}
if (\is_object($r)) {
$r = (array) $r;
}
return $r;
},
$rows
)
);
return Nip19Addressable::dTagFromTagRows($norm);
}
public function __construct(
private readonly MagazineIndexStore $magazineIndexStore,
private readonly ArticleRepository $articleRepository,
#[Autowire('%npub%')]
private readonly string $siteNpub,
#[Autowire('%d_tag%')]
private readonly string $rootDTag,
#[Autowire('%jumble_profile_users_base%')]
private readonly string $jumbleProfileUsersBase,
#[Autowire('%jumble_feed_notes_base%')]
private readonly string $jumbleFeedNotesBase,
) {
}
private function nostrKey(): Key
{
return new Key();
}
public function buildForRequest(Request $request): ?NostrShareMenuContext
{
if ($request->isXmlHttpRequest() || 'xmlhttprequest' === strtolower((string) $request->headers->get('X-Requested-With'))) {
return null;
}
if ($request->attributes->getBoolean('_embed')) {
return null;
}
$route = (string) $request->attributes->get('_route', '');
if (str_ends_with($route, 'fragment') || str_starts_with($request->getPathInfo(), '/fragment/')) {
return null;
}
if ('' === $route) {
return $this->siteWithRootMenu();
}
return match ($route) {
'home' => $this->siteWithRootMenu(),
'article' => $this->forArticleNpubD(
(string) $request->attributes->get('npub', ''),
(string) $request->attributes->get('slug', ''),
),
'author-profile' => $this->forAuthorProfile($request->attributes->get('npub', '')),
'nevent' => $this->forNevent($request, (string) $request->attributes->get('nevent', '')),
'magazine-category' => $this->forCategory($request->attributes->get('slug', '')),
'articles', 'featured_authors', 'search', 'article-preview', 'article-preview-event', 'editor-create', 'editor-edit' => $this->siteWithRootMenu(),
default => $this->siteWithRootMenu(),
};
}
private function forArticleNpubD(string $npub, string $slug): NostrShareMenuContext
{
if ($npub === '' || $slug === '' || !str_starts_with($npub, 'npub1')) {
return $this->siteWithRootMenu();
}
$list = $this->articleRepository->findBy(['slug' => $slug], ['createdAt' => 'DESC'], 1);
$article = $list[0] ?? null;
if ($article === null) {
return $this->siteWithRootMenu();
}
if ($this->nostrKey()->convertToHex($npub) !== strtolower((string) $article->getPubkey())) {
return $this->siteWithRootMenu();
}
return $this->fromArticle($article);
}
private function fromArticle(Article $article): NostrShareMenuContext
{
$npub = $this->nostrKey()->convertPublicKeyToBech32((string) $article->getPubkey());
$kind = (int) ($article->getKind()?->value ?? 30023);
$d = (string) ($article->getSlug() ?? '');
if ($d === '') {
return new NostrShareMenuContext(
$npub,
null,
null,
$this->profileJumbleUrl($npub),
);
}
$pk = strtolower((string) $article->getPubkey());
$naddr = Nip19Addressable::naddrBech32($kind, $pk, $d, []);
return new NostrShareMenuContext(
$npub,
null,
$naddr,
$this->feedJumble($naddr),
);
}
private function forAuthorProfile(mixed $npubParam): NostrShareMenuContext
{
$npub = (string) $npubParam;
if ($npub === '' || !str_starts_with($npub, 'npub1')) {
return $this->siteWithRootMenu();
}
return new NostrShareMenuContext(
$npub,
null,
null,
$this->profileJumbleUrl($npub),
);
}
private function forNevent(Request $request, string $neventFromRoute): NostrShareMenuContext
{
if ($request->attributes->has(self::ATTR_NPUB) && $request->attributes->has(self::ATTR_NADDR_BECH32)) {
$naddr = (string) $request->attributes->get(self::ATTR_NADDR_BECH32);
$np = (string) $request->attributes->get(self::ATTR_NPUB);
return new NostrShareMenuContext(
$np,
null,
$naddr,
$this->feedJumble($naddr),
);
}
if ($request->attributes->has(self::ATTR_NPUB) && $request->attributes->has(self::ATTR_NEVENT_BECH32)) {
$nb = (string) $request->attributes->get(self::ATTR_NEVENT_BECH32);
$np = (string) $request->attributes->get(self::ATTR_NPUB);
return new NostrShareMenuContext(
$np,
$nb,
null,
$this->feedJumble($nb),
);
}
$nevent = $neventFromRoute;
if ($nevent === '' || !str_starts_with($nevent, 'nevent1')) {
return $this->siteWithRootMenu();
}
try {
$decoded = new Bech32($nevent);
} catch (\Throwable) {
return $this->siteWithRootMenu();
}
if ($decoded->type !== 'nevent' || !isset($decoded->data->id)) {
return $this->siteWithRootMenu();
}
$eventId = strtolower((string) $decoded->data->id);
if (64 !== \strlen($eventId) || !ctype_xdigit($eventId)) {
return $this->siteWithRootMenu();
}
$authorHex = $decoded->data->author ?? null;
if (\is_string($authorHex) && 64 === \strlen($authorHex) && ctype_xdigit($authorHex)) {
$authorHex = strtolower($authorHex);
} else {
$authorHex = null;
}
$kind = isset($decoded->data->kind) ? (int) $decoded->data->kind : 1;
$relays = $decoded->data->relays ?? [];
$relays = \is_array($relays) ? $relays : [];
if ($authorHex !== null) {
$rebuilt = (string) Bech32::nevent(
id: $eventId,
relays: $relays,
author: $authorHex,
kind: $kind,
);
return new NostrShareMenuContext(
$this->nostrKey()->convertPublicKeyToBech32($authorHex),
$rebuilt,
null,
$this->feedJumble($rebuilt),
);
}
return new NostrShareMenuContext(
null,
$nevent,
null,
$this->feedJumble($nevent),
);
}
private function forCategory(string $slug): NostrShareMenuContext
{
if ($slug === '') {
return $this->siteWithRootMenu();
}
$cat = $this->magazineIndexStore->getCategory($slug);
if ($cat === null) {
return $this->siteWithRootMenu();
}
return $this->fromNostrEvent($cat) ?? $this->siteWithRootMenu();
}
private function fromNostrEvent(Event $e): ?NostrShareMenuContext
{
$id = strtolower($e->getId());
if (64 !== \strlen($id) || !ctype_xdigit($id)) {
return null;
}
$pk = strtolower($e->getPubkey());
if (64 !== \strlen($pk) || !ctype_xdigit($pk)) {
return null;
}
$kind = (int) $e->getKind();
$d = Nip19Addressable::dTagFromEventEntity($e);
$npub = $this->nostrKey()->convertPublicKeyToBech32($pk);
if (Nip19Addressable::isParameterizedReplaceableKind($kind) && $d !== null) {
$naddr = Nip19Addressable::naddrBech32($kind, $pk, $d, []);
return new NostrShareMenuContext(
$npub,
null,
$naddr,
$this->feedJumble($naddr),
);
}
$nevent = (string) Bech32::nevent(
id: $id,
relays: [],
author: $pk,
kind: $kind,
);
return new NostrShareMenuContext(
$npub,
$nevent,
null,
$this->feedJumble($nevent),
);
}
private function siteWithRootMenu(): NostrShareMenuContext
{
$root = $this->magazineIndexStore->getRoot($this->siteNpub, $this->rootDTag);
if (null === $fromRoot = $root ? $this->fromNostrEvent($root) : null) {
return new NostrShareMenuContext(
$this->siteNpub,
null,
null,
$this->profileJumbleUrl($this->siteNpub),
);
}
return $fromRoot;
}
private function profileJumbleUrl(string $npub): string
{
$b = rtrim($this->jumbleProfileUsersBase, '/');
return $b === '' ? '#' : $b.'/'.$npub;
}
private function feedJumble(string $naddrOrNeventOrNoteBech32): string
{
$b = rtrim($this->jumbleFeedNotesBase, '/');
return $b === '' ? $naddrOrNeventOrNoteBech32 : $b.'/'.$naddrOrNeventOrNoteBech32;
}
}

40
src/Twig/NostrPathExtension.php

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Twig;
use App\Entity\Article;
use App\Service\NostrPathHelper;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
final class NostrPathExtension extends AbstractExtension
{
public function __construct(
private readonly NostrPathHelper $nostrPathHelper,
) {
}
public function getFunctions(): array
{
return [
new TwigFunction('npub_from_hex', $this->npubFromHex(...)),
new TwigFunction('article_path', $this->articlePath(...)),
];
}
public function npubFromHex(string $pubkeyHex): string
{
if (64 !== \strlen($pubkeyHex) || !ctype_xdigit($pubkeyHex)) {
return '';
}
return $this->nostrPathHelper->npubFromPubkeyHex($pubkeyHex);
}
public function articlePath(Article $article): string
{
return $this->nostrPathHelper->articlePath($article);
}
}

37
src/Twig/NostrShareMenuExtension.php

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Twig;
use App\Dto\NostrShareMenuContext;
use App\Service\NostrShareMenuBuilder;
use Symfony\Component\HttpFoundation\RequestStack;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
final class NostrShareMenuExtension extends AbstractExtension
{
public function __construct(
private readonly NostrShareMenuBuilder $builder,
private readonly RequestStack $requestStack,
) {
}
public function getFunctions(): array
{
return [
new TwigFunction('nostr_share_menu', [$this, 'getOrBuildContext']),
];
}
public function getOrBuildContext(): ?NostrShareMenuContext
{
$request = $this->requestStack->getCurrentRequest();
if ($request === null) {
return null;
}
return $this->builder->buildForRequest($request);
}
}

3
templates/components/Header.html.twig

@ -8,8 +8,11 @@
<span class="brand__title">{{ website_name }}</span> <span class="brand__title">{{ website_name }}</span>
</h1> </h1>
</a> </a>
<div class="header__end">
{% include 'components/Molecules/NostrShareMenu.html.twig' %}
<button class="hamburger btn btn-secondary" data-action="click->menu#toggle" aria-label="Menu">&#9776;</button> <button class="hamburger btn btn-secondary" data-action="click->menu#toggle" aria-label="Menu">&#9776;</button>
</div> </div>
</div>
<div class="header__categories" data-menu-target="menu"> <div class="header__categories" data-menu-target="menu">
<ul> <ul>
{% for category in cats %} {% for category in cats %}

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

@ -10,7 +10,7 @@
<small>{{ article.createdAt|date('F j Y') }}</small> <small>{{ article.createdAt|date('F j Y') }}</small>
{% endif %} {% endif %}
</div> </div>
<a href="{{ path('article-slug', {slug: article.slug}) }}"> <a href="{{ (article.pubkey and npub_from_hex(article.pubkey) != '') ? path('article', { npub: npub_from_hex(article.pubkey), slug: article.slug }) : path('article-legacy-redirect', { slug: article.slug }) }}">
<div class="card-header"> <div class="card-header">
{% if category %}<small class="text-uppercase">{{ category }}</small>{% endif %} {% if category %}<small class="text-uppercase">{{ category }}</small>{% endif %}
{% if article.image %} {% if article.image %}

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

@ -10,11 +10,6 @@
<p class="card-text">{{ tag[1] }}</p> <p class="card-text">{{ tag[1] }}</p>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
{% if preview.id is defined and preview.id %}
<div class="card-footer nostr-preview-card__meta text-subtle">
<a href="https://jumble.imwald.eu/feed/notes/{{ preview.id }}" class="nostr-jumble-outlink" target="_blank" rel="noopener noreferrer">View event</a>
</div>
{% endif %}
</div> </div>
{% elseif preview.type == 'nevent' %} {% elseif preview.type == 'nevent' %}
{% if preview.kind == 9802 %} {% if preview.kind == 9802 %}
@ -42,11 +37,6 @@
</blockquote> </blockquote>
{% endif %} {% endif %}
</div> </div>
{% if preview.id is defined and preview.id %}
<div class="card-footer nostr-preview-card__meta text-subtle">
<a href="https://jumble.imwald.eu/feed/notes/{{ preview.id }}" class="nostr-jumble-outlink" target="_blank" rel="noopener noreferrer">View event</a>
</div>
{% endif %}
</div> </div>
{% else %} {% else %}
{% set is_longform = preview.kind == 30023 or preview.kind == 30024 %} {% set is_longform = preview.kind == 30023 or preview.kind == 30024 %}
@ -88,10 +78,6 @@
</div> </div>
<div class="card-footer nostr-preview-card__meta text-subtle"> <div class="card-footer nostr-preview-card__meta text-subtle">
<small>{{ preview.created_at is defined ? preview.created_at|date('F j Y') : '' }}</small> <small>{{ preview.created_at is defined ? preview.created_at|date('F j Y') : '' }}</small>
{% if preview.id is defined and preview.id %}
<span class="nostr-preview-card__sep">·</span>
<a href="https://jumble.imwald.eu/feed/notes/{{ preview.id }}" class="nostr-jumble-outlink" target="_blank" rel="noopener noreferrer">View event</a>
{% endif %}
</div> </div>
</div> </div>
{% endif %} {% endif %}

31
templates/components/Molecules/NostrShareMenu.html.twig

@ -0,0 +1,31 @@
{% set share = nostr_share_menu() %}
{% if share is not null %}
<details class="nostr-share-menu">
<summary class="nostr-share-menu__trigger btn btn-secondary btn-sm" title="Nostr options" aria-label="Nostr options">⋯</summary>
<ul class="nostr-share-menu__list" role="menu">
{% if share.npub is not null and share.npub is not same as('') %}
<li class="nostr-share-menu__item" role="none"
data-controller="copy-text"
data-copy-text-text-value="{{ share.npub|e('html_attr') }}">
<button type="button" class="nostr-share-menu__action" data-action="click->copy-text#copy" data-copy-text-target="button" role="menuitem">Copy npub</button>
</li>
{% endif %}
{% if share.naddrBech32 is not null and share.naddrBech32 is not same as('') %}
<li class="nostr-share-menu__item" role="none"
data-controller="copy-text"
data-copy-text-text-value="{{ share.naddrBech32|e('html_attr') }}">
<button type="button" class="nostr-share-menu__action" data-action="click->copy-text#copy" data-copy-text-target="button" role="menuitem">Copy naddr</button>
</li>
{% elseif share.neventBech32 is not null and share.neventBech32 is not same as('') %}
<li class="nostr-share-menu__item" role="none"
data-controller="copy-text"
data-copy-text-text-value="{{ share.neventBech32|e('html_attr') }}">
<button type="button" class="nostr-share-menu__action" data-action="click->copy-text#copy" data-copy-text-target="button" role="menuitem">Copy nevent</button>
</li>
{% endif %}
<li class="nostr-share-menu__item" role="none">
<a class="nostr-share-menu__action" role="menuitem" href="{{ share.jumbleHref|e('html_attr') }}" target="_blank" rel="nofollow noopener noreferrer">View on Jumble</a>
</li>
</ul>
</details>
{% endif %}

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

@ -182,10 +182,6 @@
</p> </p>
<small> <small>
{% if cts is not null and cts != '' %}{{ cts|date('F j Y') }}{% endif %} {% if cts is not null and cts != '' %}{{ cts|date('F j Y') }}{% endif %}
{% if cid != '' %}
<span class="comments-quotes__sep">·</span>
<a href="https://jumble.imwald.eu/feed/notes/{{ cid }}" class="nostr-jumble-outlink" target="_blank" rel="noopener noreferrer">View event</a>
{% endif %}
</small> </small>
</div> </div>
<div class="card-body"> <div class="card-body">

4
templates/components/Organisms/FeaturedList.html.twig

@ -7,7 +7,7 @@
<div> <div>
{% set feature = list[0] %} {% set feature = list[0] %}
<div class="card"> <div class="card">
<a href="{{ path('article-slug', {slug: feature.slug}) }}"> <a href="{{ (feature.pubkey and npub_from_hex(feature.pubkey) != '') ? path('article', { npub: npub_from_hex(feature.pubkey), slug: feature.slug }) : path('article-legacy-redirect', { slug: feature.slug }) }}">
<div class="card-header"> <div class="card-header">
{% if feature.image %} {% if feature.image %}
<img src="{{ feature.image }}" alt="Cover image for {{ feature.title }}"> <img src="{{ feature.image }}" alt="Cover image for {{ feature.title }}">
@ -26,7 +26,7 @@
{% for item in list %} {% for item in list %}
{% if item != feature %} {% if item != feature %}
<div class="card"> <div class="card">
<a href="{{ path('article-slug', {slug: item.slug}) }}"> <a href="{{ (item.pubkey and npub_from_hex(item.pubkey) != '') ? path('article', { npub: npub_from_hex(item.pubkey), slug: item.slug }) : path('article-legacy-redirect', { slug: item.slug }) }}">
<div class="card-body"> <div class="card-body">
<h2 class="card-title">{{ item.title }}</h2> <h2 class="card-title">{{ item.title }}</h2>
<p class="lede truncate"> <p class="lede truncate">

2
templates/pages/article.html.twig

@ -21,7 +21,7 @@
{% set _og_default_dims = false %} {% set _og_default_dims = false %}
{% endif %} {% endif %}
{% set _desc = article.summary|default('')|striptags|u.truncate(159, '…') %} {% set _desc = article.summary|default('')|striptags|u.truncate(159, '…') %}
{% set _canonical = url('article-slug', {slug: article.slug}) %} {% set _canonical = url('article', { npub: npub|default(npub_from_hex(article.pubkey)), slug: article.slug }) %}
{% set _author_name = '' %} {% set _author_name = '' %}
{% if author is defined and author %} {% if author is defined and author %}
{% set _author_name = attribute(author, 'name')|default(attribute(author, 'display_name')|default('')) %} {% set _author_name = attribute(author, 'name')|default(attribute(author, 'display_name')|default('')) %}

1
templates/pages/author.html.twig

@ -9,7 +9,6 @@
profile_websites: profile_websites, profile_websites: profile_websites,
profile_nip05: profile_nip05, profile_nip05: profile_nip05,
profile_payment_links: profile_payment_links, profile_payment_links: profile_payment_links,
jumble_profile_href: jumble_profile_href,
} only %} } only %}
<hr class="author-profile__divider" /> <hr class="author-profile__divider" />

5
templates/pages/featured_authors.html.twig

@ -26,14 +26,9 @@
profile_nip05: [], profile_nip05: [],
profile_websites: row.profile_websites, profile_websites: row.profile_websites,
profile_payment_links: row.profile_payment_links, profile_payment_links: row.profile_payment_links,
jumble_profile_href: row.jumble_profile_href,
omit_jumble_button: true,
} only %} } only %}
</div> </div>
<div class="featured-authors__actions"> <div class="featured-authors__actions">
{% if row.jumble_profile_href is not null and row.jumble_profile_href != '' %}
<a class="btn btn-secondary" href="{{ row.jumble_profile_href|e('html_attr') }}" target="_blank" rel="nofollow noopener noreferrer">View on Jumble</a>
{% endif %}
<a class="btn btn-secondary" href="{{ path('author-profile', { npub: row.npub }) }}">Full profile</a> <a class="btn btn-secondary" href="{{ path('author-profile', { npub: row.npub }) }}">Full profile</a>
</div> </div>
</article> </article>

8
templates/partial/author_profile_header.html.twig

@ -1,4 +1,4 @@
{# Shared author “header” + about (no article list). Expects: author, npub, profile_*, jumble_profile_href; show_nip05: true on full /p/ profile only #} {# Shared author “header” + about (no article list). Expects: author, npub, profile_*; show_nip05: true on full /p/ profile only #}
{% set author_pic = null %} {% set author_pic = null %}
{% if author.picture is defined and author.picture %} {% if author.picture is defined and author.picture %}
{% set author_pic = author.picture %} {% set author_pic = author.picture %}
@ -77,9 +77,3 @@
{{ author.about|markdown_to_html|mentionify|linkify }} {{ author.about|markdown_to_html|mentionify|linkify }}
{% endif %} {% endif %}
</div> </div>
{% if not omit_jumble_button|default(false) and jumble_profile_href is not null and jumble_profile_href != '' %}
<p class="author-profile__jumble">
<a class="btn btn-secondary" href="{{ jumble_profile_href|e('html_attr') }}" target="_blank" rel="nofollow noopener noreferrer">View on Jumble</a>
</p>
{% endif %}

Loading…
Cancel
Save