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 }} - +
+ {% include 'components/Molecules/NostrShareMenu.html.twig' %} + +
    diff --git a/templates/components/Molecules/Card.html.twig b/templates/components/Molecules/Card.html.twig index 21f4008..0be5f80 100644 --- a/templates/components/Molecules/Card.html.twig +++ b/templates/components/Molecules/Card.html.twig @@ -10,7 +10,7 @@ {{ article.createdAt|date('F j Y') }} {% endif %}
- + {% 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] %}
- +
{% if feature.image %} Cover image for {{ feature.title }} @@ -26,7 +26,7 @@ {% for item in list %} {% if item != feature %}
- +

{{ item.title }}

diff --git a/templates/pages/article.html.twig b/templates/pages/article.html.twig index 9ab0957..c1cf380 100644 --- a/templates/pages/article.html.twig +++ b/templates/pages/article.html.twig @@ -21,7 +21,7 @@ {% set _og_default_dims = false %} {% endif %} {% 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 = '' %} {% if author is defined and author %} {% set _author_name = attribute(author, 'name')|default(attribute(author, 'display_name')|default('')) %} diff --git a/templates/pages/author.html.twig b/templates/pages/author.html.twig index 9fa2c53..6513cb8 100644 --- a/templates/pages/author.html.twig +++ b/templates/pages/author.html.twig @@ -9,7 +9,6 @@ profile_websites: profile_websites, profile_nip05: profile_nip05, profile_payment_links: profile_payment_links, - jumble_profile_href: jumble_profile_href, } only %}


diff --git a/templates/pages/featured_authors.html.twig b/templates/pages/featured_authors.html.twig index 2bff0ca..30f04b3 100644 --- a/templates/pages/featured_authors.html.twig +++ b/templates/pages/featured_authors.html.twig @@ -26,14 +26,9 @@ profile_nip05: [], profile_websites: row.profile_websites, profile_payment_links: row.profile_payment_links, - jumble_profile_href: row.jumble_profile_href, - omit_jumble_button: true, } only %}
diff --git a/templates/partial/author_profile_header.html.twig b/templates/partial/author_profile_header.html.twig index 05c627a..e1bc3a4 100644 --- a/templates/partial/author_profile_header.html.twig +++ b/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 %} {% if author.picture is defined and author.picture %} {% set author_pic = author.picture %} @@ -77,9 +77,3 @@ {{ author.about|markdown_to_html|mentionify|linkify }} {% endif %}
- - {% if not omit_jumble_button|default(false) and jumble_profile_href is not null and jumble_profile_href != '' %} -

- View on Jumble -

- {% endif %}