Browse Source

reveal curation set at the top of landing page

imwald
Silberengel 3 days ago
parent
commit
0e57e157db
  1. 6
      .dockerignore
  2. 7
      assets/controllers/article_comments_controller.js
  3. 5
      assets/controllers/login_controller.js
  4. 26
      assets/controllers/progress_bar_controller.js
  5. 6
      config/unfold.yaml
  6. 29
      migrations/Version20260428103000.php
  7. 6
      phpstan-baseline.neon
  8. 9
      src/Command/PrewarmCommand.php
  9. 3
      src/Controller/DefaultController.php
  10. 24
      src/Dto/FeaturedArticleCard.php
  11. 2
      src/Entity/Event.php
  12. 25
      src/Nostr/MagazineEventKeys.php
  13. 124
      src/Service/MagazineContentService.php
  14. 31
      src/Service/MagazineIndexStore.php
  15. 30
      src/Service/MagazineRefresher.php
  16. 65
      src/Service/Nip09DeletionApplier.php
  17. 188
      src/Service/NostrClient.php
  18. 8
      src/Service/NostrKind5DeletionFilter.php
  19. 58
      src/Util/CurationSet30004Home.php
  20. 6
      templates/components/Organisms/FeaturedWall.html.twig
  21. 10
      templates/home.html.twig
  22. 11
      tests/Service/NostrKind5DeletionFilterTest.php
  23. 44
      tests/Util/CurationSet30004HomeTest.php

6
.dockerignore

@ -2,8 +2,10 @@ @@ -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

7
assets/controllers/article_comments_controller.js

@ -13,7 +13,10 @@ export default class extends Controller { @@ -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,8 +31,10 @@ export default class extends Controller { @@ -28,8 +31,10 @@ export default class extends Controller {
}
disconnect() {
if (this.boundOnAuth) {
window.removeEventListener('unfold:auth-changed', this.boundOnAuth);
}
}
onAuthChanged() {
if (!this.hasContainerTarget || !this.urlValue) {

5
assets/controllers/login_controller.js

@ -29,8 +29,9 @@ export default class extends Controller { @@ -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 } })
);

26
assets/controllers/progress_bar_controller.js

@ -11,17 +11,27 @@ export default class extends Controller { @@ -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 { @@ -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');

6
config/unfold.yaml

@ -44,7 +44,11 @@ parameters: @@ -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'

29
migrations/Version20260428103000.php

@ -0,0 +1,29 @@ @@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Remove deprecated home-curation kind-1 cache rows (no longer written or read).
*/
final class Version20260428103000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Delete event rows with storage_role magazine_curation_kind1_ref (curation strip is 30023-only)';
}
public function up(Schema $schema): void
{
$this->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.
}
}

6
phpstan-baseline.neon

@ -432,12 +432,6 @@ parameters: @@ -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\<array\{entries\: list\<array\{coordinate\: string, status\: string, reason\: string\}\>\}\>\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset

9
src/Command/PrewarmCommand.php

@ -64,7 +64,7 @@ final class PrewarmCommand extends Command @@ -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 @@ -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 @@ -284,11 +284,12 @@ final class PrewarmCommand extends Command
try {
$st = $this->nip09DeletionApplier->apply($kind5);
$io->writeln(sprintf(
'Kind 5 events: <info>%d</info> (deduped). NIP-23 long-form in DB (30023/30024) removed: <info>%d</info>. Magazine index in cache (30040) removed: root <info>%d</info>, category <info>%d</info>.',
'Kind 5 events: <info>%d</info> (deduped). NIP-23 long-form in DB (30023/30024) removed: <info>%d</info>. Magazine index in cache (30040) removed: root <info>%d</info>, category <info>%d</info>. Home curation (30004) rows removed: <info>%d</info>.',
\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]);

3
src/Controller/DefaultController.php

@ -25,8 +25,11 @@ class DefaultController extends AbstractController @@ -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),
]);

24
src/Dto/FeaturedArticleCard.php

@ -4,6 +4,8 @@ declare(strict_types=1); @@ -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 @@ -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;

2
src/Entity/Event.php

@ -17,6 +17,8 @@ class Event @@ -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';

25
src/Nostr/MagazineEventKeys.php

@ -7,10 +7,33 @@ namespace App\Nostr; @@ -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);

124
src/Service/MagazineContentService.php

@ -8,7 +8,9 @@ use App\Dto\FeaturedArticleCard; @@ -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 @@ -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 @@ -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<array<string, mixed>>}
*/
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<array<int, mixed>> $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 @@ -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;
}

31
src/Service/MagazineIndexStore.php

@ -10,8 +10,8 @@ use App\Repository\EventRepository; @@ -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 @@ -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 @@ -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 === '') {

30
src/Service/MagazineRefresher.php

@ -91,6 +91,8 @@ final class MagazineRefresher @@ -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 @@ -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
*/

65
src/Service/Nip09DeletionApplier.php

@ -16,7 +16,8 @@ use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; @@ -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 @@ -40,7 +41,7 @@ final class Nip09DeletionApplier
/**
* @param list<object> $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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -263,11 +291,11 @@ final class Nip09DeletionApplier
*
* @param array<string, true> $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 @@ -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;
}

188
src/Service/NostrClient.php

@ -5,6 +5,7 @@ namespace App\Service; @@ -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 @@ -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<string> $eventIdHexes
*
* @return array<string, object> 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<string> $eventIdHexes
*
* @return array<string, object>
*/
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 @@ -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 @@ -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),
]);
}
}
}

8
src/Service/NostrKind5DeletionFilter.php

@ -7,8 +7,8 @@ namespace App\Service; @@ -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 @@ -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 @@ -49,6 +50,7 @@ final class NostrKind5DeletionFilter
KindsEnum::LONGFORM->value,
KindsEnum::LONGFORM_DRAFT->value,
KindsEnum::PUBLICATION_INDEX->value,
KindsEnum::CURATION_SET->value,
];
}
}

58
src/Util/CurationSet30004Home.php

@ -0,0 +1,58 @@ @@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Util;
use App\Enum\KindsEnum;
/**
* NIP-51 kind 30004 (curation set) for the home strip: ordered `a` tags for kind **30023** only.
* Other address kinds and `e` tags are ignored.
*/
final class CurationSet30004Home
{
/**
* @param iterable<int, mixed> $tags Event tag rows (Nostr JSON shape)
*
* @return array{title: string, items: list<array{type: 'article', pk: string, slug: string}>}
*/
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];
}
}

6
templates/components/Organisms/FeaturedWall.html.twig

@ -3,13 +3,13 @@ @@ -3,13 +3,13 @@
#}
{% if tiles is not empty %}
<div
class="featured-list featured-list--wall"
class="featured-list featured-list--wall{{ wall_extra_class|default('') != '' ? ' ' ~ wall_extra_class : '' }}"
role="region"
aria-label="{{ (website_name ~ ' — featured articles')|e('html_attr') }}"
aria-label="{{ (region_aria_label|default(website_name ~ ' — featured articles'))|e('html_attr') }}"
>
{% for tile in tiles %}
{% set item = tile.article %}
{% set _hue = (tile.categoryTitle|default('x')|length * 47) % 360 %}
{% set item = tile.article %}
<article class="featured-tile" style="--tile-hue: {{ _hue }};">
<a
class="featured-tile__link"

10
templates/home.html.twig

@ -27,6 +27,16 @@ @@ -27,6 +27,16 @@
{% block body %}
<div class="home-body home-body--wall">
{% if home_curation_tiles|default([]) is not empty %}
{% if home_curation_heading|default('') != '' %}
<h2 class="home-curation-heading h5 mb-3 text-body-secondary">{{ home_curation_heading|e }}</h2>
{% endif %}
{% include 'components/Organisms/FeaturedWall.html.twig' with {
tiles: home_curation_tiles,
region_aria_label: home_curation_heading|default('') != '' ? (home_curation_heading ~ ' — curated articles') : (website_name ~ ' — curated articles'),
wall_extra_class: 'featured-list--curation',
} only %}
{% endif %}
{% include 'components/Organisms/FeaturedWall.html.twig' with { tiles: home_featured_tiles|default([]) } only %}
</div>
{% endblock %}

11
tests/Service/NostrKind5DeletionFilterTest.php

@ -40,4 +40,15 @@ final class NostrKind5DeletionFilterTest extends TestCase @@ -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));
}
}

44
tests/Util/CurationSet30004HomeTest.php

@ -0,0 +1,44 @@ @@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Tests\Util;
use App\Util\CurationSet30004Home;
use PHPUnit\Framework\TestCase;
final class CurationSet30004HomeTest extends TestCase
{
public function testParsesTitleAndOrdered30023RefsOnly(): void
{
$eid = 'd78ba0d5dce22bfff9db0a9e996c9ef27e2c91051de0c4e1da340e0326b4941e';
$tags = [
['d', 'jvdy9i4'],
['title', 'Yaks'],
['image', 'https://example.com/y.jpg'],
['a', '30023:26dc95542e18b8b7aec2f14610f55c335abebec76f3db9e58c254661d0593a0c:slug-one'],
['e', $eid],
['a', '30024:26dc95542e18b8b7aec2f14610f55c335abebec76f3db9e58c254661d0593a0c:draft-only'],
['a', '30023:26dc95542e18b8b7aec2f14610f55c335abebec76f3db9e58c254661d0593a0c:slug-two'],
];
$out = CurationSet30004Home::parseTitleAndOrderedRefs($tags);
self::assertSame('Yaks', $out['title']);
self::assertSame([
['type' => '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']);
}
}
Loading…
Cancel
Save