From f6703e2f4bfbb57e401aaac2b00f3bc6d32cd489 Mon Sep 17 00:00:00 2001
From: Silberengel
Date: Fri, 24 Apr 2026 07:39:31 +0200
Subject: [PATCH] add event menus
---
assets/styles/app.css | 4 +
assets/styles/layout.css | 86 +++++
config/packages/monolog.yaml | 31 +-
config/services.yaml | 2 +
config/unfold.yaml | 2 +
src/Controller/ArticleController.php | 90 ++++-
src/Controller/AuthorController.php | 5 -
src/Controller/EventController.php | 27 +-
src/Controller/FeaturedAuthorsController.php | 3 -
src/Controller/SeoController.php | 13 +-
src/Dto/NostrShareMenuContext.php | 21 ++
src/Nostr/Nip19Addressable.php | 73 ++++
src/Service/NostrPathHelper.php | 52 +++
src/Service/NostrShareMenuBuilder.php | 348 ++++++++++++++++++
src/Twig/NostrPathExtension.php | 40 ++
src/Twig/NostrShareMenuExtension.php | 37 ++
templates/components/Header.html.twig | 5 +-
templates/components/Molecules/Card.html.twig | 2 +-
.../Molecules/NostrPreviewContent.html.twig | 14 -
.../Molecules/NostrShareMenu.html.twig | 31 ++
.../components/Organisms/Comments.html.twig | 4 -
.../Organisms/FeaturedList.html.twig | 4 +-
templates/pages/article.html.twig | 2 +-
templates/pages/author.html.twig | 1 -
templates/pages/featured_authors.html.twig | 5 -
.../partial/author_profile_header.html.twig | 8 +-
26 files changed, 830 insertions(+), 80 deletions(-)
create mode 100644 src/Dto/NostrShareMenuContext.php
create mode 100644 src/Nostr/Nip19Addressable.php
create mode 100644 src/Service/NostrPathHelper.php
create mode 100644 src/Service/NostrShareMenuBuilder.php
create mode 100644 src/Twig/NostrPathExtension.php
create mode 100644 src/Twig/NostrShareMenuExtension.php
create mode 100644 templates/components/Molecules/NostrShareMenu.html.twig
diff --git a/assets/styles/app.css b/assets/styles/app.css
index 50393dc..9816d35 100644
--- a/assets/styles/app.css
+++ b/assets/styles/app.css
@@ -351,6 +351,10 @@ div:nth-child(odd) .featured-list {
flex-shrink: 0;
margin-left: 0;
}
+
+ .header__end {
+ flex-shrink: 0;
+ }
}
/* Fixed square + overflow clips to a true circle. Logo img is out-of-flow so
diff --git a/assets/styles/layout.css b/assets/styles/layout.css
index 6c7fb20..7cdc4d8 100644
--- a/assets/styles/layout.css
+++ b/assets/styles/layout.css
@@ -77,6 +77,75 @@ nav a:hover {
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 {
display: flex;
width: 100%;
@@ -187,6 +256,23 @@ nav a:hover {
.header__mobile-account {
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 */
diff --git a/config/packages/monolog.yaml b/config/packages/monolog.yaml
index d993f59..ddcc8ac 100644
--- a/config/packages/monolog.yaml
+++ b/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:
channels:
- deprecation # Deprecations are logged in the dedicated "deprecation" channel when it exists
@@ -5,19 +9,21 @@ monolog:
when@dev:
monolog:
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:
type: group
members: [file, docker]
channels: ["!event"]
file:
- type: stream
+ type: rotating_file
path: "%kernel.logs_dir%/%kernel.environment%.log"
level: debug
+ max_files: 14
docker:
type: stream
path: "php://stderr"
- level: debug
+ # Min level info: debug stays out of stderr (file only).
+ level: info
console:
type: console
process_psr_3_messages: false
@@ -40,17 +46,20 @@ when@test:
when@prod:
monolog:
handlers:
+ # No fingers_crossed: we split explicitly between file (all) and stderr (warning+ only).
main:
- type: fingers_crossed
- action_level: error
- handler: nested
- excluded_http_codes: [404, 405]
- channels: ["!deprecation"]
- buffer_size: 50 # How many messages should be saved? Prevent memory leaks
- nested:
+ type: group
+ members: [file, stderr]
+ channels: ["!deprecation", "!event"]
+ file:
+ type: rotating_file
+ path: "%kernel.logs_dir%/%kernel.environment%.log"
+ level: debug
+ max_files: 30
+ stderr:
type: stream
path: php://stderr
- level: debug
+ level: warning
formatter: monolog.formatter.json
console:
type: console
diff --git a/config/services.yaml b/config/services.yaml
index 0777676..4698972 100644
--- a/config/services.yaml
+++ b/config/services.yaml
@@ -45,6 +45,8 @@ services:
arguments:
$footerLinksPath: '%footer_links%'
tags: [ 'twig.extension' ]
+ App\Twig\NostrShareMenuExtension:
+ tags: [ 'twig.extension' ]
# Nostr index snapshots: distinct key prefix from other cache.app users.
App\Service\MagazineIndexStore:
arguments:
diff --git a/config/unfold.yaml b/config/unfold.yaml
index 8e2cf84..9543748 100644
--- a/config/unfold.yaml
+++ b/config/unfold.yaml
@@ -35,6 +35,8 @@ parameters:
nip05_domain: 'blog.imwald.eu'
# 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'
+ # 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).
magazine_prewarm_prefer_slugs_empty: ''
magazine_prewarm_prefer_slugs: '%env(default:magazine_prewarm_prefer_slugs_empty:MAGAZINE_PREWARM_PREFER_SLUGS)%'
diff --git a/src/Controller/ArticleController.php b/src/Controller/ArticleController.php
index a5dde3e..d2bea45 100644
--- a/src/Controller/ArticleController.php
+++ b/src/Controller/ArticleController.php
@@ -272,7 +272,9 @@ class ArticleController extends AbstractController
$nostrClient->getLongFormFromNaddr($slug, $relays, $author, $kind);
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.');
@@ -283,13 +285,14 @@ class ArticleController extends AbstractController
*/
// Slug is the NIP-33 d-identifier and may contain "/"; default [^/]++ would break sitemap/URL generation.
#[Route(
- path: '/article/d/{slug}',
- name: 'article-slug',
- requirements: ['slug' => '.+'],
+ path: '/p/{npub}/d/{slug}',
+ name: 'article',
+ requirements: ['npub' => '^npub1.*', 'slug' => '.+'],
options: ['utf8' => true],
)]
public function article(
- $slug,
+ string $npub,
+ string $slug,
EntityManagerInterface $entityManager,
CacheService $cacheService,
CacheItemPoolInterface $articlesCache,
@@ -297,32 +300,77 @@ class ArticleController extends AbstractController
ArticleCommentThreadLoader $commentThreadLoader
): 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
- ini_set('max_execution_time', '300');
+ return $this->renderArticle(
+ $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);
$articles = $repository->findBy(['slug' => $slug]);
- $revisions = count($articles);
-
+ $revisions = \count($articles);
if ($revisions === 0) {
- throw $this->createNotFoundException('The article could not be found');
+ return null;
}
-
if ($revisions > 1) {
- // sort articles by created at date
usort($articles, function ($a, $b) {
return $b->getCreatedAt() <=> $a->getCreatedAt();
});
- // get the last article
- $article = end($articles);
- } else {
- $article = $articles[0];
+
+ return end($articles);
}
- $cacheKey = 'article_' . $article->getId();
+ 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();
$cacheItem = $articlesCache->getItem($cacheKey);
if (!$cacheItem->isHit()) {
$cacheItem->set($converter->convertToHtml($article->getContent()));
@@ -335,7 +383,7 @@ class ArticleController extends AbstractController
$kind = $article->getKind()?->value ?? 30023;
$pubkey = (string) $article->getPubkey();
- $articleSlug = (string) ($article->getSlug() ?? $slug);
+ $articleSlug = (string) $article->getSlug();
$coordinate = $kind.':'.$pubkey.':'.$articleSlug;
$eid = $article->getEventId();
$eid = ($eid !== null && $eid !== '' && self::isValidHexEventId($eid)) ? $eid : null;
@@ -518,11 +566,13 @@ class ArticleController extends AbstractController
$article = $cacheItem->get();
$content = $converter->convertToHtml($article->getContent());
+ $previewNpub = (new Key())->convertPublicKeyToBech32($currentPubkey);
return $this->render('pages/article.html.twig', [
'article' => $article,
'content' => $content,
'author' => $user->getMetadata(),
+ 'npub' => $previewNpub,
'comments_preloaded' => false,
]);
}
diff --git a/src/Controller/AuthorController.php b/src/Controller/AuthorController.php
index 4086b41..3315224 100644
--- a/src/Controller/AuthorController.php
+++ b/src/Controller/AuthorController.php
@@ -75,10 +75,6 @@ class AuthorController extends AbstractController
}
$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);
$fa = $featuredAuthorRepository->findOneByPubkeyHex($pubkey);
if ($fa !== null && $fa->isListed()) {
@@ -96,7 +92,6 @@ class AuthorController extends AbstractController
'profile_websites' => $profileIdentityLinks->buildWebsites($author, $kind0Tags),
'profile_nip05' => $profileNip05,
'profile_payment_links' => $profilePaymentLinks->buildPaymentRows($author, $kind0Tags, $extraPayto),
- 'jumble_profile_href' => $jumbleProfileHref,
]);
}
diff --git a/src/Controller/EventController.php b/src/Controller/EventController.php
index 3d9ab3b..f65a071 100644
--- a/src/Controller/EventController.php
+++ b/src/Controller/EventController.php
@@ -6,6 +6,7 @@ namespace App\Controller;
use App\Service\NostrClient;
use App\Service\NostrLinkParser;
+use App\Service\NostrShareMenuBuilder;
use App\Service\CacheService;
use Exception;
use nostriphant\NIP19\Bech32;
@@ -13,6 +14,7 @@ use nostriphant\NIP19\Data;
use Psr\Log\LoggerInterface;
use swentel\nostr\Key\Key;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
+use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
@@ -23,8 +25,14 @@ class EventController extends AbstractController
* @throws Exception
*/
#[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]);
try {
@@ -37,11 +45,15 @@ class EventController extends AbstractController
$data = $decoded->data;
$logger->info('Event data', ['data' => json_encode($data)]);
+ $relays = [];
// Sort which event type this is using $data->type
switch ($decoded->type) {
case 'note':
// Handle note (regular event)
$relays = $data->relays ?? [];
+ if (!\is_array($relays)) {
+ $relays = [];
+ }
$event = $nostrClient->getEventById($data->identifier, $relays);
break;
@@ -53,16 +65,23 @@ class EventController extends AbstractController
case 'nevent':
// Handle nevent identifier (event with additional metadata)
$relays = $data->relays ?? [];
+ if (!\is_array($relays)) {
+ $relays = [];
+ }
$event = $nostrClient->getEventById($data->id, $relays);
break;
case 'naddr':
// Handle naddr (parameterized replaceable event)
+ $relays = $data->relays ?? [];
+ if (!\is_array($relays)) {
+ $relays = [];
+ }
$decodedData = [
'kind' => $data->kind,
'pubkey' => $data->pubkey,
'identifier' => $data->identifier,
- 'relays' => $data->relays ?? []
+ 'relays' => $relays,
];
$event = $nostrClient->getEventByNaddr($decodedData);
break;
@@ -77,6 +96,8 @@ class EventController extends AbstractController
throw new NotFoundHttpException('Event not found');
}
+ NostrShareMenuBuilder::applyWireEventToRequest($request, $event, $relays);
+
// Parse event content for Nostr links
$nostrLinks = [];
if (isset($event->content)) {
diff --git a/src/Controller/FeaturedAuthorsController.php b/src/Controller/FeaturedAuthorsController.php
index a044145..c275498 100644
--- a/src/Controller/FeaturedAuthorsController.php
+++ b/src/Controller/FeaturedAuthorsController.php
@@ -30,7 +30,6 @@ final class FeaturedAuthorsController extends AbstractController
ParameterBagInterface $params,
): Response {
$domain = trim((string) $params->get('nip05_domain'));
- $jumbleBase = rtrim((string) $params->get('jumble_profile_users_base'), '/');
$keys = new Key();
$authors = [];
foreach ($featuredAuthorRepository->findAllListedOrderByLocalPart() as $fa) {
@@ -38,7 +37,6 @@ final class FeaturedAuthorsController extends AbstractController
$bundle = $cacheService->getMetadataBundle($npub);
$author = $bundle['content'];
$kind0Tags = $bundle['kind0_tags'];
- $jumbleProfileHref = $jumbleBase !== '' ? $jumbleBase.'/'.$npub : null;
$kind10133 = [];
try {
$kind10133 = $nostrClient->getKind10133PaymentTargetEventsForNpub($npub, 20);
@@ -50,7 +48,6 @@ final class FeaturedAuthorsController extends AbstractController
'npub' => $npub,
'profile_websites' => $profileIdentityLinks->buildWebsites($author, $kind0Tags),
'profile_payment_links' => $profilePaymentLinks->buildPaymentRows($author, $kind0Tags, $extraPayto),
- 'jumble_profile_href' => $jumbleProfileHref,
];
}
diff --git a/src/Controller/SeoController.php b/src/Controller/SeoController.php
index b731773..334925a 100644
--- a/src/Controller/SeoController.php
+++ b/src/Controller/SeoController.php
@@ -10,6 +10,7 @@ use App\Repository\ArticleRepository;
use App\Repository\FeaturedAuthorRepository;
use App\Service\MagazineContentService;
use App\Service\MagazineIndexStore;
+use App\Service\NostrPathHelper;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
@@ -31,6 +32,7 @@ final class SeoController extends AbstractController
private readonly MagazineIndexStore $magazineIndexStore,
private readonly ParameterBagInterface $params,
private readonly FeaturedAuthorRepository $featuredAuthorRepository,
+ private readonly NostrPathHelper $nostrPathHelper,
) {
}
@@ -57,8 +59,12 @@ final class SeoController extends AbstractController
$articles = $this->articleRepository->findPublishedForSyndication(8000);
$bySlug = $this->dedupeArticlesByLatestRevision($articles);
foreach ($bySlug as $article) {
+ $loc = $this->nostrPathHelper->articleAbsoluteUrl($article);
+ if ($loc === '') {
+ continue;
+ }
$urls[] = [
- 'loc' => $this->absoluteUrlForRoute('article-slug', ['slug' => (string) $article->getSlug()]),
+ 'loc' => $loc,
'lastmod' => $this->articleLastMod($article),
];
}
@@ -277,7 +283,10 @@ final class SeoController extends AbstractController
if ($slug === '') {
return '';
}
- $permalink = $this->absoluteUrlForRoute('article-slug', ['slug' => $slug]);
+ $permalink = $this->nostrPathHelper->articleAbsoluteUrl($article);
+ if ($permalink === '') {
+ return '';
+ }
$title = (string) ($article->getTitle() ?? 'Untitled');
$tArticle = $this->articleLastMod($article);
$sum = (string) ($article->getSummary() ?? '');
diff --git a/src/Dto/NostrShareMenuContext.php b/src/Dto/NostrShareMenuContext.php
new file mode 100644
index 0000000..d30b2f4
--- /dev/null
+++ b/src/Dto/NostrShareMenuContext.php
@@ -0,0 +1,21 @@
+= 30_000 && $kind < 40_000;
+ }
+
+ /**
+ * @param array $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,
+ );
+ }
+}
diff --git a/src/Service/NostrPathHelper.php b/src/Service/NostrPathHelper.php
new file mode 100644
index 0000000..d61e8e1
--- /dev/null
+++ b/src/Service/NostrPathHelper.php
@@ -0,0 +1,52 @@
+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);
+ }
+}
diff --git a/src/Service/NostrShareMenuBuilder.php b/src/Service/NostrShareMenuBuilder.php
new file mode 100644
index 0000000..b40b3c5
--- /dev/null
+++ b/src/Service/NostrShareMenuBuilder.php
@@ -0,0 +1,348 @@
+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|\ArrayObject $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;
+ }
+}
diff --git a/src/Twig/NostrPathExtension.php b/src/Twig/NostrPathExtension.php
new file mode 100644
index 0000000..de83b31
--- /dev/null
+++ b/src/Twig/NostrPathExtension.php
@@ -0,0 +1,40 @@
+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);
+ }
+}
diff --git a/src/Twig/NostrShareMenuExtension.php b/src/Twig/NostrShareMenuExtension.php
new file mode 100644
index 0000000..b9fbee7
--- /dev/null
+++ b/src/Twig/NostrShareMenuExtension.php
@@ -0,0 +1,37 @@
+requestStack->getCurrentRequest();
+ if ($request === null) {
+ return null;
+ }
+
+ return $this->builder->buildForRequest($request);
+ }
+}
diff --git a/templates/components/Header.html.twig b/templates/components/Header.html.twig
index 36d1a8d..e5d6091 100644
--- a/templates/components/Header.html.twig
+++ b/templates/components/Header.html.twig
@@ -8,7 +8,10 @@
{{ website_name }}
-
+
-
+
{% elseif preview.type == 'nevent' %}
{% if preview.kind == 9802 %}
@@ -42,11 +37,6 @@
{% endif %}
- {% if preview.id is defined and preview.id %}
-
- {% endif %}
{% else %}
{% set is_longform = preview.kind == 30023 or preview.kind == 30024 %}
@@ -88,10 +78,6 @@
{% endif %}
diff --git a/templates/components/Molecules/NostrShareMenu.html.twig b/templates/components/Molecules/NostrShareMenu.html.twig
new file mode 100644
index 0000000..3f701c8
--- /dev/null
+++ b/templates/components/Molecules/NostrShareMenu.html.twig
@@ -0,0 +1,31 @@
+{% set share = nostr_share_menu() %}
+{% if share is not null %}
+
+{% endif %}
diff --git a/templates/components/Organisms/Comments.html.twig b/templates/components/Organisms/Comments.html.twig
index 4496677..1568341 100644
--- a/templates/components/Organisms/Comments.html.twig
+++ b/templates/components/Organisms/Comments.html.twig
@@ -182,10 +182,6 @@
{% if cts is not null and cts != '' %}{{ cts|date('F j Y') }}{% endif %}
- {% if cid != '' %}
-
- View event
- {% endif %}
diff --git a/templates/components/Organisms/FeaturedList.html.twig b/templates/components/Organisms/FeaturedList.html.twig
index 9d68004..a3b22d1 100644
--- a/templates/components/Organisms/FeaturedList.html.twig
+++ b/templates/components/Organisms/FeaturedList.html.twig
@@ -7,7 +7,7 @@
{% set feature = list[0] %}