Browse Source

fix replacement of categories

imwald
Silberengel 6 days ago
parent
commit
7e6f172731
  1. 10
      assets/styles/article.css
  2. 2
      src/Command/PrewarmCommand.php
  3. 4
      src/Dto/NostrShareMenuContext.php
  4. 5
      src/Repository/ArticleRepository.php
  5. 4
      src/Service/MagazineContentService.php
  6. 18
      src/Service/MagazineRefresher.php
  7. 46
      src/Service/NostrClient.php
  8. 69
      src/Service/NostrShareMenuBuilder.php
  9. 5
      templates/components/Molecules/Card.html.twig
  10. 3
      templates/components/Molecules/NostrShareMenu.html.twig
  11. 2
      templates/components/Organisms/CardList.html.twig
  12. 7
      templates/pages/category.html.twig

10
assets/styles/article.css

@ -40,6 +40,10 @@
align-items: flex-start; align-items: flex-start;
gap: 0.75rem; gap: 0.75rem;
flex-wrap: wrap; 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 { .card-header--article .card-title {
@ -48,6 +52,12 @@
margin: 0; 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 { .card.comment .metadata.comment-card__head {
align-items: flex-start; align-items: flex-start;
} }

2
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('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-metadata', null, InputOption::VALUE_NONE, 'Skip Nostr profile metadata cache')
->addOption('no-comments', null, InputOption::VALUE_NONE, 'Skip comment thread 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-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('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') ->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')

4
src/Dto/NostrShareMenuContext.php

@ -5,8 +5,8 @@ declare(strict_types=1);
namespace App\Dto; namespace App\Dto;
/** /**
* Nostr "⋯" share menu: copy npub; copy nevent or naddr (Jumble uses the same bech in /feed/notes/…). * Nostr "⋯" share menu: copy npub; copy naddr and/or nevent (Jumble /feed/notes/… uses the naddr when present, else nevent).
* Addressable (NIP-33) long-form / index events: prefer naddr; one-off stateless events: nevent. * For NIP-33 replaceable events, both can be set: naddr is the coordinate, nevent is the specific revision.
*/ */
final class NostrShareMenuContext final class NostrShareMenuContext
{ {

5
src/Repository/ArticleRepository.php

@ -110,11 +110,12 @@ class ArticleRepository extends ServiceEntityRepository
$qb = $this->createQueryBuilder('a'); $qb = $this->createQueryBuilder('a');
$orX = $qb->expr()->orX(); $orX = $qb->expr()->orX();
foreach ($pairs as $i => $p) { foreach ($pairs as $i => $p) {
$pkQ = strtolower((string) $p['pubkey']);
$orX->add($qb->expr()->andX( $orX->add($qb->expr()->andX(
$qb->expr()->eq('a.pubkey', ':pk'.$i), $qb->expr()->eq('a.pubkey', ':pk'.$i),
$qb->expr()->eq('a.slug', ':sl'.$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->setParameter('sl'.$i, $p['slug']);
} }
$qb->where($orX); $qb->where($orX);
@ -123,7 +124,7 @@ class ArticleRepository extends ServiceEntityRepository
$rows = $qb->getQuery()->getResult(); $rows = $qb->getQuery()->getResult();
$out = []; $out = [];
foreach ($rows as $a) { foreach ($rows as $a) {
$pk = (string) $a->getPubkey(); $pk = strtolower((string) $a->getPubkey());
$sl = trim((string) $a->getSlug()); $sl = trim((string) $a->getSlug());
if ($sl !== '') { if ($sl !== '') {
$out[$pk."\0".$sl] = $a; $out[$pk."\0".$sl] = $a;

4
src/Service/MagazineContentService.php

@ -236,7 +236,7 @@ final class MagazineContentService
continue; continue;
} }
$pairs[] = [ $pairs[] = [
'pubkey' => (string) $parts[1], 'pubkey' => strtolower((string) $parts[1]),
'slug' => $slugPart, 'slug' => $slugPart,
]; ];
} }
@ -246,7 +246,7 @@ final class MagazineContentService
if (\count($parts) < 3) { if (\count($parts) < 3) {
continue; continue;
} }
$k = (string) $parts[1]."\0".trim((string) $parts[2]); $k = strtolower((string) $parts[1])."\0".trim((string) $parts[2]);
if (isset($byAddress[$k])) { if (isset($byAddress[$k])) {
$list[] = $byAddress[$k]; $list[] = $byAddress[$k];
} }

18
src/Service/MagazineRefresher.php

@ -39,8 +39,13 @@ final class MagazineRefresher
} }
/** /**
* Fetches the root index then each category index until $budgetSeconds elapses. $preferSlugs * Fetches the root 30040, then each category 30040. The soft wall-time budget applies to the
* are requested first (e.g. current /cat route) so they are less likely to miss the budget. * **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<string, int|string|bool|null>): void)|null $onProgress * @param (callable(string, array<string, int|string|bool|null>): void)|null $onProgress
* Phases: `before_root`, `after_root` (total_steps, step, slug_count, slugs: list<string>), * Phases: `before_root`, `after_root` (total_steps, step, slug_count, slugs: list<string>),
@ -50,15 +55,12 @@ final class MagazineRefresher
{ {
// Allow large budgets (PrewarmCommand --magazine-budget). Hard cap only to avoid runaway PHP time. // Allow large budgets (PrewarmCommand --magazine-budget). Hard cap only to avoid runaway PHP time.
$budgetSeconds = max(1, min(600, $budgetSeconds)); $budgetSeconds = max(1, min(600, $budgetSeconds));
$deadline = microtime(true) + $budgetSeconds;
$npub = (string) $this->params->get('npub'); $npub = (string) $this->params->get('npub');
$dTag = (string) $this->params->get('d_tag'); $dTag = (string) $this->params->get('d_tag');
$preferFromEnv = $this->parseCommaSeparatedSlugs($this->magazinePrewarmPreferSlugs); $preferFromEnv = $this->parseCommaSeparatedSlugs($this->magazinePrewarmPreferSlugs);
// Do not set max_execution_time to the *remaining* soft budget: PHP resets the timer, so // Allow enough PHP wall time for a slow root fetch plus the full category-phase budget.
// after a 6s root fetch, "2s left" would become a 2s hard cap for the *next* relay I/O $this->applyExecutionTimeCap(2 * $budgetSeconds);
// (e.g. slow TLS) and can fatal. Cap once with headroom; the $deadline loop limits work.
$this->applyExecutionTimeCap($budgetSeconds);
$defaultRelay = (string) $this->params->get('default_relay'); $defaultRelay = (string) $this->params->get('default_relay');
$relayLabel = (string) (parse_url($defaultRelay, \PHP_URL_HOST) ?: $defaultRelay); $relayLabel = (string) (parse_url($defaultRelay, \PHP_URL_HOST) ?: $defaultRelay);
@ -86,6 +88,8 @@ final class MagazineRefresher
$this->store->putRoot($npub, $dTag, $root); $this->store->putRoot($npub, $dTag, $root);
$deadline = microtime(true) + $budgetSeconds;
$mergedPrefer = $this->mergePreferSlugsInOrder($preferSlugs, $preferFromEnv); $mergedPrefer = $this->mergePreferSlugsInOrder($preferSlugs, $preferFromEnv);
$alsoFromEnv = $this->parseCommaSeparatedSlugs($this->magazinePrewarmAlsoSlugs); $alsoFromEnv = $this->parseCommaSeparatedSlugs($this->magazinePrewarmAlsoSlugs);
if ($alsoFromEnv !== []) { if ($alsoFromEnv !== []) {

46
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 * NIP-33: from merged relay results, the event at the replaceable address kind:pubkeyLower:d.
* {@see wireEventSupersedes}. * 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<mixed> $events * @param list<mixed> $events
*/ */
@ -2457,42 +2462,25 @@ class NostrClient
} }
$wantD = trim($dTag); $wantD = trim($dTag);
$expectedAddr = (string) $expectedKind.':'.$authorHexLower.':'.$wantD; $expectedAddr = (string) $expectedKind.':'.$authorHexLower.':'.$wantD;
$byAddress = [];
foreach ($events as $e) { $merged = self::mergeNip33ParameterizedWireEvents($events);
$addr = self::nip33ParameterizedReplaceableAddress($e); foreach ($merged as $e) {
if ($addr === null) { if (!\is_object($e)) {
continue;
}
if (strtolower(self::magazineEventPubkeyHex($e)) !== $authorHexLower) {
continue;
}
if (self::eventDTagValue($e) !== $wantD) {
continue; continue;
} }
if (self::magazineEventKind($e) !== $expectedKind) { if (self::magazineEventKind($e) !== $expectedKind) {
continue; continue;
} }
if (!isset($byAddress[$addr]) || self::wireEventSupersedes($e, $byAddress[$addr])) { if (strtolower(self::magazineEventPubkeyHex($e)) !== $authorHexLower) {
$byAddress[$addr] = $e; continue;
} }
} $addr = self::nip33ParameterizedReplaceableAddress($e);
if ($byAddress === []) { if ($addr === $expectedAddr) {
return null; return $e;
}
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;
} }
} }
return $best; return null;
} }
/** /**

69
src/Service/NostrShareMenuBuilder.php

@ -38,17 +38,25 @@ final class NostrShareMenuBuilder
$npub = $key->convertPublicKeyToBech32($pubkeyHex); $npub = $key->convertPublicKeyToBech32($pubkeyHex);
$kind = (int) ($event->kind ?? 0); $kind = (int) ($event->kind ?? 0);
$d = self::dTagFromWireEvent($event); $d = self::dTagFromWireEvent($event);
$eventIdHex = strtolower((string) ($event->id ?? ''));
if (Nip19Addressable::isParameterizedReplaceableKind($kind) && $d !== null) { if (Nip19Addressable::isParameterizedReplaceableKind($kind) && $d !== null) {
$naddr = Nip19Addressable::naddrBech32($kind, $pubkeyHex, $d, $relayHints); $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( return new NostrShareMenuContext(
$npub, $npub,
null, $neventForRev,
$naddr, $naddr,
$this->feedJumble($naddr), $this->feedJumble($naddr),
); );
} }
$eventIdHex = strtolower((string) ($event->id ?? ''));
if (64 === \strlen($eventIdHex) && ctype_xdigit($eventIdHex)) { if (64 === \strlen($eventIdHex) && ctype_xdigit($eventIdHex)) {
$rebuilt = (string) Bech32::nevent( $rebuilt = (string) Bech32::nevent(
id: $eventIdHex, id: $eventIdHex,
@ -87,8 +95,6 @@ final class NostrShareMenuBuilder
$request->attributes->set(self::ATTR_NPUB, $ctx->npub); $request->attributes->set(self::ATTR_NPUB, $ctx->npub);
if ($ctx->naddrBech32 !== null && $ctx->naddrBech32 !== '') { if ($ctx->naddrBech32 !== null && $ctx->naddrBech32 !== '') {
$request->attributes->set(self::ATTR_NADDR_BECH32, $ctx->naddrBech32); $request->attributes->set(self::ATTR_NADDR_BECH32, $ctx->naddrBech32);
return;
} }
if ($ctx->neventBech32 !== null && $ctx->neventBech32 !== '') { if ($ctx->neventBech32 !== null && $ctx->neventBech32 !== '') {
$request->attributes->set(self::ATTR_NEVENT_BECH32, $ctx->neventBech32); $request->attributes->set(self::ATTR_NEVENT_BECH32, $ctx->neventBech32);
@ -205,10 +211,19 @@ final class NostrShareMenuBuilder
} }
$pk = strtolower((string) $article->getPubkey()); $pk = strtolower((string) $article->getPubkey());
$naddr = Nip19Addressable::naddrBech32($kind, $pk, $d, []); $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( return new NostrShareMenuContext(
$npub, $npub,
null, $nevent,
$naddr, $naddr,
$this->feedJumble($naddr), $this->feedJumble($naddr),
); );
@ -231,27 +246,23 @@ final class NostrShareMenuBuilder
private function forNevent(Request $request, string $neventFromRoute): NostrShareMenuContext private function forNevent(Request $request, string $neventFromRoute): NostrShareMenuContext
{ {
if ($request->attributes->has(self::ATTR_NPUB) && $request->attributes->has(self::ATTR_NADDR_BECH32)) { if ($request->attributes->has(self::ATTR_NPUB)
$naddr = (string) $request->attributes->get(self::ATTR_NADDR_BECH32); && ($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,
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); $np = (string) $request->attributes->get(self::ATTR_NPUB);
$naddrRaw = $request->attributes->get(self::ATTR_NADDR_BECH32);
return new NostrShareMenuContext( $naddr = \is_string($naddrRaw) && $naddrRaw !== '' ? $naddrRaw : null;
$np, $neventRaw = $request->attributes->get(self::ATTR_NEVENT_BECH32);
$nb, $nb = \is_string($neventRaw) && $neventRaw !== '' ? $neventRaw : null;
null, if (null !== $naddr || null !== $nb) {
$this->feedJumble($nb), $jumble = $this->feedJumble($naddr ?? $nb);
);
return new NostrShareMenuContext(
$np,
$nb,
$naddr,
$jumble,
);
}
} }
$nevent = $neventFromRoute; $nevent = $neventFromRoute;
@ -331,10 +342,16 @@ final class NostrShareMenuBuilder
$npub = $this->nostrKey()->convertPublicKeyToBech32($pk); $npub = $this->nostrKey()->convertPublicKeyToBech32($pk);
if (Nip19Addressable::isParameterizedReplaceableKind($kind) && $d !== null) { if (Nip19Addressable::isParameterizedReplaceableKind($kind) && $d !== null) {
$naddr = Nip19Addressable::naddrBech32($kind, $pk, $d, []); $naddr = Nip19Addressable::naddrBech32($kind, $pk, $d, []);
$neventForRev = (string) Bech32::nevent(
id: $id,
relays: [],
author: $pk,
kind: $kind,
);
return new NostrShareMenuContext( return new NostrShareMenuContext(
$npub, $npub,
null, $neventForRev,
$naddr, $naddr,
$this->feedJumble($naddr), $this->feedJumble($naddr),
); );

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

@ -1,4 +1,5 @@
{% if article is defined %} {% if article is defined %}
{% set card_title = article.title|default('')|trim %}
<div class="card"> <div class="card">
<div class="metadata"> <div class="metadata">
{% if category %} {% if category %}
@ -14,11 +15,11 @@
<div class="card-header"> <div class="card-header">
{% if category %}<small class="text-uppercase">{{ category }}</small>{% endif %} {% if category %}<small class="text-uppercase">{{ category }}</small>{% endif %}
{% if article.image %} {% if article.image %}
<img src="{{ article.image }}" alt="Cover image for {{ article.title }}" onerror="this.style.display='none';" > <img src="{{ article.image }}" alt="Cover image for {{ card_title != '' ? card_title : (article.slug|default('')) }}" onerror="this.style.display='none';" >
{% endif %} {% endif %}
</div> </div>
<div class="card-body"> <div class="card-body">
<h2 class="card-title">{{ article.title }}</h2> <h2 class="card-title">{% if card_title != '' %}{{ card_title }}{% else %}{{ article.slug|default('')|replace({'-': ' '})|title }}{% endif %}</h2>
{% if article.summary %} {% if article.summary %}
<p class="lede"> <p class="lede">
{{ article.summary }} {{ article.summary }}

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

@ -24,7 +24,8 @@
data-copy-text-text-value="{{ share.naddrBech32|e('html_attr') }}"> 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> <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> </li>
{% elseif share.neventBech32 is not null and share.neventBech32 is not same as('') %} {% endif %}
{% if share.neventBech32 is not null and share.neventBech32 is not same as('') %}
<li class="nostr-share-menu__item" role="none" <li class="nostr-share-menu__item" role="none"
data-controller="copy-text" data-controller="copy-text"
data-copy-text-text-value="{{ share.neventBech32|e('html_attr') }}"> data-copy-text-text-value="{{ share.neventBech32|e('html_attr') }}">

2
templates/components/Organisms/CardList.html.twig

@ -1,7 +1,7 @@
<div {{ attributes }}> <div {{ attributes }}>
{% set is_author_profile = is_author_profile|default(false) %} {% set is_author_profile = is_author_profile|default(false) %}
{% for item in list %} {% for item in list %}
{% if item.slug is not empty and item.title is not empty %} {% if item.slug is not empty %}
<twig:Molecules:Card :article="item" :is_author_profile="is_author_profile"></twig:Molecules:Card> <twig:Molecules:Card :article="item" :is_author_profile="is_author_profile"></twig:Molecules:Card>
{% endif %} {% endif %}
{% endfor %} {% endfor %}

7
templates/pages/category.html.twig

@ -29,6 +29,13 @@
{% endblock %} {% endblock %}
{% block body %} {% block body %}
<div class="card category-page__header-card">
<div class="card-header card-header--article">
<h1 class="card-title">{{ (category.title|default('')|trim) != '' ? category.title : 'Category' }}</h1>
{% set _cat_share = nostr_share_menu() %}
{% if _cat_share %}{% include 'components/Molecules/NostrShareMenu.html.twig' with { share: _cat_share, event_menu: true } only %}{% endif %}
</div>
</div>
<div class="category-body"> <div class="category-body">
<twig:Organisms:CardList :list="list" class="article-list" /> <twig:Organisms:CardList :list="list" class="article-list" />
</div> </div>

Loading…
Cancel
Save