Compare commits

..

5 Commits

  1. 14
      assets/controllers/comment_reply_controller.js
  2. 69
      assets/styles/app.css
  3. 1
      config/services.yaml
  4. 10
      deploy/well-known-nip05-nginx.conf
  5. 2
      frankenphp/Caddyfile
  6. 14
      importmap.php
  7. 18
      public/.htaccess
  8. 31
      src/Command/PrewarmCommand.php
  9. 5
      src/Controller/AuthorController.php
  10. 10
      src/Controller/FeaturedAuthorsController.php
  11. 57
      src/Dto/FeaturedArticleCard.php
  12. 42
      src/Repository/ArticleRepository.php
  13. 169
      src/Service/ArticleCommentThreadLoader.php
  14. 14
      src/Service/MagazineContentService.php
  15. 7
      src/Service/MagazineRefresher.php
  16. 6
      src/Service/Nip05VerificationService.php
  17. 50
      src/Service/NostrClient.php
  18. 19
      src/Twig/Components/Organisms/FeaturedList.php
  19. 28
      templates/components/Organisms/Comments.html.twig
  20. 49
      templates/partial/author_profile_header.html.twig

14
assets/controllers/comment_reply_controller.js

@ -68,8 +68,9 @@ export default class extends Controller {
return; return;
} }
this.setHint('Preparing event…'); this.setHint('Preparing event…');
const { nip19 } = await import('nostr-tools'); // `nostr-tools` entry pulls @noble/curves (bare spec → breaks in AssetMapper). NIP-19 only needs bech32 helpers.
const link = this.buildParentBech32(nip19); const { naddrEncode, neventEncode } = await import('nostr-tools/nip19');
const link = this.buildParentBech32(naddrEncode, neventEncode);
const blurb = `> Replying to **${this.blurbLabelValue}** — [view parent](nostr:${link})\n\n`; const blurb = `> Replying to **${this.blurbLabelValue}** — [view parent](nostr:${link})\n\n`;
const unsigned = { const unsigned = {
kind: 1111, kind: 1111,
@ -135,18 +136,19 @@ export default class extends Controller {
} }
/** /**
* @param {import('nostr-tools').nip19} nip19 * @param {function(object): string} naddrEncode
* @param {function(object): string} neventEncode
*/ */
buildParentBech32(nip19) { buildParentBech32(naddrEncode, neventEncode) {
const allZero = /^0{64}$/.test(this.parentIdValue); const allZero = /^0{64}$/.test(this.parentIdValue);
const parts = (this.expectedCoordinateValue || '').split(':'); const parts = (this.expectedCoordinateValue || '').split(':');
const k = parts[0] ? parseInt(parts[0], 10) : 30023; const k = parts[0] ? parseInt(parts[0], 10) : 30023;
const pub = parts[1] || this.authorPubkeyValue; const pub = parts[1] || this.authorPubkeyValue;
const d = parts[2] || ''; const d = parts[2] || '';
if (allZero && d !== '') { if (allZero && d !== '') {
return nip19.naddrEncode({ kind: k, pubkey: pub, identifier: d, relays: [] }); return naddrEncode({ kind: k, pubkey: pub, identifier: d, relays: [] });
} }
return nip19.neventEncode({ return neventEncode({
id: this.parentIdValue, id: this.parentIdValue,
kind: this.parentKindValue, kind: this.parentKindValue,
pubkey: this.authorPubkeyValue, pubkey: this.authorPubkeyValue,

69
assets/styles/app.css

@ -543,6 +543,48 @@ footer a {
text-align: left; text-align: left;
} }
/* One main label (NIP-05, Payment) with values stacked in the next column */
.author-profile__section--label-value {
display: grid;
grid-template-columns: 7.5rem minmax(0, 1fr);
column-gap: 0.5rem;
align-items: start;
margin-top: 0.5rem;
}
.author-profile__section-type {
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text);
opacity: 0.75;
line-height: 1.35;
padding-top: 0.15em;
}
.author-profile__value-stack {
list-style: none;
margin: 0;
padding: 0;
min-width: 0;
}
.author-profile__value-stack-line {
margin: 0.35rem 0 0;
}
.author-profile__value-stack-line:first-child {
margin-top: 0;
}
.author-profile__payments-nested {
list-style: none;
margin: 0;
padding: 0;
min-width: 0;
}
.author-profile__identity { .author-profile__identity {
list-style: none; list-style: none;
margin: 0.5rem 0 0; margin: 0.5rem 0 0;
@ -579,17 +621,26 @@ footer a {
white-space: nowrap; white-space: nowrap;
} }
/* NIP-05: ellipsis long addresses; keep ✓ immediately after the (truncated) text, not at the column edge. */
.author-profile__nip05-value { .author-profile__nip05-value {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.3rem; justify-content: flex-start;
flex-wrap: nowrap;
gap: 0.25rem;
min-width: 0; min-width: 0;
overflow: hidden; max-width: 100%;
width: 100%;
} }
.author-profile__nip05-value .author-profile__identity-link { .author-profile__nip05-link {
flex: 1 1 0; flex: 0 1 auto;
min-width: 0; min-width: 0;
/* reserve space for the checkmark on the same “line” of layout */
max-width: calc(100% - 1.4rem);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
word-break: normal; word-break: normal;
} }
@ -600,14 +651,8 @@ footer a {
line-height: 1; line-height: 1;
opacity: 0.85; opacity: 0.85;
user-select: none; user-select: none;
} flex-shrink: 0;
flex-grow: 0;
.author-profile__payments {
list-style: none;
margin: 0.5rem 0 0;
padding: 0;
max-width: 100%;
text-align: left;
} }
.author-profile__payment-type { .author-profile__payment-type {

1
config/services.yaml

@ -23,6 +23,7 @@ services:
resource: '../src/' resource: '../src/'
exclude: exclude:
- '../src/DependencyInjection/' - '../src/DependencyInjection/'
- '../src/Dto/'
- '../src/Entity/' - '../src/Entity/'
- '../src/Kernel.php' - '../src/Kernel.php'

10
deploy/well-known-nip05-nginx.conf

@ -0,0 +1,10 @@
# NIP-05: browsers and verifiers request
# GET https://<site>/.well-known/nostr.json?name=<local-part>
# Symfony route: SeoController::nostrWellKnown (name: nostr_well_known)
#
# Include a location for this *before* any broad rule that blocks or mishandles
# `/.well-known/` (e.g. `location ^~ /.well-known/ { return 404; }`).
location = /.well-known/nostr.json {
try_files $uri /index.php$is_args$args;
}

2
frankenphp/Caddyfile

@ -29,6 +29,8 @@
# Disable Topics tracking if not enabled explicitly: https://github.com/jkarlin/topics # Disable Topics tracking if not enabled explicitly: https://github.com/jkarlin/topics
header ?Permissions-Policy "browsing-topics=()" header ?Permissions-Policy "browsing-topics=()"
# NIP-05: Apache deployments need public/.well-known/ handled via public/.htaccess; nginx: deploy/well-known-nip05-nginx.conf
@phpRoute { @phpRoute {
not path /.well-known/mercure* not path /.well-known/mercure*
not file {path} not file {path}

14
importmap.php

@ -52,6 +52,20 @@ return [
'nostr-tools' => [ 'nostr-tools' => [
'version' => '2.10.4', 'version' => '2.10.4',
], ],
// Subpath: bech32 only (no @noble/curves) — used by comment_reply_controller for NIP-19 in blurb lines.
'nostr-tools/nip19' => [
'version' => '2.10.4',
],
'@noble/hashes' => [
'version' => '1.3.1',
],
// Required by nostr-tools/nip19 (bytesToHex / hexToBytes / concatBytes); bare @noble/hashes is not enough.
'@noble/hashes/utils' => [
'version' => '1.3.1',
],
'@scure/base' => [
'version' => '1.1.1',
],
'quill/dist/quill.core.css' => [ 'quill/dist/quill.core.css' => [
'version' => '2.0.3', 'version' => '2.0.3',
'type' => 'css', 'type' => 'css',

18
public/.htaccess

@ -0,0 +1,18 @@
# See https://symfony.com/doc/current/setup/web_server_configuration.html
# Required so routes like `/.well-known/nostr.json` (NIP-05) are handled by index.php.
DirectoryIndex index.php
<IfModule mod_negotiation.c>
Options -MultiViews
</IfModule>
<IfModule mod_rewrite.c>
RewriteEngine On
# Authorization header (e.g. API)
RewriteCond %{HTTP:Authorization} .+
RewriteRule ^ - [E=HTTP_AUTHORIZATION:%0]
# Serve existing files directly; all other requests → front controller
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.php [QSA,L]
</IfModule>

31
src/Command/PrewarmCommand.php

@ -111,6 +111,12 @@ final class PrewarmCommand extends Command
} }
$io->writeln(sprintf(' · <info>%s</info>', $s)); $io->writeln(sprintf(' · <info>%s</info>', $s));
} }
$io->writeln(sprintf(
' <comment>Progress bar: <info>%d</info> steps = <info>1</info> (root) + <info>%d</info> (categor%s).</comment>',
1 + $n,
$n,
$n === 1 ? 'y' : 'ies'
));
} }
$bar = $this->createPrewarmProgressBar( $bar = $this->createPrewarmProgressBar(
$io, $io,
@ -128,17 +134,28 @@ final class PrewarmCommand extends Command
} }
$bar->setMessage($tSlug !== '' ? 'Category: '.$tSlug : 'Category'); $bar->setMessage($tSlug !== '' ? 'Category: '.$tSlug : 'Category');
if ($tSlug !== '') { if ($tSlug !== '') {
$step = (int) ($p['step'] ?? 0); $ci = (int) ($p['category_index'] ?? 0);
$tot = (int) ($p['total_steps'] ?? 0); $ct = (int) ($p['category_total'] ?? 0);
if ($tot > 0) { if ($ci > 0 && $ct > 0) {
$io->writeln(sprintf( $io->writeln(sprintf(
' <info>[%d/%d]</info> <comment>Fetched category index</comment><info>%s</info>', ' <info>[category %d/%d]</info> <comment>Fetched category index</comment><info>%s</info>',
$step, $ci,
$tot, $ct,
$tSlug $tSlug
)); ));
} else { } else {
$io->writeln(sprintf(' <comment>Fetched category index</comment><info>%s</info>', $tSlug)); $st = (int) ($p['step'] ?? 0);
$tot = (int) ($p['total_steps'] ?? 0);
if ($tot > 0) {
$io->writeln(sprintf(
' <info>[%d/%d]</info> <comment>Fetched category index</comment><info>%s</info>',
$st,
$tot,
$tSlug
));
} else {
$io->writeln(sprintf(' <comment>Fetched category index</comment><info>%s</info>', $tSlug));
}
} }
} }
} }

5
src/Controller/AuthorController.php

@ -33,6 +33,11 @@ class AuthorController extends AbstractController
ProfilePaymentLinksBuilder $profilePaymentLinks, ProfilePaymentLinksBuilder $profilePaymentLinks,
ProfileIdentityLinksBuilder $profileIdentityLinks, ProfileIdentityLinksBuilder $profileIdentityLinks,
): Response { ): Response {
// Profile pages chain several sequential Nostr REQ runs; match article pages so a slow relay
// set does not hit PHP’s default 30s max_execution_time during Twig render.
@set_time_limit(300);
@ini_set('max_execution_time', '300');
$keys = new Key(); $keys = new Key();
$pubkey = $keys->convertToHex($npub); $pubkey = $keys->convertToHex($npub);

10
src/Controller/FeaturedAuthorsController.php

@ -6,6 +6,7 @@ namespace App\Controller;
use App\Repository\FeaturedAuthorRepository; use App\Repository\FeaturedAuthorRepository;
use App\Service\CacheService; use App\Service\CacheService;
use App\Service\NostrClient;
use App\Service\ProfileIdentityLinksBuilder; use App\Service\ProfileIdentityLinksBuilder;
use App\Service\ProfilePaymentLinksBuilder; use App\Service\ProfilePaymentLinksBuilder;
use swentel\nostr\Key\Key; use swentel\nostr\Key\Key;
@ -23,6 +24,7 @@ final class FeaturedAuthorsController extends AbstractController
public function index( public function index(
FeaturedAuthorRepository $featuredAuthorRepository, FeaturedAuthorRepository $featuredAuthorRepository,
CacheService $cacheService, CacheService $cacheService,
NostrClient $nostrClient,
ProfileIdentityLinksBuilder $profileIdentityLinks, ProfileIdentityLinksBuilder $profileIdentityLinks,
ProfilePaymentLinksBuilder $profilePaymentLinks, ProfilePaymentLinksBuilder $profilePaymentLinks,
ParameterBagInterface $params, ParameterBagInterface $params,
@ -37,11 +39,17 @@ final class FeaturedAuthorsController extends AbstractController
$author = $bundle['content']; $author = $bundle['content'];
$kind0Tags = $bundle['kind0_tags']; $kind0Tags = $bundle['kind0_tags'];
$jumbleProfileHref = $jumbleBase !== '' ? $jumbleBase.'/'.$npub : null; $jumbleProfileHref = $jumbleBase !== '' ? $jumbleBase.'/'.$npub : null;
$kind10133 = [];
try {
$kind10133 = $nostrClient->getKind10133PaymentTargetEventsForNpub($npub, 20);
} catch (\Throwable) {
}
$extraPayto = $profilePaymentLinks->collectPaytoUrisFromNipA3Kind10133Events($kind10133);
$authors[] = [ $authors[] = [
'author' => $author, 'author' => $author,
'npub' => $npub, 'npub' => $npub,
'profile_websites' => $profileIdentityLinks->buildWebsites($author, $kind0Tags), 'profile_websites' => $profileIdentityLinks->buildWebsites($author, $kind0Tags),
'profile_payment_links' => $profilePaymentLinks->buildPaymentRows($author, $kind0Tags, []), 'profile_payment_links' => $profilePaymentLinks->buildPaymentRows($author, $kind0Tags, $extraPayto),
'jumble_profile_href' => $jumbleProfileHref, 'jumble_profile_href' => $jumbleProfileHref,
]; ];
} }

57
src/Dto/FeaturedArticleCard.php

@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Dto;
/**
* Minimal article row for home/category list cards (avoids loading long-form `content` from the DB).
*/
final readonly class FeaturedArticleCard
{
public function __construct(
private ?int $id,
private ?string $slug,
private ?string $title,
private ?string $summary,
private ?string $image,
private ?\DateTimeImmutable $createdAt,
private ?string $pubkey,
) {
}
public function getId(): ?int
{
return $this->id;
}
public function getSlug(): ?string
{
return $this->slug;
}
public function getTitle(): ?string
{
return $this->title;
}
public function getSummary(): ?string
{
return $this->summary;
}
public function getImage(): ?string
{
return $this->image;
}
public function getCreatedAt(): ?\DateTimeImmutable
{
return $this->createdAt;
}
public function getPubkey(): ?string
{
return $this->pubkey;
}
}

42
src/Repository/ArticleRepository.php

@ -2,9 +2,11 @@
namespace App\Repository; namespace App\Repository;
use App\Dto\FeaturedArticleCard;
use App\Entity\Article; use App\Entity\Article;
use App\Enum\EventStatusEnum; use App\Enum\EventStatusEnum;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\DBAL\ArrayParameterType;
use Doctrine\DBAL\Exception; use Doctrine\DBAL\Exception;
use Doctrine\Persistence\ManagerRegistry; use Doctrine\Persistence\ManagerRegistry;
@ -53,20 +55,42 @@ class ArticleRepository extends ServiceEntityRepository
} }
/** /**
* Find articles by multiple slugs * List-card fields only: avoids loading `content` / `raw` (can be very large) for home/category featured rows.
*
* @return list<FeaturedArticleCard>
*/ */
public function findBySlugsCriteria(array $slugs): array public function findFeaturedCardsBySlugs(array $slugs): array
{ {
if (empty($slugs)) { if ($slugs === []) {
return []; return [];
} }
return $this->createQueryBuilder('a') $conn = $this->getEntityManager()->getConnection();
->where('a.slug IN (:slugs)') $qb = $conn->createQueryBuilder();
->setParameter('slugs', $slugs) $qb
->orderBy('a.createdAt', 'DESC') ->select('a.id', 'a.slug', 'a.title', 'a.summary', 'a.image', 'a.created_at', 'a.pubkey')
->getQuery() ->from('article', 'a')
->getResult(); ->where($qb->expr()->in('a.slug', ':slugs'))
->setParameter('slugs', $slugs, ArrayParameterType::STRING)
->orderBy('a.created_at', 'DESC');
/** @var list<array<string, mixed>> $rows */
$rows = $qb->executeQuery()->fetchAllAssociative();
$out = [];
foreach ($rows as $row) {
$ca = $row['created_at'] ?? null;
$out[] = new FeaturedArticleCard(
isset($row['id']) ? (int) $row['id'] : null,
isset($row['slug']) ? (string) $row['slug'] : null,
isset($row['title']) ? (string) $row['title'] : null,
isset($row['summary']) ? (string) $row['summary'] : null,
isset($row['image']) ? (string) $row['image'] : null,
$ca !== null && $ca !== '' ? new \DateTimeImmutable((string) $ca) : null,
isset($row['pubkey']) ? (string) $row['pubkey'] : null,
);
}
return $out;
} }
/** /**

169
src/Service/ArticleCommentThreadLoader.php

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Service; namespace App\Service;
use App\Enum\KindsEnum;
use Psr\Cache\CacheItemPoolInterface; use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException; use Psr\Cache\InvalidArgumentException;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
@ -12,9 +13,14 @@ use Symfony\Contracts\Cache\ItemInterface;
/** /**
* Loads Nostr article discussion: NIP-22 (1111) + legacy kind 1 replies, plus quotes/reposts (q / a tags). * Loads Nostr article discussion: NIP-22 (1111) + legacy kind 1 replies, plus quotes/reposts (q / a tags).
*
* Reply blurbs mirror the jumble client: resolve the parent from `e` / `E` tags (NIP-10, `reply` marker,
* last-of-sequence), then show a short preview of the parent’s body (see jumble `ParentNotePreview`). Inline
* NIP-22 blockquotes with `nostr:` in the child still take precedence when present.
*/ */
final readonly class ArticleCommentThreadLoader final readonly class ArticleCommentThreadLoader
{ {
private const PARENT_REPLY_TEXT_PREVIEW_MAX = 200;
/** PSR-6 pool backing {@see $cache}; used for true cache-only reads (SSR) without invoking Nostr. */ /** PSR-6 pool backing {@see $cache}; used for true cache-only reads (SSR) without invoking Nostr. */
public function __construct( public function __construct(
private NostrClient $nostrClient, private NostrClient $nostrClient,
@ -157,6 +163,7 @@ final readonly class ArticleCommentThreadLoader
]); ]);
$this->enrichThreadListForDisplay($list, $articleEventHexId); $this->enrichThreadListForDisplay($list, $articleEventHexId);
$this->stripRepostEventBodies($list, $quotes);
$commentLinks = []; $commentLinks = [];
$quoteLinks = []; $quoteLinks = [];
@ -189,6 +196,39 @@ final readonly class ArticleCommentThreadLoader
]; ];
} }
/**
* NIP-18 reposts (kinds 6 and 16) carry a JSON-wrapped copy of the original; we only show who reposted, not the body.
*
* @param array<int, object> $list
* @param array<int, object> $quotes
*/
private function stripRepostEventBodies(array $list, array $quotes): void
{
$strip = static function (object $ev): void {
$k = (int) ($ev->kind ?? 0);
if ($k !== KindsEnum::REPOST->value && $k !== KindsEnum::GENERIC_REPOST->value) {
return;
}
$ev->content = '';
if (isset($ev->unfold_reply_blurb)) {
$ev->unfold_reply_blurb = null;
}
if (isset($ev->unfold_body)) {
$ev->unfold_body = '';
}
};
foreach ($list as $ev) {
if (\is_object($ev)) {
$strip($ev);
}
}
foreach ($quotes as $ev) {
if (\is_object($ev)) {
$strip($ev);
}
}
}
/** /**
* @param array<string, array<int, mixed>> $linkBucket * @param array<string, array<int, mixed>> $linkBucket
* @param array<string, string> $processedContent * @param array<string, string> $processedContent
@ -231,31 +271,51 @@ final readonly class ArticleCommentThreadLoader
{ {
$threadIdSet = []; $threadIdSet = [];
foreach ($list as $ev) { foreach ($list as $ev) {
$hid = isset($ev->id) ? (string) $ev->id : ''; $hid = isset($ev->id) ? strtolower((string) $ev->id) : '';
if ($hid !== '') { if (64 === \strlen($hid) && ctype_xdigit($hid)) {
$threadIdSet[$hid] = true; $threadIdSet[$hid] = true;
} }
} }
$idToEvent = [];
foreach ($list as $ev) {
$hid = isset($ev->id) ? strtolower((string) $ev->id) : '';
if (64 === \strlen($hid) && ctype_xdigit($hid)) {
$idToEvent[$hid] = $ev;
}
}
$parentOf = []; $parentOf = [];
foreach ($list as $ev) { foreach ($list as $ev) {
$id = isset($ev->id) ? (string) $ev->id : ''; $id = isset($ev->id) ? strtolower((string) $ev->id) : '';
if ($id === '') { if (64 !== \strlen($id) || !ctype_xdigit($id)) {
continue; continue;
} }
$p = $this->resolveParentCommentId($ev, $threadIdSet, $articleEventHexId); $p = $this->resolveInThreadParentId($ev, $threadIdSet, $articleEventHexId);
if ($p !== null) { if ($p !== null) {
$parentOf[$id] = $p; $parentOf[$id] = $p;
} }
} }
foreach ($list as $ev) { foreach ($list as $ev) {
$id = isset($ev->id) ? (string) $ev->id : ''; $id = isset($ev->id) ? strtolower((string) $ev->id) : '';
$raw = isset($ev->content) ? (string) $ev->content : ''; $raw = isset($ev->content) ? (string) $ev->content : '';
$split = $this->splitNip22ReplyBlurb($raw); $split = $this->splitNip22ReplyBlurb($raw);
$ev->unfold_reply_blurb = $split['blurb']; $blurb = $split['blurb'];
if (($blurb === null || trim($blurb) === '') && $id !== '' && isset($parentOf[$id])) {
$pid = $parentOf[$id];
if (isset($idToEvent[$pid])) {
$parent = $idToEvent[$pid];
$pRaw = isset($parent->content) ? (string) $parent->content : '';
$preview = $this->parentEventTextPreviewForBlurb($pRaw);
if ($preview !== '') {
$blurb = '> *'.'Replying to thread'.'* — '."\n> ".$preview;
}
}
}
$ev->unfold_reply_blurb = $blurb;
$ev->unfold_body = $split['body']; $ev->unfold_body = $split['body'];
$ev->unfold_depth = $id === '' ? 0 : $this->threadDepthCapped($id, $parentOf, 3); $ev->unfold_depth = $id === '' || !ctype_xdigit($id) ? 0 : $this->threadDepthCapped($id, $parentOf, 3);
} }
} }
@ -281,38 +341,103 @@ final readonly class ArticleCommentThreadLoader
} }
/** /**
* NIP-22 nested replies use a lowercase `e` tag for the immediate parent comment; root comments * Truncated single-line text from a parent’s content (strips a leading NIP-22 quote block when present),
* under the article usually have no such tag. Some clients also use `E` for the article root. * similar in spirit to Jumble’s {@see ParentNotePreview} + compact ContentPreview.
*/
private function parentEventTextPreviewForBlurb(string $raw): string
{
$split = $this->splitNip22ReplyBlurb($raw);
$use = (string) $split['body'];
if (trim($use) === '' && $raw !== '') {
$use = $raw;
}
$one = trim((string) (preg_replace('/\s+/', ' ', $use) ?? ''));
if ($one === '') {
return '';
}
if (mb_strlen($one) > self::PARENT_REPLY_TEXT_PREVIEW_MAX) {
return mb_substr($one, 0, self::PARENT_REPLY_TEXT_PREVIEW_MAX).'…';
}
return $one;
}
/**
* In-thread parent id for a reply, mirroring jumble’s {@code getParentETag} / kind-1111 branch: prefer
* {@code e}/{@code E} with marker {@code reply} when that id is another event in the loaded thread, else
* the last in-thread id when several {@code e}/{@code E} apply (NIP-10), else the only in-thread id.
* *
* @param array<string, true> $threadIdSet * The article’s root event id is never returned (blurbs/depth are about comments in the fetched list only).
*
* @param array<string, true> $threadIdSet lower-hex id keys
*
* @return string|null lower-hex parent id, or null
*/ */
private function resolveParentCommentId(object $event, array $threadIdSet, ?string $articleEventHexId): ?string private function resolveInThreadParentId(object $event, array $threadIdSet, ?string $articleEventHexId): ?string
{ {
$selfId = isset($event->id) ? (string) $event->id : ''; $selfId = isset($event->id) ? strtolower((string) $event->id) : '';
$last = null; if (64 !== \strlen($selfId) || !ctype_xdigit($selfId)) {
$selfId = '';
}
$article = ($articleEventHexId !== null && $articleEventHexId !== '' && 64 === \strlen($articleEventHexId) && ctype_xdigit($articleEventHexId))
? strtolower($articleEventHexId) : null;
$isThreadTag = static function (string $n): bool {
return $n === 'e' || $n === 'E';
};
$validInThread = function (string $pid) use ($selfId, $article, $threadIdSet): bool {
if (64 !== \strlen($pid) || !ctype_xdigit($pid)) {
return false;
}
if ($selfId !== '' && hash_equals($pid, $selfId)) {
return false;
}
if ($article !== null && hash_equals($pid, $article)) {
return false;
}
return isset($threadIdSet[$pid]);
};
// 1) Explicit NIP-10 "reply" marker
foreach ($event->tags ?? [] as $tag) { foreach ($event->tags ?? [] as $tag) {
if (!\is_array($tag) || \count($tag) < 2) { if (!\is_array($tag) || \count($tag) < 2) {
continue; continue;
} }
if ((string) ($tag[0] ?? '') !== 'e') { if (!$isThreadTag((string) ($tag[0] ?? ''))) {
continue; continue;
} }
$pid = (string) ($tag[1] ?? ''); if (($tag[3] ?? '') !== 'reply') {
if (64 !== \strlen($pid) || !ctype_xdigit($pid)) {
continue; continue;
} }
if ($selfId !== '' && hash_equals($pid, $selfId)) { $pid = strtolower((string) ($tag[1] ?? ''));
if ($validInThread($pid)) {
return $pid;
}
}
// 2) All in-thread references in tag order; last wins when multiple (cf. jumble getParentETagCommentOrDiscussion)
$candidates = [];
foreach ($event->tags ?? [] as $tag) {
if (!\is_array($tag) || \count($tag) < 2) {
continue; continue;
} }
if ($articleEventHexId !== null && $articleEventHexId !== '' && hash_equals($pid, $articleEventHexId)) { if (!$isThreadTag((string) ($tag[0] ?? ''))) {
continue; continue;
} }
if (isset($threadIdSet[$pid])) { $pid = strtolower((string) ($tag[1] ?? ''));
$last = $pid; if ($validInThread($pid)) {
$candidates[] = $pid;
} }
} }
if ($candidates === []) {
return null;
}
if (\count($candidates) >= 2) {
return $candidates[\count($candidates) - 1];
}
return $last; return $candidates[0];
} }
/** /**

14
src/Service/MagazineContentService.php

@ -9,6 +9,7 @@ use App\Entity\Event;
use App\Enum\EventStatusEnum; use App\Enum\EventStatusEnum;
use App\Repository\ArticleRepository; use App\Repository\ArticleRepository;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\HttpFoundation\RequestStack;
/** /**
* Magazine index for templates. Reads {@see MagazineIndexStore} only on HTTP; relay refresh and DB * Magazine index for templates. Reads {@see MagazineIndexStore} only on HTTP; relay refresh and DB
@ -21,6 +22,7 @@ final class MagazineContentService
private readonly ParameterBagInterface $params, private readonly ParameterBagInterface $params,
private readonly ArticleRepository $articleRepository, private readonly ArticleRepository $articleRepository,
private readonly NostrClient $nostrClient, private readonly NostrClient $nostrClient,
private readonly RequestStack $requestStack,
) { ) {
} }
@ -42,7 +44,17 @@ final class MagazineContentService
*/ */
public function getHomeCategoryAIndexTagsFromStoreOnly(): array public function getHomeCategoryAIndexTagsFromStoreOnly(): array
{ {
return $this->categoryATagsFromStoredRoot(); $request = $this->requestStack->getCurrentRequest();
if ($request !== null && $request->attributes->has('_magazine_home_a_tags')) {
/** @var list<array<int, string>> */
return $request->attributes->get('_magazine_home_a_tags');
}
$tags = $this->categoryATagsFromStoredRoot();
if ($request !== null) {
$request->attributes->set('_magazine_home_a_tags', $tags);
}
return $tags;
} }
/** /**

7
src/Service/MagazineRefresher.php

@ -33,7 +33,7 @@ final class MagazineRefresher
* *
* @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>),
* `category_fetched` (step, total_steps, slug) * `category_fetched` (step, total_steps, category_index, category_total, slug)
*/ */
public function refreshFromRelays(int $budgetSeconds = 8, array $preferSlugs = [], ?callable $onProgress = null): void public function refreshFromRelays(int $budgetSeconds = 8, array $preferSlugs = [], ?callable $onProgress = null): void
{ {
@ -76,6 +76,8 @@ final class MagazineRefresher
'slugs' => $slugs, 'slugs' => $slugs,
]); ]);
$step = 1; $step = 1;
$catTotal = \count($slugs);
$catIndex = 0;
foreach ($slugs as $slug) { foreach ($slugs as $slug) {
if (microtime(true) >= $deadline) { if (microtime(true) >= $deadline) {
$this->logger->notice('MagazineRefresher: stopped at time budget; some categories not fetched', [ $this->logger->notice('MagazineRefresher: stopped at time budget; some categories not fetched', [
@ -100,9 +102,12 @@ final class MagazineRefresher
]); ]);
} finally { } finally {
++$step; ++$step;
++$catIndex;
$onProgress?->__invoke('category_fetched', [ $onProgress?->__invoke('category_fetched', [
'step' => $step, 'step' => $step,
'total_steps' => $totalSteps, 'total_steps' => $totalSteps,
'category_index' => $catIndex,
'category_total' => $catTotal,
'slug' => $slug, 'slug' => $slug,
]); ]);
} }

6
src/Service/Nip05VerificationService.php

@ -54,10 +54,14 @@ final readonly class Nip05VerificationService
$verified = false; $verified = false;
try { try {
$item = $this->appCache->getItem($k); $item = $this->appCache->getItem($k);
if ($item->isHit() && is_bool($item->get())) { if ($item->isHit() && \is_bool($item->get())) {
$verified = (bool) $item->get(); $verified = (bool) $item->get();
} else {
// Cold cache: verify now so the profile shows ✓ without a prior prewarm run.
$verified = $this->verifyAndCache($h, $label);
} }
} catch (InvalidArgumentException) { } catch (InvalidArgumentException) {
$verified = $this->verifyAndCache($h, $label);
} }
$out[] = [...$r, 'verified' => $verified]; $out[] = [...$r, 'verified' => $verified];
} }

50
src/Service/NostrClient.php

@ -39,6 +39,12 @@ class NostrClient
*/ */
private const MAX_DISCUSSION_RELAY_URLS = 10; private const MAX_DISCUSSION_RELAY_URLS = 10;
/**
* {@see Request::send()} hits relays sequentially; profile pages (metadata, long-form list, 10133) used
* the full default+article+profile list (~8–9 wss) → 2 slow relays can exceed PHP’s 30s default max_execution_time.
*/
private const MAX_PROFILE_SEQUENTIAL_RELAY_URLS = 3;
/** /**
* {@see sendArticleDiscussionToRelaysSequential} visits relays one after another (~RELAY_REQUEST_TIMEOUT_SEC * {@see sendArticleDiscussionToRelaysSequential} visits relays one after another (~RELAY_REQUEST_TIMEOUT_SEC
* each). Keep this low so HTTP /fragment/comments and browsers do not hit 60–90s proxy cuts. * each). Keep this low so HTTP /fragment/comments and browsers do not hit 60–90s proxy cuts.
@ -327,6 +333,25 @@ class NostrClient
return $relaySet; return $relaySet;
} }
/**
* @param list<string> $urls
*
* @return list<string>
*/
private function capSequentialRelaysForProfileFetches(array $urls): array
{
if (\count($urls) <= self::MAX_PROFILE_SEQUENTIAL_RELAY_URLS) {
return $urls;
}
$this->logger->notice('nostr.relay_list_capped', [
'context' => 'profile_sequential',
'max' => self::MAX_PROFILE_SEQUENTIAL_RELAY_URLS,
'had' => \count($urls),
]);
return \array_values(\array_slice($urls, 0, self::MAX_PROFILE_SEQUENTIAL_RELAY_URLS));
}
/** /**
* Full NIP-65 (kind-10002) wss:// list for a hex pubkey, cached. Used for comment fetches; prefer * Full NIP-65 (kind-10002) wss:// list for a hex pubkey, cached. Used for comment fetches; prefer
* {@see getTopReputableRelaysForAuthor} when you only need a few relays. * {@see getTopReputableRelaysForAuthor} when you only need a few relays.
@ -589,9 +614,9 @@ class NostrClient
*/ */
public function getNpubMetadata($npub): \stdClass public function getNpubMetadata($npub): \stdClass
{ {
$relaysTried = $this->profileMetadataQueryRelayUrlList(); $relaysTried = $this->capSequentialRelaysForProfileFetches($this->profileMetadataQueryRelayUrlList());
$relaysTriedStr = implode(', ', array_map(self::relayLogLabel(...), $relaysTried)); $relaysTriedStr = implode(', ', array_map(self::relayLogLabel(...), $relaysTried));
$relaySet = $this->relaySetForProfileMetadataFetch(); $relaySet = $this->relaySetFromDistinctUrlList($relaysTried);
$this->logger->info(sprintf('Getting metadata for npub (relays: %s)', $relaysTriedStr), ['npub' => $npub, 'relays' => $relaysTried]); $this->logger->info(sprintf('Getting metadata for npub (relays: %s)', $relaysTriedStr), ['npub' => $npub, 'relays' => $relaysTried]);
$request = $this->createNostrRequest( $request = $this->createNostrRequest(
kinds: [KindsEnum::METADATA], kinds: [KindsEnum::METADATA],
@ -624,9 +649,9 @@ class NostrClient
*/ */
public function getKind10133PaymentTargetEventsForNpub(string $npub, int $limit = 20): array public function getKind10133PaymentTargetEventsForNpub(string $npub, int $limit = 20): array
{ {
$relaysTried = $this->profileMetadataQueryRelayUrlList(); $relaysTried = $this->capSequentialRelaysForProfileFetches($this->profileMetadataQueryRelayUrlList());
$relaysTriedStr = implode(', ', array_map(self::relayLogLabel(...), $relaysTried)); $relaysTriedStr = implode(', ', array_map(self::relayLogLabel(...), $relaysTried));
$relaySet = $this->relaySetForProfileMetadataFetch(); $relaySet = $this->relaySetFromDistinctUrlList($relaysTried);
try { try {
$request = $this->createNostrRequest( $request = $this->createNostrRequest(
kinds: [KindsEnum::PAYMENT_TARGETS], kinds: [KindsEnum::PAYMENT_TARGETS],
@ -1527,13 +1552,20 @@ class NostrClient
*/ */
public function getLongFormContentForPubkey(string $ident): array public function getLongFormContentForPubkey(string $ident): array
{ {
// Add user relays to the default set
$authorRelays = $this->getTopReputableRelaysForAuthor($ident); $authorRelays = $this->getTopReputableRelaysForAuthor($ident);
// Create a RelaySet from the author's relays $base = $this->configuredArticleRelayUrlList();
$relaySet = $this->defaultRelaySet; $merged = $authorRelays !== [] ? array_merge($base, $authorRelays) : $base;
if (!empty($authorRelays)) { $seen = [];
$relaySet = $this->createRelaySet($authorRelays); $deduped = [];
foreach ($merged as $url) {
if (!\is_string($url) || $url === '' || isset($seen[$url])) {
continue;
}
$seen[$url] = true;
$deduped[] = $url;
} }
$capped = $this->capSequentialRelaysForProfileFetches($deduped);
$relaySet = $this->relaySetFromDistinctUrlList($capped);
// Create request using the helper method // Create request using the helper method
$request = $this->createNostrRequest( $request = $this->createNostrRequest(

19
src/Twig/Components/Organisms/FeaturedList.php

@ -2,6 +2,7 @@
namespace App\Twig\Components\Organisms; namespace App\Twig\Components\Organisms;
use App\Dto\FeaturedArticleCard;
use App\Repository\ArticleRepository; use App\Repository\ArticleRepository;
use App\Service\MagazineIndexStore; use App\Service\MagazineIndexStore;
use Psr\Cache\InvalidArgumentException; use Psr\Cache\InvalidArgumentException;
@ -66,7 +67,7 @@ final class FeaturedList
return; return;
} }
$articles = $this->articleRepository->findBySlugsCriteria($slugs); $articles = $this->articleRepository->findFeaturedCardsBySlugs($slugs);
$slugMap = []; $slugMap = [];
foreach ($articles as $article) { foreach ($articles as $article) {
@ -74,7 +75,7 @@ final class FeaturedList
if ($articleSlug !== '') { if ($articleSlug !== '') {
if (!isset($slugMap[$articleSlug])) { if (!isset($slugMap[$articleSlug])) {
$slugMap[$articleSlug] = $article; $slugMap[$articleSlug] = $article;
} elseif ($article->getCreatedAt() > $slugMap[$articleSlug]->getCreatedAt()) { } elseif (self::isNewer($article, $slugMap[$articleSlug])) {
$slugMap[$articleSlug] = $article; $slugMap[$articleSlug] = $article;
} }
} }
@ -90,4 +91,18 @@ final class FeaturedList
$this->list = array_slice($orderedList, 0, 4); $this->list = array_slice($orderedList, 0, 4);
} }
private static function isNewer(FeaturedArticleCard $a, FeaturedArticleCard $b): bool
{
$ca = $a->getCreatedAt();
$cb = $b->getCreatedAt();
if ($ca === null) {
return false;
}
if ($cb === null) {
return true;
}
return $ca > $cb;
}
} }

28
templates/components/Organisms/Comments.html.twig

@ -63,6 +63,7 @@
{% set cpk = item.pubkey|default('') %} {% set cpk = item.pubkey|default('') %}
{% set cts = item.created_at|default(null) %} {% set cts = item.created_at|default(null) %}
{% set cdepth = item.unfold_depth|default(0) %} {% set cdepth = item.unfold_depth|default(0) %}
{% set is_nip18_repost = item.kind is defined and (item.kind == 6 or item.kind == 16) %}
<div class="card comment comment--depth-{{ cdepth }}"> <div class="card comment comment--depth-{{ cdepth }}">
<div class="metadata"> <div class="metadata">
<p> <p>
@ -70,20 +71,26 @@
<span class="ui-badge ui-badge--neutral" title="Legacy text-note reply (pre–NIP-22)">kind 1</span> <span class="ui-badge ui-badge--neutral" title="Legacy text-note reply (pre–NIP-22)">kind 1</span>
{% elseif item.kind is defined and item.kind == 1111 %} {% elseif item.kind is defined and item.kind == 1111 %}
<span class="ui-badge ui-badge--secondary" title="NIP-22 comment">1111</span> <span class="ui-badge ui-badge--secondary" title="NIP-22 comment">1111</span>
{% elseif is_nip18_repost %}
<span class="ui-badge ui-badge--neutral" title="NIP-18 repost (body omitted)">repost</span>
{% endif %} {% endif %}
{% if cpk != '' %}<twig:Molecules:UserFromNpub ident="{{ cpk }}" />{% else %}<span class="text-subtle">Unknown</span>{% endif %} {% if cpk != '' %}<twig:Molecules:UserFromNpub ident="{{ cpk }}" />{% else %}<span class="text-subtle">Unknown</span>{% endif %}
</p> </p>
<small>{% if cts is not null and cts != '' %}{{ cts|date('F j Y') }}{% endif %}</small> <small>{% if cts is not null and cts != '' %}{{ cts|date('F j Y') }}{% endif %}</small>
</div> </div>
{% if item.unfold_reply_blurb|default('')|trim != '' %} {% if not is_nip18_repost and item.unfold_reply_blurb|default('')|trim != '' %}
<div class="comment__reply-blurb" role="note" aria-label="Reply context"> <div class="comment__reply-blurb" role="note" aria-label="Reply context">
<twig:Atoms:Content content="{{ item.unfold_reply_blurb }}" /> <twig:Atoms:Content content="{{ item.unfold_reply_blurb }}" />
</div> </div>
{% endif %} {% endif %}
<div class="card-body"> <div class="card-body">
<twig:Atoms:Content content="{{ item.unfold_body|default(item.content|default('')) }}" /> {% if is_nip18_repost %}
<p class="text-subtle">Repost</p>
{% else %}
<twig:Atoms:Content content="{{ item.unfold_body|default(item.content|default('')) }}" />
{% endif %}
</div> </div>
{% if cid != '' and commentLinks[cid] is defined and commentLinks[cid]|length > 0 %} {% if not is_nip18_repost and cid != '' and commentLinks[cid] is defined and commentLinks[cid]|length > 0 %}
<div class="card-footer nostr-previews mt-3"> <div class="card-footer nostr-previews mt-3">
<div class="preview-container"> <div class="preview-container">
{% for link in commentLinks[cid] %} {% for link in commentLinks[cid] %}
@ -161,10 +168,15 @@
{% set cid = item.id|default('') %} {% set cid = item.id|default('') %}
{% set cpk = item.pubkey|default('') %} {% set cpk = item.pubkey|default('') %}
{% set cts = item.created_at|default(null) %} {% set cts = item.created_at|default(null) %}
{% set q_repost = item.kind is defined and (item.kind == 6 or item.kind == 16) %}
<div class="card comment comment--quote"> <div class="card comment comment--quote">
<div class="metadata"> <div class="metadata">
<p> <p>
<span class="ui-badge ui-badge--neutral">kind {{ item.kind|default('?') }}</span> {% if q_repost %}
<span class="ui-badge ui-badge--neutral" title="NIP-18 repost (body omitted)">repost (kind {{ item.kind }})</span>
{% else %}
<span class="ui-badge ui-badge--neutral">kind {{ item.kind|default('?') }}</span>
{% endif %}
{% if cpk != '' %}<twig:Molecules:UserFromNpub ident="{{ cpk }}" />{% else %}<span class="text-subtle">Unknown</span>{% endif %} {% if cpk != '' %}<twig:Molecules:UserFromNpub ident="{{ cpk }}" />{% else %}<span class="text-subtle">Unknown</span>{% endif %}
</p> </p>
<small> <small>
@ -176,9 +188,13 @@
</small> </small>
</div> </div>
<div class="card-body"> <div class="card-body">
<twig:Atoms:Content content="{{ item.content|default('') }}" /> {% if q_repost %}
<p class="text-subtle">Repost</p>
{% else %}
<twig:Atoms:Content content="{{ item.content|default('') }}" />
{% endif %}
</div> </div>
{% if cid != '' and quoteLinks[cid] is defined and quoteLinks[cid]|length > 0 %} {% if not q_repost and cid != '' and quoteLinks[cid] is defined and quoteLinks[cid]|length > 0 %}
<div class="card-footer nostr-previews mt-3"> <div class="card-footer nostr-previews mt-3">
<div class="preview-container"> <div class="preview-container">
{% for link in quoteLinks[cid] %} {% for link in quoteLinks[cid] %}

49
templates/partial/author_profile_header.html.twig

@ -22,35 +22,40 @@
{% for row in profile_websites %} {% for row in profile_websites %}
<li class="author-profile__identity-row author-profile__meta-line"> <li class="author-profile__identity-row author-profile__meta-line">
<span class="author-profile__identity-type">Website</span> <span class="author-profile__identity-type">Website</span>
<a class="author-profile__identity-link author-profile__meta-value" href="{{ row.href|e('html_attr') }}" rel="nofollow noopener">{{ row.label|e }}</a> <a class="author-profile__identity-link author-profile__meta-value" href="{{ row.href|e('html_attr') }}" target="_blank" rel="nofollow noopener noreferrer">{{ row.label|e }}</a>
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
{% endif %} {% endif %}
{% if show_nip05|default(false) and profile_nip05 is not empty %} {% if show_nip05|default(false) and profile_nip05 is not empty %}
<ul class="author-profile__identity" aria-label="NIP-05"> <div class="author-profile__section author-profile__section--label-value" aria-label="NIP-05">
{% for row in profile_nip05 %} <span class="author-profile__section-type">NIP-05</span>
<li class="author-profile__identity-row author-profile__meta-line"> <ul class="author-profile__value-stack">
<span class="author-profile__identity-type">NIP-05</span> {% for row in profile_nip05 %}
<span class="author-profile__meta-value author-profile__nip05-value"> <li class="author-profile__value-stack-line">
<a class="author-profile__identity-link" href="{{ row.href|e('html_attr') }}" target="_blank" rel="nofollow noopener noreferrer" title="Open /.well-known/nostr.json for this name">{{ row.label|e }}</a> <span class="author-profile__nip05-value">
{% if row.verified|default(false) %} <a class="author-profile__identity-link author-profile__nip05-link" href="{{ row.href|e('html_attr') }}" target="_blank" rel="nofollow noopener noreferrer" title="Open /.well-known/nostr.json for this name">{{ row.label|e }}</a>
<span class="author-profile__nip05-verified" title="This identifier matches the pubkey in /.well-known/nostr.json" aria-label="Verified NIP-05">✓</span> {% if row.verified|default(false) %}
{% endif %} <span class="author-profile__nip05-verified" title="This identifier matches the pubkey in /.well-known/nostr.json" aria-label="Verified NIP-05">✓</span>
</span> {% endif %}
</li> </span>
{% endfor %} </li>
</ul> {% endfor %}
</ul>
</div>
{% endif %} {% endif %}
{% if profile_payment_links is not empty %} {% if profile_payment_links is not empty %}
<ul class="author-profile__payments" aria-label="Payment (Lightning and payto)"> <div class="author-profile__section author-profile__section--label-value" aria-label="Payment">
{% for row in profile_payment_links %} <span class="author-profile__section-type">Payment</span>
<li class="author-profile__payment author-profile__meta-line"> <ul class="author-profile__payments-nested">
<span class="author-profile__payment-type"{% if row.display_type_label|default('')|trim == '' %} aria-hidden="true"{% endif %}>{{ row.display_type_label|default('')|e }}</span> {% for row in profile_payment_links %}
<a class="author-profile__payment-link author-profile__meta-value" href="{{ row.href|e('html_attr') }}" rel="nofollow noopener">{{ row.label|e }}</a> <li class="author-profile__payment author-profile__meta-line">
</li> <span class="author-profile__payment-type"{% if row.display_type_label|default('')|trim == '' %} aria-hidden="true"{% endif %}>{{ row.display_type_label|default('')|e }}</span>
{% endfor %} <a class="author-profile__payment-link author-profile__meta-value" href="{{ row.href|e('html_attr') }}" target="_blank" rel="nofollow noopener noreferrer">{{ row.label|e }}</a>
</ul> </li>
{% endfor %}
</ul>
</div>
{% endif %} {% endif %}
</div> </div>

Loading…
Cancel
Save