From 0e57e157db51ae04fd3f192c1094c8bf10bf409c Mon Sep 17 00:00:00 2001 From: Silberengel Date: Thu, 30 Apr 2026 09:08:11 +0200 Subject: [PATCH] reveal curation set at the top of landing page --- .dockerignore | 6 +- .../article_comments_controller.js | 9 +- assets/controllers/login_controller.js | 5 +- assets/controllers/progress_bar_controller.js | 26 ++- config/unfold.yaml | 6 +- migrations/Version20260428103000.php | 29 +++ phpstan-baseline.neon | 6 - src/Command/PrewarmCommand.php | 9 +- src/Controller/DefaultController.php | 3 + src/Dto/FeaturedArticleCard.php | 24 +++ src/Entity/Event.php | 2 + src/Nostr/MagazineEventKeys.php | 25 ++- src/Service/MagazineContentService.php | 124 +++++++++++- src/Service/MagazineIndexStore.php | 31 ++- src/Service/MagazineRefresher.php | 30 +++ src/Service/Nip09DeletionApplier.php | 65 +++++- src/Service/NostrClient.php | 188 ++++++++++++++++++ src/Service/NostrKind5DeletionFilter.php | 8 +- src/Util/CurationSet30004Home.php | 58 ++++++ .../Organisms/FeaturedWall.html.twig | 6 +- templates/home.html.twig | 10 + .../Service/NostrKind5DeletionFilterTest.php | 11 + tests/Util/CurationSet30004HomeTest.php | 44 ++++ 23 files changed, 682 insertions(+), 43 deletions(-) create mode 100644 migrations/Version20260428103000.php create mode 100644 src/Util/CurationSet30004Home.php create mode 100644 tests/Util/CurationSet30004HomeTest.php diff --git a/.dockerignore b/.dockerignore index 75f39a4..51f9249 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,8 +2,10 @@ **/*.md **/*.php~ **/*.dist.php -**/*.dist -!.env.dist +# Do not use `**/*.dist`: it matches `.env.dist`, and negation order is easy to get wrong across +# Docker versions. Ignore other *.dist templates explicitly (see Dockerfile `cp .env.dist`). +**/phpunit.xml.dist +**/phpstan.neon.dist **/*.cache **/._* **/.dockerignore diff --git a/assets/controllers/article_comments_controller.js b/assets/controllers/article_comments_controller.js index 4cc5fe2..336e3e6 100644 --- a/assets/controllers/article_comments_controller.js +++ b/assets/controllers/article_comments_controller.js @@ -13,7 +13,10 @@ export default class extends Controller { connect() { this.partialReloads = 0; - this.boundOnAuth = this.onAuthChanged.bind(this); + // Stable reference across reconnects: rebinding each connect() would strand old listeners + // because removeEventListener must use the same function reference that was passed to add. + this.boundOnAuth ??= this.onAuthChanged.bind(this); + window.removeEventListener('unfold:auth-changed', this.boundOnAuth); window.addEventListener('unfold:auth-changed', this.boundOnAuth); if (!this.hasContainerTarget || !this.urlValue) { return; @@ -28,7 +31,9 @@ export default class extends Controller { } disconnect() { - window.removeEventListener('unfold:auth-changed', this.boundOnAuth); + if (this.boundOnAuth) { + window.removeEventListener('unfold:auth-changed', this.boundOnAuth); + } } onAuthChanged() { diff --git a/assets/controllers/login_controller.js b/assets/controllers/login_controller.js index 24f9e48..f81c443 100644 --- a/assets/controllers/login_controller.js +++ b/assets/controllers/login_controller.js @@ -29,8 +29,9 @@ export default class extends Controller { if (!response.ok) return false; return 'Authentication Successful'; }) - if (!!result) { - await this.component.render(); + if (result) { + // Do not await render(): in UX Live Component it can deadlock the same update/render loop. + void this.component.render(); window.dispatchEvent( new CustomEvent('unfold:auth-changed', { detail: { loggedIn: true } }) ); diff --git a/assets/controllers/progress_bar_controller.js b/assets/controllers/progress_bar_controller.js index c052a63..153b752 100644 --- a/assets/controllers/progress_bar_controller.js +++ b/assets/controllers/progress_bar_controller.js @@ -11,17 +11,27 @@ export default class extends Controller { // removeEventListener; new .bind() references each connect() would leave stale listeners. this.boundHandleInteraction ??= this.handleInteraction.bind(this); this.boundPageShow ??= this.onPageShow.bind(this); + this.boundTouchStart ??= this.handleTouchStart.bind(this); + this.boundTouchEnd ??= this.handleTouchEnd.bind(this); + document.removeEventListener('click', this.boundHandleInteraction); document.addEventListener('click', this.boundHandleInteraction); - document.addEventListener('touchstart', this.handleTouchStart); - document.addEventListener('touchend', this.handleTouchEnd); + document.removeEventListener('touchstart', this.boundTouchStart); + document.addEventListener('touchstart', this.boundTouchStart); + document.removeEventListener('touchend', this.boundTouchEnd); + document.addEventListener('touchend', this.boundTouchEnd); + window.removeEventListener('pageshow', this.boundPageShow); window.addEventListener('pageshow', this.boundPageShow); this.resumeIfPending(); } disconnect() { document.removeEventListener('click', this.boundHandleInteraction); - document.removeEventListener('touchstart', this.handleTouchStart); - document.removeEventListener('touchend', this.handleTouchEnd); + if (this.boundTouchStart) { + document.removeEventListener('touchstart', this.boundTouchStart); + } + if (this.boundTouchEnd) { + document.removeEventListener('touchend', this.boundTouchEnd); + } window.removeEventListener('pageshow', this.boundPageShow); if (this.loadListener) { window.removeEventListener('load', this.loadListener); @@ -85,20 +95,20 @@ export default class extends Controller { this.barTarget.style.width = '0'; } - handleTouchStart = (event) => { + handleTouchStart(event) { const touch = event.changedTouches[0]; this.touchStartX = touch.screenX; this.touchStartY = touch.screenY; - }; + } - handleTouchEnd = (event) => { + handleTouchEnd(event) { const touch = event.changedTouches[0]; const dx = Math.abs(touch.screenX - this.touchStartX); const dy = Math.abs(touch.screenY - this.touchStartY); if (dx < 10 && dy < 10) { this.handleInteraction(event); } - }; + } handleInteraction(event) { const link = event.target.closest('a'); diff --git a/config/unfold.yaml b/config/unfold.yaml index 31190d7..c48cfaa 100644 --- a/config/unfold.yaml +++ b/config/unfold.yaml @@ -44,7 +44,11 @@ parameters: theme_bg_color: '#f1ebe4' npub: 'npub1m4ny6hjqzepn4rxknuq94c2gpqzr29ufkkw7ttcxyak7v43n6vvsajc2jl' - d_tag: 'newsroom-magazine-on-imwald-by-laeserin' + # Kind 30040 magazine root #d (NIP-33). Exposed as `d_tag` for backward compatibility. + d_tag_magazine: 'newsroom-magazine-on-imwald-by-laeserin' + d_tag: '%d_tag_magazine%' + # NIP-51 kind 30004 curation set #d for `npub` (home “spotlight” strip): ordered `a` for kind 30023 only; other `a` kinds and `e` tags are ignored. Empty or `d-tag-goes-here` disables. + d_tag_curation_set: 'd-tag-goes-here' community_articles: true # Domain for site-assigned NIP-05 for featured (magazine category) authors; must match the host serving /.well-known/nostr.json nip05_domain: 'blog.imwald.eu' diff --git a/migrations/Version20260428103000.php b/migrations/Version20260428103000.php new file mode 100644 index 0000000..36b9455 --- /dev/null +++ b/migrations/Version20260428103000.php @@ -0,0 +1,29 @@ +addSql("DELETE FROM event WHERE storage_role = 'magazine_curation_kind1_ref'"); + } + + public function down(Schema $schema): void + { + // Irreversible data purge; rows were ephemeral cache copies of relay notes. + } +} diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 0c1d458..a07bbf7 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -432,12 +432,6 @@ parameters: count: 3 path: src/Service/MagazineContentService.php - - - message: '#^Call to function method_exists\(\) with App\\Entity\\Event and ''getTags'' will always evaluate to true\.$#' - identifier: function.alreadyNarrowedType - count: 1 - path: src/Service/MagazineContentService.php - - message: '#^Offset ''categories'' on array\{categories\: list\\}\>\} on left side of \?\? always exists and is not nullable\.$#' identifier: nullCoalesce.offset diff --git a/src/Command/PrewarmCommand.php b/src/Command/PrewarmCommand.php index bffbe7e..48955f6 100644 --- a/src/Command/PrewarmCommand.php +++ b/src/Command/PrewarmCommand.php @@ -64,7 +64,7 @@ final class PrewarmCommand extends Command { $this ->addOption('no-magazine', null, InputOption::VALUE_NONE, 'Skip magazine 30040 index fetch') - ->addOption('no-deletions', null, InputOption::VALUE_NONE, 'Skip NIP-09 kind 5 deletion sync (articles + event rows for stored kinds)') + ->addOption('no-deletions', null, InputOption::VALUE_NONE, 'Skip NIP-09 kind 5 deletion sync (articles + event rows: magazine, curation 30004, cached curation notes, profiles, etc.)') ->addOption('deletion-since', null, InputOption::VALUE_REQUIRED, 'strtotime() window start for kind 5 fetch', '-2 month') ->addOption('no-metadata', null, InputOption::VALUE_NONE, 'Skip batched kind-0 profile prewarm (MySQL event table)') ->addOption('no-comments', null, InputOption::VALUE_NONE, 'Skip comment thread cache') @@ -236,7 +236,7 @@ final class PrewarmCommand extends Command $this->disableCliExecutionTimeLimit(); if (!$input->getOption('no-deletions')) { - $io->section('NIP-09 deletions (kind 5 → 30023/30024 / 30040)'); + $io->section('NIP-09 deletions (kind 5 → 30023/30024 / 30040 / 30004)'); $sinceStr = (string) $input->getOption('deletion-since'); $since = strtotime($sinceStr); if ($since === false) { @@ -284,11 +284,12 @@ final class PrewarmCommand extends Command try { $st = $this->nip09DeletionApplier->apply($kind5); $io->writeln(sprintf( - 'Kind 5 events: %d (deduped). NIP-23 long-form in DB (30023/30024) removed: %d. Magazine index in cache (30040) removed: root %d, category %d.', + 'Kind 5 events: %d (deduped). NIP-23 long-form in DB (30023/30024) removed: %d. Magazine index in cache (30040) removed: root %d, category %d. Home curation (30004) rows removed: %d.', \count($kind5), $st['articles_removed'], $st['magazine_roots'], - $st['magazine_categories'] + $st['magazine_categories'], + $st['magazine_curation_30004'] ?? 0 )); } catch (\Throwable $e) { $this->logger->error('app:prewarm NIP-09 failed', ['exception' => $e]); diff --git a/src/Controller/DefaultController.php b/src/Controller/DefaultController.php index 30e680f..a61ccaf 100644 --- a/src/Controller/DefaultController.php +++ b/src/Controller/DefaultController.php @@ -25,8 +25,11 @@ class DefaultController extends AbstractController public function index(): Response { $categoryATags = $this->magazineContent->getHomeCategoryAIndexTagsFromStoreOnly(); + $curation = $this->magazineContent->buildHomeCurationWallData(); return $this->render('home.html.twig', [ + 'home_curation_heading' => $curation['heading'], + 'home_curation_tiles' => $curation['tiles'], 'home_featured_tiles' => $this->magazineContent->buildHomeMixedFeaturedWallTiles($categoryATags), 'home_highlights' => $this->articleHighlightRepository->findRecentForHome(40), ]); diff --git a/src/Dto/FeaturedArticleCard.php b/src/Dto/FeaturedArticleCard.php index c520ad8..c5af03c 100644 --- a/src/Dto/FeaturedArticleCard.php +++ b/src/Dto/FeaturedArticleCard.php @@ -4,6 +4,8 @@ declare(strict_types=1); namespace App\Dto; +use App\Entity\Article; + /** * Minimal article row for home/category list cards (avoids loading long-form `content` from the DB). */ @@ -21,6 +23,28 @@ final readonly class FeaturedArticleCard ) { } + public static function fromArticle(Article $a): self + { + $rawId = $a->getId(); + $id = null; + if (\is_int($rawId)) { + $id = $rawId; + } elseif (\is_string($rawId) && ctype_digit($rawId)) { + $id = (int) $rawId; + } + + return new self( + $id, + $a->getSlug(), + $a->getTitle(), + $a->getSummary(), + $a->getImage(), + $a->getCreatedAt(), + $a->getPublishedAt(), + $a->getPubkey(), + ); + } + public function getId(): ?int { return $this->id; diff --git a/src/Entity/Event.php b/src/Entity/Event.php index a80f191..99fad51 100644 --- a/src/Entity/Event.php +++ b/src/Entity/Event.php @@ -17,6 +17,8 @@ class Event public const STORAGE_MAGAZINE_CATEGORY = 'magazine_category'; + public const STORAGE_MAGAZINE_CURATION_30004 = 'magazine_curation_30004'; + public const STORAGE_PROFILE_KIND0 = 'profile'; public const STORAGE_RELAY_LIST_10002 = 'relay_list'; diff --git a/src/Nostr/MagazineEventKeys.php b/src/Nostr/MagazineEventKeys.php index ceb63c8..8eef0e4 100644 --- a/src/Nostr/MagazineEventKeys.php +++ b/src/Nostr/MagazineEventKeys.php @@ -7,10 +7,33 @@ namespace App\Nostr; use App\Service\NostrKeyHelper; /** - * Stable keys for {@see Event} rows: magazine root/category indices and kind-0 profiles in MySQL. + * Stable keys for {@see Event} rows: magazine root/category indices, kind 30004 curation set, and kind-0 profiles in MySQL. */ final class MagazineEventKeys { + public static function magazineCuration30004(string $npub, string $dTag): string + { + $hex = self::npubToHex($npub); + if ($hex === '') { + return ''; + } + + return 'mcur:'.$hex.':'.trim($dTag, " \0\x0B\t\n\r"); + } + + /** + * Same logical row as {@see magazineCuration30004} when `pubkeyHex64` is the site author (from an `a` tag address). + */ + public static function magazineCuration30004FromPubkeyHex(string $pubkeyHex64, string $dTag): string + { + $pk = strtolower(trim($pubkeyHex64)); + if (64 !== \strlen($pk) || !ctype_xdigit($pk)) { + return ''; + } + + return 'mcur:'.$pk.':'.trim($dTag, " \0\x0B\t\n\r"); + } + public static function magazineRoot(string $npub, string $rootDTag): string { $hex = self::npubToHex($npub); diff --git a/src/Service/MagazineContentService.php b/src/Service/MagazineContentService.php index 25683f7..8947cb3 100644 --- a/src/Service/MagazineContentService.php +++ b/src/Service/MagazineContentService.php @@ -8,7 +8,9 @@ use App\Dto\FeaturedArticleCard; use App\Entity\Article; use App\Entity\Event; use App\Enum\EventStatusEnum; +use App\Enum\KindsEnum; use App\Repository\ArticleRepository; +use App\Util\CurationSet30004Home; use App\Util\NostrEventTags; use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; use Symfony\Component\HttpFoundation\RequestStack; @@ -592,6 +594,51 @@ final class MagazineContentService } } + /** + * One relay-backed pass per HTTP request to pull 30004-listed **30023** coordinates into {@see Article} + * (see {@see NostrClient::persistCuration30004ReferencedItems}). + */ + private function maybeHydrateCuration30004ReferencedOncePerRequest(Event $stored): void + { + $r = $this->requestStack->getCurrentRequest(); + if ($r === null) { + return; + } + if ($r->attributes->get('_curation_30004_refs_hydrated')) { + return; + } + $r->attributes->set('_curation_30004_refs_hydrated', true); + try { + $this->nostrClient->persistCuration30004ReferencedItems($stored); + } catch (\Throwable) { + } + } + + private function ensureCuration30004FromRelays(string $npub, string $dTag): void + { + $r = $this->requestStack->getCurrentRequest(); + if ($r !== null && $r->attributes->get('_curation_30004_ensured')) { + return; + } + try { + $e = $this->nostrClient->getCurationSet30004($npub, $dTag); + if ($e !== null) { + $this->store->putCuration30004($npub, $dTag, $e); + try { + $this->nostrClient->persistCuration30004ReferencedItems($e); + } catch (\Throwable) { + } + if ($r !== null) { + $r->attributes->set('_curation_30004_refs_hydrated', true); + } + } + } catch (\Throwable) { + } + if ($r !== null) { + $r->attributes->set('_curation_30004_ensured', true); + } + } + private function ensureCategory30040FromRelays(string $slug): void { if (trim($slug) === '') { @@ -652,6 +699,81 @@ final class MagazineContentService return array_keys($out); } + /** + * Home strip from NIP-51 kind 30004 (curation set): `d_tag_curation_set` on `npub`, ordered `a` tags for + * kind **30023** only (other kinds and `e` tags are ignored). Tiles resolve from the local `article` table. + * + * @return array{heading: string, tiles: list>} + */ + public function buildHomeCurationWallData(): array + { + $d = trim((string) $this->params->get('d_tag_curation_set')); + if ($d === '' || strcasecmp($d, 'd-tag-goes-here') === 0) { + return ['heading' => '', 'tiles' => []]; + } + $npub = (string) $this->params->get('npub'); + $stored = $this->store->getCuration30004($npub, $d); + if ($stored === null) { + $this->ensureCuration30004FromRelays($npub, $d); + $stored = $this->store->getCuration30004($npub, $d); + } + if ($stored === null) { + return ['heading' => '', 'tiles' => []]; + } + $this->maybeHydrateCuration30004ReferencedOncePerRequest($stored); + /** @var list> $tagRows */ + $tagRows = $stored->getTags(); + $parsed = CurationSet30004Home::parseTitleAndOrderedRefs($tagRows); + if ($parsed['items'] === []) { + return ['heading' => '', 'tiles' => []]; + } + $pairsArg = []; + foreach ($parsed['items'] as $it) { + $pairsArg[] = ['pubkey' => $it['pk'], 'slug' => $it['slug']]; + } + $indexed = $this->articleRepository->findByAuthorAndSlugIndexed($pairsArg); + $missingPairs = []; + foreach ($pairsArg as $pair) { + $k = strtolower((string) $pair['pubkey'])."\0".trim((string) $pair['slug']); + if (!isset($indexed[$k])) { + $missingPairs[] = $pair; + } + } + if ($missingPairs !== []) { + try { + $this->nostrClient->ingestLongformForCategoryCoordinates(array_values(array_unique(array_map( + static fn (array $p): string => (string) KindsEnum::LONGFORM->value.':'.strtolower((string) $p['pubkey']).':'.trim((string) $p['slug']), + $missingPairs + )))); + } catch (\Throwable) { + } + $indexed = $this->articleRepository->findByAuthorAndSlugIndexed($pairsArg); + } + $heading = $parsed['title'] !== '' ? $parsed['title'] : 'Spotlight'; + $tiles = []; + $seenArticle = []; + foreach ($parsed['items'] as $it) { + $key = $it['pk']."\0".$it['slug']; + if (isset($seenArticle[$key])) { + continue; + } + $article = $indexed[$key] ?? null; + if ($article === null) { + continue; + } + $seenArticle[$key] = true; + $tiles[] = [ + 'article' => FeaturedArticleCard::fromArticle($article), + 'categoryTitle' => $heading, + ]; + } + if ($tiles === []) { + return ['heading' => '', 'tiles' => []]; + } + + return ['heading' => $heading, 'tiles' => $tiles]; + } + /** * Interleaves up to four articles per home category in round-robin order (one “wall” mixing all topics). * Duplicate slugs across categories are skipped so each article appears at most once. @@ -724,7 +846,7 @@ final class MagazineContentService } $slug = $parts[2]; $catIndex = $this->store->getCategory($slug); - if (!\is_object($catIndex) || !\method_exists($catIndex, 'getTags')) { + if ($catIndex === null) { return null; } diff --git a/src/Service/MagazineIndexStore.php b/src/Service/MagazineIndexStore.php index 9c548cb..1a009bf 100644 --- a/src/Service/MagazineIndexStore.php +++ b/src/Service/MagazineIndexStore.php @@ -10,8 +10,8 @@ use App\Repository\EventRepository; use Doctrine\ORM\EntityManagerInterface; /** - * Magazine Nostr index events (kind 30040) in MySQL {@see Event}. Updated by {@see MagazineRefresher} - * (`app:prewarm` / cron). + * Magazine Nostr index events (kind 30040) and the site’s NIP-51 kind 30004 curation set in MySQL {@see Event}. + * Updated by {@see MagazineRefresher} (`app:prewarm` / cron). */ final class MagazineIndexStore { @@ -44,6 +44,20 @@ final class MagazineIndexStore return $this->eventRepository->findOneByCoreRowKey($key); } + public function getCuration30004(string $npub, string $dTag): ?Event + { + $dTag = trim($dTag); + if ($dTag === '') { + return null; + } + $key = MagazineEventKeys::magazineCuration30004($npub, $dTag); + if ($key === '') { + return null; + } + + return $this->eventRepository->findOneByCoreRowKey($key); + } + public function putRoot(string $npub, string $dTag, Event $event): void { if ($dTag === '') { @@ -65,6 +79,19 @@ final class MagazineIndexStore $this->replaceByCoreKey($key, Event::STORAGE_MAGAZINE_CATEGORY, $event); } + public function putCuration30004(string $npub, string $dTag, Event $event): void + { + $dTag = trim($dTag); + if ($dTag === '') { + return; + } + $key = MagazineEventKeys::magazineCuration30004($npub, $dTag); + if ($key === '') { + return; + } + $this->replaceByCoreKey($key, Event::STORAGE_MAGAZINE_CURATION_30004, $event); + } + public function deleteCategory(string $slug): void { if ($slug === '') { diff --git a/src/Service/MagazineRefresher.php b/src/Service/MagazineRefresher.php index 25ffd6b..ef396e6 100644 --- a/src/Service/MagazineRefresher.php +++ b/src/Service/MagazineRefresher.php @@ -91,6 +91,8 @@ final class MagazineRefresher $this->store->putRoot($npub, $dTag, $root); + $this->refreshCuration30004FromRelays($npub); + $deadline = microtime(true) + $budgetSeconds; $mergedPrefer = $this->mergePreferSlugsInOrder($preferSlugs, $preferFromEnv); @@ -161,6 +163,34 @@ final class MagazineRefresher $this->touchLastRelayTime(); } + /** + * Persists NIP-51 kind 30004 (home curation strip) when `d_tag_curation_set` is configured. + */ + private function refreshCuration30004FromRelays(string $npub): void + { + $d = trim((string) $this->params->get('d_tag_curation_set')); + if ($d === '' || strcasecmp($d, 'd-tag-goes-here') === 0) { + return; + } + try { + $ev = $this->nostrClient->getCurationSet30004($npub, $d); + if ($ev !== null) { + $this->store->putCuration30004($npub, $d, $ev); + try { + $this->nostrClient->persistCuration30004ReferencedItems($ev); + } catch (\Throwable $e2) { + $this->logger->warning('MagazineRefresher: curation 30004 referenced ingest failed', [ + 'message' => $e2->getMessage(), + ]); + } + } + } catch (\Throwable $e) { + $this->logger->warning('MagazineRefresher: curation set 30004 fetch failed', [ + 'message' => $e->getMessage(), + ]); + } + } + /** * @throws InvalidArgumentException */ diff --git a/src/Service/Nip09DeletionApplier.php b/src/Service/Nip09DeletionApplier.php index 1dd0611..a0cf9e4 100644 --- a/src/Service/Nip09DeletionApplier.php +++ b/src/Service/Nip09DeletionApplier.php @@ -16,7 +16,8 @@ use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; /** * Applies NIP-09 (kind 5) deletion requests to: * - MySQL: long-form articles ({@see KindsEnum::LONGFORM} 30023, {@see KindsEnum::LONGFORM_DRAFT} 30024) - * - MySQL {@see Event} rows: kind 30040 magazine indices (root + category), kind 0 profile, 10002 relay list, 10133 payto + * - MySQL {@see Event} rows: kind 30040 magazine indices (root + category), kind 30004 home curation set, + * kind 0 profile, 10002 relay list, 10133 payto * * Handled for `e` tags (with `k` when present) and for NIP-33 `a` tags. * @@ -40,7 +41,7 @@ final class Nip09DeletionApplier /** * @param list $deletionEvents Kind-5 events from relays (e.g. {@see NostrClient::fetchKind5DeletionEventsForAuthors}) * - * @return array{articles_removed: int, magazine_roots: int, magazine_categories: int} + * @return array{articles_removed: int, magazine_roots: int, magazine_categories: int, magazine_curation_30004: int} */ public function apply(array $deletionEvents): array { @@ -48,6 +49,7 @@ final class Nip09DeletionApplier $articlesPendingFlush = 0; $roots = 0; $cats = 0; + $curation30004 = 0; $seenArticleIds = []; foreach ($deletionEvents as $ev) { @@ -78,13 +80,10 @@ final class Nip09DeletionApplier KindsEnum::METADATA->value, KindsEnum::RELAY_LIST->value, KindsEnum::PAYMENT_TARGETS->value, - 1, // NIP-09 may include kind 1; we do not store notes, but must not treat k as “unknown” + KindsEnum::CURATION_SET->value, ], true)) { continue; } - if ($declared === 1) { - continue; - } if ($this->removeArticleByEventIdIfValid($eId, $deletionPubkey, $declared, $seenArticleIds)) { ++$articlesRemoved; ++$articlesPendingFlush; @@ -97,12 +96,15 @@ final class Nip09DeletionApplier KindsEnum::LONGFORM->value, KindsEnum::LONGFORM_DRAFT->value, KindsEnum::PUBLICATION_INDEX->value, + KindsEnum::CURATION_SET->value, ], true)) { $mag = $this->tryRemoveMagazine30040ByEventId($eId, $deletionPubkey); if ($mag === 1) { ++$roots; } elseif ($mag === 2) { ++$cats; + } elseif ($this->tryRemoveStoredCuration30004ByEventId($eId, $deletionPubkey)) { + ++$curation30004; } } } @@ -113,6 +115,7 @@ final class Nip09DeletionApplier $articlesPendingFlush += $r['articles']; $roots += $r['roots']; $cats += $r['cats']; + $curation30004 += $r['curation']; } } @@ -126,6 +129,7 @@ final class Nip09DeletionApplier 'articles_removed' => $articlesRemoved, 'magazine_roots' => $roots, 'magazine_categories' => $cats, + 'magazine_curation_30004' => $curation30004, ]; } @@ -209,6 +213,30 @@ final class Nip09DeletionApplier return 0; } + private function tryRemoveStoredCuration30004ByEventId(string $eventId, string $deletionPubkey): bool + { + $eid = strtolower($eventId); + $e = $this->eventRepository->find($eid); + if ($e === null) { + return false; + } + if ((int) $e->getKind() !== KindsEnum::CURATION_SET->value) { + return false; + } + if (!$this->pubkeyEquals($e->getPubkey(), $deletionPubkey)) { + return false; + } + if ($e->getStorageRole() !== MagazineNostrEvent::STORAGE_MAGAZINE_CURATION_30004) { + return false; + } + $this->entityManager->remove($e); + $this->logger->notice('NIP-09: removed home curation 30004 row (event table)', [ + 'event_id' => $eid, + ]); + + return true; + } + private function pubkeyEquals(string $a, string $b): bool { if (64 !== \strlen($a) || 64 !== \strlen($b)) { @@ -263,11 +291,11 @@ final class Nip09DeletionApplier * * @param array $seenArticleIds * - * @return array{articles: int, roots: int, cats: int} + * @return array{articles: int, roots: int, cats: int, curation: int} */ private function removeByNip33Address(string $addr, string $deletionPubkey, array &$seenArticleIds): array { - $out = ['articles' => 0, 'roots' => 0, 'cats' => 0]; + $out = ['articles' => 0, 'roots' => 0, 'cats' => 0, 'curation' => 0]; $parts = explode(':', $addr, 3); if (\count($parts) < 3) { return $out; @@ -378,6 +406,27 @@ final class Nip09DeletionApplier } } + if ($kind === KindsEnum::CURATION_SET->value) { + if ($d === '') { + return $out; + } + $key = MagazineEventKeys::magazineCuration30004FromPubkeyHex($pk, $d); + if ($key === '') { + return $out; + } + $row = $this->eventRepository->findOneByCoreRowKey($key); + if ($row !== null + && (int) $row->getKind() === KindsEnum::CURATION_SET->value + && $row->getStorageRole() === MagazineNostrEvent::STORAGE_MAGAZINE_CURATION_30004 + && $this->pubkeyEquals($row->getPubkey(), $deletionPubkey)) { + $this->entityManager->remove($row); + ++$out['curation']; + $this->logger->notice('NIP-09: removed home curation 30004 (a tag)', ['address' => $addr]); + } + + return $out; + } + return $out; } diff --git a/src/Service/NostrClient.php b/src/Service/NostrClient.php index 710c0a0..5565b81 100644 --- a/src/Service/NostrClient.php +++ b/src/Service/NostrClient.php @@ -5,6 +5,7 @@ namespace App\Service; use App\Entity\Article; use App\Entity\Event as PublicationEventEntity; use App\Enum\KindsEnum; +use App\Util\CurationSet30004Home; use App\Factory\ArticleFactory; use Doctrine\ORM\EntityManagerInterface; use Doctrine\Persistence\ManagerRegistry; @@ -605,6 +606,85 @@ class NostrClient return $events[0]; } + /** + * Batch-fetch kind-1 events by id (e.g. discussion / tooling). Merges article relay set first, then + * profile-only relays for any still missing ids. + * + * @param list $eventIdHexes + * + * @return array lowercase event id hex → wire event (kind 1) + */ + public function getKind1EventsByIdsIndexed(array $eventIdHexes): array + { + $want = []; + foreach ($eventIdHexes as $raw) { + $id = strtolower(trim((string) $raw)); + if (64 === \strlen($id) && ctype_xdigit($id)) { + $want[$id] = true; + } + } + $idList = array_keys($want); + if ($idList === []) { + return []; + } + $idList = \array_slice($idList, 0, 100); + + $articleSet = $this->relayListFactory->createRelaySetMergedWithArticleList([]); + $byId = $this->queryKind1EventsByIdsFromRelaySet($idList, $articleSet); + $missing = array_values(array_diff($idList, array_keys($byId))); + if ($missing !== []) { + $profileExtra = $this->relayListFactory->getProfileRelayUrlsExcludedFromArticleRelays(); + if ($profileExtra !== []) { + $pfSet = $this->relayListFactory->createRelaySetFromUrlsOnly($profileExtra); + $extra = $this->queryKind1EventsByIdsFromRelaySet($missing, $pfSet); + foreach ($extra as $id => $ev) { + if (!isset($byId[$id])) { + $byId[$id] = $ev; + } + } + } + } + + return $byId; + } + + /** + * @param list $eventIdHexes + * + * @return array + */ + private function queryKind1EventsByIdsFromRelaySet(array $eventIdHexes, RelaySet $relaySet): array + { + if ($eventIdHexes === []) { + return []; + } + $request = $this->nostrRelayQuery->createNostrRequest( + defaultRelaySet: $this->defaultRelaySet, + relaySet: $relaySet, + kinds: [KindsEnum::TEXT_NOTE], + filters: ['ids' => $eventIdHexes], + ); + $events = $this->nostrRelayQuery->processResponse($request->send(), static fn (object $event) => $event); + $out = []; + foreach ($events as $e) { + if (!\is_object($e)) { + continue; + } + $id = strtolower((string) ($e->id ?? '')); + if (64 !== \strlen($id) || !ctype_xdigit($id)) { + continue; + } + if ((int) ($e->kind ?? 0) !== KindsEnum::TEXT_NOTE->value) { + continue; + } + if (!isset($out[$id])) { + $out[$id] = $e; + } + } + + return $out; + } + /** * Fetch event by naddr * @@ -1553,6 +1633,83 @@ class NostrClient return $this->queryMagazineIndex($npub, $dTag, $pfSet, $relaysForLog2); } + /** + * Latest NIP-51 kind 30004 curation set for this author and #d (parameterized replaceable). + * + * Relay strategy matches {@see getMagazineIndex}: article relays first, then profile relays only + * if nothing matched. + */ + public function getCurationSet30004(mixed $npub, string $dTag): ?PublicationEventEntity + { + $dTag = trim($dTag); + if ($dTag === '') { + return null; + } + $urls = $this->relayListFactory->getConfiguredArticleRelayUrlList(); + $relaysForLog = implode(', ', array_map(NostrRelayQuery::relayLogLabel(...), $urls)); + $result = $this->queryCurationSet30004($npub, $dTag, $this->defaultRelaySet, $relaysForLog); + if ($result !== null) { + return $result; + } + $profileExtra = $this->relayListFactory->getProfileRelayUrlsExcludedFromArticleRelays(); + if ($profileExtra === []) { + return null; + } + $pfSet = $this->relayListFactory->createRelaySetFromUrlsOnly($profileExtra); + $relaysForLog2 = implode(', ', array_map(NostrRelayQuery::relayLogLabel(...), $profileExtra)).' (profile_relays)'; + + return $this->queryCurationSet30004($npub, $dTag, $pfSet, $relaysForLog2); + } + + private function queryCurationSet30004(mixed $npub, string $dTag, RelaySet $relaySet, string $relaysForLog): ?PublicationEventEntity + { + $authorHex = $this->wireMerge->npubToHexPubkey($npub); + if ($authorHex === null) { + $this->logger->warning('Curation set 30004: could not resolve npub to hex pubkey', [ + 'npub' => $npub, + 'dTag' => $dTag, + ]); + + return null; + } + $request = $this->nostrRelayQuery->createNostrRequest( + defaultRelaySet: $this->defaultRelaySet, + relaySet: $relaySet, + kinds: [KindsEnum::CURATION_SET], + filters: ['authors' => [(string) $npub], 'tag' => ['#d', [$dTag]]], + ); + $this->logger->info(sprintf('Curation set 30004 query (relays: %s)', $relaysForLog), [ + 'npub' => $npub, + 'dTag' => $dTag, + 'relays' => $relaysForLog, + ]); + $response = $request->send(); + $events = $this->nostrRelayQuery->processResponse($response, function ($received) { + return $received; + }); + if ($events === []) { + return null; + } + $raw = $this->wireMerge->pickLatestNip33ParameterizedForQuery( + $events, + KindsEnum::CURATION_SET->value, + $authorHex, + $dTag + ); + if ($raw === null) { + $this->logger->warning('Curation set 30004: no event matched NIP-33 address (kind:pubkey:d) after merge', [ + 'npub' => $npub, + 'dTag' => $dTag, + 'relays' => $relaysForLog, + 'event_count' => \count($events), + ]); + + return null; + } + + return $this->wireMerge->magazineEventToPublicationEntity($raw); + } + private function queryMagazineIndex(mixed $npub, mixed $dTag, RelaySet $relaySet, string $relaysForLog): ?PublicationEventEntity { $authorHex = $this->wireMerge->npubToHexPubkey($npub); @@ -1934,4 +2091,35 @@ class NostrClient } $this->logger->info('[longform_ingest] ingestLongform: done (all groups)'); } + + /** + * After persisting NIP-51 kind 30004, ingest each listed **30023** `a` coordinate into {@see Article}. + * Non-30023 `a` tags and `e` tags are ignored at parse time ({@see CurationSet30004Home}). + */ + public function persistCuration30004ReferencedItems(PublicationEventEntity $curation30004): void + { + try { + $parsed = CurationSet30004Home::parseTitleAndOrderedRefs($curation30004->getTags()); + } catch (\Throwable $e) { + $this->logger->warning('[curation_30004] parse refs failed', ['message' => $e->getMessage()]); + + return; + } + $addresses = []; + foreach ($parsed['items'] as $it) { + $addresses[] = (string) KindsEnum::LONGFORM->value.':'.strtolower((string) $it['pk']).':'.trim((string) $it['slug']); + } + $addresses = array_values(array_unique($addresses)); + if ($addresses === []) { + return; + } + try { + $this->ingestLongformForCategoryCoordinates($addresses); + } catch (\Throwable $e) { + $this->logger->warning('[curation_30004] longform ingest for curated list failed', [ + 'message' => $e->getMessage(), + 'address_count' => \count($addresses), + ]); + } + } } diff --git a/src/Service/NostrKind5DeletionFilter.php b/src/Service/NostrKind5DeletionFilter.php index a53db55..3f719b4 100644 --- a/src/Service/NostrKind5DeletionFilter.php +++ b/src/Service/NostrKind5DeletionFilter.php @@ -7,8 +7,8 @@ namespace App\Service; use App\Enum\KindsEnum; /** - * NIP-09 kind-5: keep only deletion events that target kinds persisted in MySQL (profile, relay list, payto, - * long-form, magazine). Skips thread/reply deletions to reduce relay payload. + * NIP-09 kind-5: keep deletion events that may affect MySQL-backed rows (profile, relay list, payto, + * long-form, magazine 30040, home curation 30004). Skips thread/reply deletions to reduce relay payload. */ final class NostrKind5DeletionFilter { @@ -28,7 +28,8 @@ final class NostrKind5DeletionFilter } if ((string) $r[0] === 'a') { $parts = explode(':', (string) $r[1], 3); - if (\in_array((int) $parts[0], $kinds, true)) { + $kindNum = (int) $parts[0]; + if (\in_array($kindNum, $kinds, true)) { return true; } } @@ -49,6 +50,7 @@ final class NostrKind5DeletionFilter KindsEnum::LONGFORM->value, KindsEnum::LONGFORM_DRAFT->value, KindsEnum::PUBLICATION_INDEX->value, + KindsEnum::CURATION_SET->value, ]; } } diff --git a/src/Util/CurationSet30004Home.php b/src/Util/CurationSet30004Home.php new file mode 100644 index 0000000..06d05cc --- /dev/null +++ b/src/Util/CurationSet30004Home.php @@ -0,0 +1,58 @@ + $tags Event tag rows (Nostr JSON shape) + * + * @return array{title: string, items: list} + */ + public static function parseTitleAndOrderedRefs(iterable $tags): array + { + $title = ''; + $items = []; + foreach ($tags as $tag) { + if (!\is_array($tag) || $tag === []) { + continue; + } + $name = isset($tag[0]) ? strtolower((string) $tag[0]) : ''; + $v = isset($tag[1]) ? (string) $tag[1] : ''; + if ($v === '') { + continue; + } + if ($name === 'title' && $title === '') { + $title = trim($v); + continue; + } + if ($name !== 'a') { + continue; + } + $parts = explode(':', $v, 3); + if (\count($parts) < 3) { + continue; + } + $kind = (int) $parts[0]; + if ($kind !== KindsEnum::LONGFORM->value) { + continue; + } + $pk = strtolower(trim($parts[1])); + $slug = trim($parts[2]); + if (64 !== \strlen($pk) || !ctype_xdigit($pk) || $slug === '') { + continue; + } + $items[] = ['type' => 'article', 'pk' => $pk, 'slug' => $slug]; + } + + return ['title' => $title, 'items' => $items]; + } +} diff --git a/templates/components/Organisms/FeaturedWall.html.twig b/templates/components/Organisms/FeaturedWall.html.twig index f27580d..a542cc4 100644 --- a/templates/components/Organisms/FeaturedWall.html.twig +++ b/templates/components/Organisms/FeaturedWall.html.twig @@ -3,13 +3,13 @@ #} {% if tiles is not empty %} {% endblock %} diff --git a/tests/Service/NostrKind5DeletionFilterTest.php b/tests/Service/NostrKind5DeletionFilterTest.php index 5a58ba3..9f5fa93 100644 --- a/tests/Service/NostrKind5DeletionFilterTest.php +++ b/tests/Service/NostrKind5DeletionFilterTest.php @@ -40,4 +40,15 @@ final class NostrKind5DeletionFilterTest extends TestCase ]; $this->assertTrue($f->isRelevantToStoredDbData($ev)); } + + public function testAddressTagWithCurationSetKindIsRelevant(): void + { + $f = new NostrKind5DeletionFilter(); + $pk = str_repeat('c', 64); + $ev = (object) [ + 'kind' => 5, + 'tags' => [['a', KindsEnum::CURATION_SET->value.':'.$pk.':home']], + ]; + $this->assertTrue($f->isRelevantToStoredDbData($ev)); + } } diff --git a/tests/Util/CurationSet30004HomeTest.php b/tests/Util/CurationSet30004HomeTest.php new file mode 100644 index 0000000..8996716 --- /dev/null +++ b/tests/Util/CurationSet30004HomeTest.php @@ -0,0 +1,44 @@ + 'article', 'pk' => '26dc95542e18b8b7aec2f14610f55c335abebec76f3db9e58c254661d0593a0c', 'slug' => 'slug-one'], + ['type' => 'article', 'pk' => '26dc95542e18b8b7aec2f14610f55c335abebec76f3db9e58c254661d0593a0c', 'slug' => 'slug-two'], + ], $out['items']); + } + + public function testSkipsNon30023ATags(): void + { + $tags = [ + ['a', '30040:26dc95542e18b8b7aec2f14610f55c335abebec76f3db9e58c254661d0593a0c:mag'], + ['a', '30023:26dc95542e18b8b7aec2f14610f55c335abebec76f3db9e58c254661d0593a0c:ok'], + ]; + $out = CurationSet30004Home::parseTitleAndOrderedRefs($tags); + self::assertSame('', $out['title']); + self::assertCount(1, $out['items']); + self::assertSame('article', $out['items'][0]['type']); + self::assertSame('ok', $out['items'][0]['slug']); + } +}