diff --git a/assets/styles/article.css b/assets/styles/article.css index 8ce00f6..6a65b48 100644 --- a/assets/styles/article.css +++ b/assets/styles/article.css @@ -40,6 +40,10 @@ align-items: flex-start; gap: 0.75rem; flex-wrap: wrap; + /* .card-header { overflow: hidden } would clip the ⋯ dropdown; following siblings can paint on top. */ + overflow: visible; + position: relative; + z-index: 5; } .card-header--article .card-title { @@ -48,6 +52,12 @@ margin: 0; } +/* Sibling .category-body would paint over the ⋯ popover; lift the title card above the list. */ +.category-page__header-card { + position: relative; + z-index: 6; +} + .card.comment .metadata.comment-card__head { align-items: flex-start; } diff --git a/src/Command/PrewarmCommand.php b/src/Command/PrewarmCommand.php index fbc7a79..0eb22bb 100644 --- a/src/Command/PrewarmCommand.php +++ b/src/Command/PrewarmCommand.php @@ -65,7 +65,7 @@ final class PrewarmCommand extends Command ->addOption('deletion-since', null, InputOption::VALUE_REQUIRED, 'strtotime() window start for kind 5 fetch', '-2 month') ->addOption('no-metadata', null, InputOption::VALUE_NONE, 'Skip Nostr profile metadata cache') ->addOption('no-comments', null, InputOption::VALUE_NONE, 'Skip comment thread cache') - ->addOption('magazine-budget', null, InputOption::VALUE_REQUIRED, 'Seconds wall time for magazine relay refresh (capped at 600s; if many category indices, raise this or set MAGAZINE_PREWARM_PREFER_SLUGS for hot slugs first)', '90') + ->addOption('magazine-budget', null, InputOption::VALUE_REQUIRED, 'Seconds wall time for the category 30040 phase only (root fetch is not counted; capped at 600s). If many slugs, raise this or set MAGAZINE_PREWARM_PREFER_SLUGS', '90') ->addOption('metadata-limit', null, InputOption::VALUE_REQUIRED, 'Max distinct author pubkeys to warm (0 = all)', '0') ->addOption('metadata-batch', null, InputOption::VALUE_REQUIRED, 'Kind-0 metadata: pubkeys per Nostr REQ (batched)', '50') ->addOption('comments-max', null, InputOption::VALUE_REQUIRED, 'Newest N magazine category articles to warm comment cache for (0 = all, order: createdAt DESC; excludes generic /articles feed-only rows)', '10') diff --git a/src/Dto/NostrShareMenuContext.php b/src/Dto/NostrShareMenuContext.php index d30b2f4..8f3fd7c 100644 --- a/src/Dto/NostrShareMenuContext.php +++ b/src/Dto/NostrShareMenuContext.php @@ -5,8 +5,8 @@ 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. + * Nostr "⋯" share menu: copy npub; copy naddr and/or nevent (Jumble /feed/notes/… uses the naddr when present, else nevent). + * For NIP-33 replaceable events, both can be set: naddr is the coordinate, nevent is the specific revision. */ final class NostrShareMenuContext { diff --git a/src/Repository/ArticleRepository.php b/src/Repository/ArticleRepository.php index 86813ea..6261945 100644 --- a/src/Repository/ArticleRepository.php +++ b/src/Repository/ArticleRepository.php @@ -110,11 +110,12 @@ class ArticleRepository extends ServiceEntityRepository $qb = $this->createQueryBuilder('a'); $orX = $qb->expr()->orX(); foreach ($pairs as $i => $p) { + $pkQ = strtolower((string) $p['pubkey']); $orX->add($qb->expr()->andX( $qb->expr()->eq('a.pubkey', ':pk'.$i), $qb->expr()->eq('a.slug', ':sl'.$i) )); - $qb->setParameter('pk'.$i, $p['pubkey']); + $qb->setParameter('pk'.$i, $pkQ); $qb->setParameter('sl'.$i, $p['slug']); } $qb->where($orX); @@ -123,7 +124,7 @@ class ArticleRepository extends ServiceEntityRepository $rows = $qb->getQuery()->getResult(); $out = []; foreach ($rows as $a) { - $pk = (string) $a->getPubkey(); + $pk = strtolower((string) $a->getPubkey()); $sl = trim((string) $a->getSlug()); if ($sl !== '') { $out[$pk."\0".$sl] = $a; diff --git a/src/Service/MagazineContentService.php b/src/Service/MagazineContentService.php index 51c507c..3838cc3 100644 --- a/src/Service/MagazineContentService.php +++ b/src/Service/MagazineContentService.php @@ -236,7 +236,7 @@ final class MagazineContentService continue; } $pairs[] = [ - 'pubkey' => (string) $parts[1], + 'pubkey' => strtolower((string) $parts[1]), 'slug' => $slugPart, ]; } @@ -246,7 +246,7 @@ final class MagazineContentService if (\count($parts) < 3) { continue; } - $k = (string) $parts[1]."\0".trim((string) $parts[2]); + $k = strtolower((string) $parts[1])."\0".trim((string) $parts[2]); if (isset($byAddress[$k])) { $list[] = $byAddress[$k]; } diff --git a/src/Service/MagazineRefresher.php b/src/Service/MagazineRefresher.php index d8c9cfd..207c0a6 100644 --- a/src/Service/MagazineRefresher.php +++ b/src/Service/MagazineRefresher.php @@ -39,8 +39,13 @@ final class MagazineRefresher } /** - * Fetches the root index then each category index until $budgetSeconds elapses. $preferSlugs - * are requested first (e.g. current /cat route) so they are less likely to miss the budget. + * Fetches the root 30040, then each category 30040. The soft wall-time budget applies to the + * **category phase only** (after the root is stored). The root fetch is not counted against that + * window—otherwise a slow root can consume the entire default budget and no category would be + * refreshed (stale per-category cache while the root looks current). + * + * $preferSlugs are requested first (e.g. current /cat route) so they are less likely to miss + * the category budget if the slug list is long. * * @param (callable(string, array): void)|null $onProgress * Phases: `before_root`, `after_root` (total_steps, step, slug_count, slugs: list), @@ -50,15 +55,12 @@ final class MagazineRefresher { // Allow large budgets (PrewarmCommand --magazine-budget). Hard cap only to avoid runaway PHP time. $budgetSeconds = max(1, min(600, $budgetSeconds)); - $deadline = microtime(true) + $budgetSeconds; $npub = (string) $this->params->get('npub'); $dTag = (string) $this->params->get('d_tag'); $preferFromEnv = $this->parseCommaSeparatedSlugs($this->magazinePrewarmPreferSlugs); - // Do not set max_execution_time to the *remaining* soft budget: PHP resets the timer, so - // after a 6s root fetch, "2s left" would become a 2s hard cap for the *next* relay I/O - // (e.g. slow TLS) and can fatal. Cap once with headroom; the $deadline loop limits work. - $this->applyExecutionTimeCap($budgetSeconds); + // Allow enough PHP wall time for a slow root fetch plus the full category-phase budget. + $this->applyExecutionTimeCap(2 * $budgetSeconds); $defaultRelay = (string) $this->params->get('default_relay'); $relayLabel = (string) (parse_url($defaultRelay, \PHP_URL_HOST) ?: $defaultRelay); @@ -86,6 +88,8 @@ final class MagazineRefresher $this->store->putRoot($npub, $dTag, $root); + $deadline = microtime(true) + $budgetSeconds; + $mergedPrefer = $this->mergePreferSlugsInOrder($preferSlugs, $preferFromEnv); $alsoFromEnv = $this->parseCommaSeparatedSlugs($this->magazinePrewarmAlsoSlugs); if ($alsoFromEnv !== []) { diff --git a/src/Service/NostrClient.php b/src/Service/NostrClient.php index 33c4565..c084c53 100644 --- a/src/Service/NostrClient.php +++ b/src/Service/NostrClient.php @@ -2441,8 +2441,13 @@ class NostrClient } /** - * NIP-33: among relay results for a single (kind, author, d) filter, keep the live revision per - * {@see wireEventSupersedes}. + * NIP-33: from merged relay results, the event at the replaceable address kind:pubkeyLower:d. + * Uses {@see mergeNip33ParameterizedWireEvents} so every relay’s copies collapse to the live + * revision the same way everywhere; then we match the requested address only. + * + * (Older logic reimplemented “merge” by hand and had a fallback that could return a **different** + * 30040 (wrong #d) when the expected address key did not line up, which surfaced as “stale” + * category indices even when a newer note existed on a relay such as TheForest.) * * @param list $events */ @@ -2457,42 +2462,25 @@ class NostrClient } $wantD = trim($dTag); $expectedAddr = (string) $expectedKind.':'.$authorHexLower.':'.$wantD; - $byAddress = []; - foreach ($events as $e) { - $addr = self::nip33ParameterizedReplaceableAddress($e); - if ($addr === null) { - continue; - } - if (strtolower(self::magazineEventPubkeyHex($e)) !== $authorHexLower) { - continue; - } - if (self::eventDTagValue($e) !== $wantD) { + + $merged = self::mergeNip33ParameterizedWireEvents($events); + foreach ($merged as $e) { + if (!\is_object($e)) { continue; } if (self::magazineEventKind($e) !== $expectedKind) { continue; } - if (!isset($byAddress[$addr]) || self::wireEventSupersedes($e, $byAddress[$addr])) { - $byAddress[$addr] = $e; + if (strtolower(self::magazineEventPubkeyHex($e)) !== $authorHexLower) { + continue; } - } - if ($byAddress === []) { - return null; - } - if (isset($byAddress[$expectedAddr])) { - return $byAddress[$expectedAddr]; - } - if (\count($byAddress) === 1) { - return $byAddress[array_key_first($byAddress)]; - } - $best = null; - foreach ($byAddress as $e) { - if ($best === null || self::wireEventSupersedes($e, $best)) { - $best = $e; + $addr = self::nip33ParameterizedReplaceableAddress($e); + if ($addr === $expectedAddr) { + return $e; } } - return $best; + return null; } /** diff --git a/src/Service/NostrShareMenuBuilder.php b/src/Service/NostrShareMenuBuilder.php index b3a07b2..b7e8251 100644 --- a/src/Service/NostrShareMenuBuilder.php +++ b/src/Service/NostrShareMenuBuilder.php @@ -38,17 +38,25 @@ final class NostrShareMenuBuilder $npub = $key->convertPublicKeyToBech32($pubkeyHex); $kind = (int) ($event->kind ?? 0); $d = self::dTagFromWireEvent($event); + $eventIdHex = strtolower((string) ($event->id ?? '')); if (Nip19Addressable::isParameterizedReplaceableKind($kind) && $d !== null) { $naddr = Nip19Addressable::naddrBech32($kind, $pubkeyHex, $d, $relayHints); + $neventForRev = (64 === \strlen($eventIdHex) && ctype_xdigit($eventIdHex)) + ? (string) Bech32::nevent( + id: $eventIdHex, + relays: $relayHints, + author: $pubkeyHex, + kind: $kind, + ) + : null; return new NostrShareMenuContext( $npub, - null, + $neventForRev, $naddr, $this->feedJumble($naddr), ); } - $eventIdHex = strtolower((string) ($event->id ?? '')); if (64 === \strlen($eventIdHex) && ctype_xdigit($eventIdHex)) { $rebuilt = (string) Bech32::nevent( id: $eventIdHex, @@ -87,8 +95,6 @@ final class NostrShareMenuBuilder $request->attributes->set(self::ATTR_NPUB, $ctx->npub); if ($ctx->naddrBech32 !== null && $ctx->naddrBech32 !== '') { $request->attributes->set(self::ATTR_NADDR_BECH32, $ctx->naddrBech32); - - return; } if ($ctx->neventBech32 !== null && $ctx->neventBech32 !== '') { $request->attributes->set(self::ATTR_NEVENT_BECH32, $ctx->neventBech32); @@ -205,10 +211,19 @@ final class NostrShareMenuBuilder } $pk = strtolower((string) $article->getPubkey()); $naddr = Nip19Addressable::naddrBech32($kind, $pk, $d, []); + $eid = strtolower((string) ($article->getEventId() ?? '')); + $nevent = (64 === \strlen($eid) && ctype_xdigit($eid)) + ? (string) Bech32::nevent( + id: $eid, + relays: [], + author: $pk, + kind: $kind, + ) + : null; return new NostrShareMenuContext( $npub, - null, + $nevent, $naddr, $this->feedJumble($naddr), ); @@ -231,27 +246,23 @@ final class NostrShareMenuBuilder 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); + if ($request->attributes->has(self::ATTR_NPUB) + && ($request->attributes->has(self::ATTR_NADDR_BECH32) || $request->attributes->has(self::ATTR_NEVENT_BECH32))) { $np = (string) $request->attributes->get(self::ATTR_NPUB); - - return new NostrShareMenuContext( - $np, - $nb, - null, - $this->feedJumble($nb), - ); + $naddrRaw = $request->attributes->get(self::ATTR_NADDR_BECH32); + $naddr = \is_string($naddrRaw) && $naddrRaw !== '' ? $naddrRaw : null; + $neventRaw = $request->attributes->get(self::ATTR_NEVENT_BECH32); + $nb = \is_string($neventRaw) && $neventRaw !== '' ? $neventRaw : null; + if (null !== $naddr || null !== $nb) { + $jumble = $this->feedJumble($naddr ?? $nb); + + return new NostrShareMenuContext( + $np, + $nb, + $naddr, + $jumble, + ); + } } $nevent = $neventFromRoute; @@ -331,10 +342,16 @@ final class NostrShareMenuBuilder $npub = $this->nostrKey()->convertPublicKeyToBech32($pk); if (Nip19Addressable::isParameterizedReplaceableKind($kind) && $d !== null) { $naddr = Nip19Addressable::naddrBech32($kind, $pk, $d, []); + $neventForRev = (string) Bech32::nevent( + id: $id, + relays: [], + author: $pk, + kind: $kind, + ); return new NostrShareMenuContext( $npub, - null, + $neventForRev, $naddr, $this->feedJumble($naddr), ); diff --git a/templates/components/Molecules/Card.html.twig b/templates/components/Molecules/Card.html.twig index 0be5f80..e52d862 100644 --- a/templates/components/Molecules/Card.html.twig +++ b/templates/components/Molecules/Card.html.twig @@ -1,4 +1,5 @@ {% if article is defined %} + {% set card_title = article.title|default('')|trim %}