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. 90
      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. 5
      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 { @@ -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

86
assets/styles/layout.css

@ -77,6 +77,75 @@ nav a:hover { @@ -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 { @@ -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 */

31
config/packages/monolog.yaml

@ -1,3 +1,7 @@ @@ -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: @@ -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: @@ -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

2
config/services.yaml

@ -45,6 +45,8 @@ services: @@ -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:

2
config/unfold.yaml

@ -35,6 +35,8 @@ parameters: @@ -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)%'

90
src/Controller/ArticleController.php

@ -272,7 +272,9 @@ class ArticleController extends AbstractController @@ -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 @@ -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 @@ -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 @@ -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 @@ -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,
]);
}

5
src/Controller/AuthorController.php

@ -75,10 +75,6 @@ class AuthorController extends AbstractController @@ -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 @@ -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,
]);
}

27
src/Controller/EventController.php

@ -6,6 +6,7 @@ namespace App\Controller; @@ -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; @@ -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 @@ -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 @@ -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 @@ -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 @@ -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)) {

3
src/Controller/FeaturedAuthorsController.php

@ -30,7 +30,6 @@ final class FeaturedAuthorsController extends AbstractController @@ -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 @@ -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 @@ -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,
];
}

13
src/Controller/SeoController.php

@ -10,6 +10,7 @@ use App\Repository\ArticleRepository; @@ -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 @@ -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 @@ -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 @@ -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() ?? '');

21
src/Dto/NostrShareMenuContext.php

@ -0,0 +1,21 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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);
}
}

5
templates/components/Header.html.twig

@ -8,7 +8,10 @@ @@ -8,7 +8,10 @@
<span class="brand__title">{{ website_name }}</span>
</h1>
</a>
<button class="hamburger btn btn-secondary" data-action="click->menu#toggle" aria-label="Menu">&#9776;</button>
<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>
</div>
</div>
<div class="header__categories" data-menu-target="menu">
<ul>

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

@ -10,7 +10,7 @@ @@ -10,7 +10,7 @@
<small>{{ article.createdAt|date('F j Y') }}</small>
{% endif %}
</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">
{% if category %}<small class="text-uppercase">{{ category }}</small>{% endif %}
{% if article.image %}

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

@ -10,11 +10,6 @@ @@ -10,11 +10,6 @@
<p class="card-text">{{ tag[1] }}</p>
{% endif %}
{% 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>
{% elseif preview.type == 'nevent' %}
{% if preview.kind == 9802 %}
@ -42,11 +37,6 @@ @@ -42,11 +37,6 @@
</blockquote>
{% endif %}
</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>
{% else %}
{% set is_longform = preview.kind == 30023 or preview.kind == 30024 %}
@ -88,10 +78,6 @@ @@ -88,10 +78,6 @@
</div>
<div class="card-footer nostr-preview-card__meta text-subtle">
<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>
{% endif %}

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

@ -0,0 +1,31 @@ @@ -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 @@ @@ -182,10 +182,6 @@
</p>
<small>
{% 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>
</div>
<div class="card-body">

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

@ -7,7 +7,7 @@ @@ -7,7 +7,7 @@
<div>
{% set feature = list[0] %}
<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">
{% if feature.image %}
<img src="{{ feature.image }}" alt="Cover image for {{ feature.title }}">
@ -26,7 +26,7 @@ @@ -26,7 +26,7 @@
{% for item in list %}
{% if item != feature %}
<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">
<h2 class="card-title">{{ item.title }}</h2>
<p class="lede truncate">

2
templates/pages/article.html.twig

@ -21,7 +21,7 @@ @@ -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('')) %}

1
templates/pages/author.html.twig

@ -9,7 +9,6 @@ @@ -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 %}
<hr class="author-profile__divider" />

5
templates/pages/featured_authors.html.twig

@ -26,14 +26,9 @@ @@ -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 %}
</div>
<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>
</div>
</article>

8
templates/partial/author_profile_header.html.twig

@ -1,4 +1,4 @@ @@ -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 @@ @@ -77,9 +77,3 @@
{{ author.about|markdown_to_html|mentionify|linkify }}
{% endif %}
</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