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 {{ tag[1] }}
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 %}
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 %}