Compare commits

..

No commits in common. '20e8a6f4efaadb5055c6fc655a0d48f74d745a7f' and '2b31b5d1de602c4bcdc2682384c1453978fe62ab' have entirely different histories.

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

69
assets/styles/app.css

@ -543,48 +543,6 @@ footer a { @@ -543,48 +543,6 @@ footer a {
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 {
list-style: none;
margin: 0.5rem 0 0;
@ -621,26 +579,17 @@ footer a { @@ -621,26 +579,17 @@ footer a {
white-space: nowrap;
}
/* NIP-05: ellipsis long addresses; keep ✓ immediately after the (truncated) text, not at the column edge. */
.author-profile__nip05-value {
display: flex;
align-items: center;
justify-content: flex-start;
flex-wrap: nowrap;
gap: 0.25rem;
gap: 0.3rem;
min-width: 0;
max-width: 100%;
width: 100%;
overflow: hidden;
}
.author-profile__nip05-link {
flex: 0 1 auto;
.author-profile__nip05-value .author-profile__identity-link {
flex: 1 1 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;
}
@ -651,8 +600,14 @@ footer a { @@ -651,8 +600,14 @@ footer a {
line-height: 1;
opacity: 0.85;
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 {

1
config/services.yaml

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

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

@ -1,10 +0,0 @@ @@ -1,10 +0,0 @@
# 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,8 +29,6 @@ @@ -29,8 +29,6 @@
# Disable Topics tracking if not enabled explicitly: https://github.com/jkarlin/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 {
not path /.well-known/mercure*
not file {path}

14
importmap.php

@ -52,20 +52,6 @@ return [ @@ -52,20 +52,6 @@ return [
'nostr-tools' => [
'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' => [
'version' => '2.0.3',
'type' => 'css',

18
public/.htaccess

@ -1,18 +0,0 @@ @@ -1,18 +0,0 @@
# 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,12 +111,6 @@ final class PrewarmCommand extends Command @@ -111,12 +111,6 @@ final class PrewarmCommand extends Command
}
$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(
$io,
@ -134,28 +128,17 @@ final class PrewarmCommand extends Command @@ -134,28 +128,17 @@ final class PrewarmCommand extends Command
}
$bar->setMessage($tSlug !== '' ? 'Category: '.$tSlug : 'Category');
if ($tSlug !== '') {
$ci = (int) ($p['category_index'] ?? 0);
$ct = (int) ($p['category_total'] ?? 0);
if ($ci > 0 && $ct > 0) {
$step = (int) ($p['step'] ?? 0);
$tot = (int) ($p['total_steps'] ?? 0);
if ($tot > 0) {
$io->writeln(sprintf(
' <info>[category %d/%d]</info> <comment>Fetched category index</comment><info>%s</info>',
$ci,
$ct,
' <info>[%d/%d]</info> <comment>Fetched category index</comment><info>%s</info>',
$step,
$tot,
$tSlug
));
} else {
$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));
}
$io->writeln(sprintf(' <comment>Fetched category index</comment><info>%s</info>', $tSlug));
}
}
}

5
src/Controller/AuthorController.php

@ -33,11 +33,6 @@ class AuthorController extends AbstractController @@ -33,11 +33,6 @@ class AuthorController extends AbstractController
ProfilePaymentLinksBuilder $profilePaymentLinks,
ProfileIdentityLinksBuilder $profileIdentityLinks,
): 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();
$pubkey = $keys->convertToHex($npub);

10
src/Controller/FeaturedAuthorsController.php

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

57
src/Dto/FeaturedArticleCard.php

@ -1,57 +0,0 @@ @@ -1,57 +0,0 @@
<?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,11 +2,9 @@ @@ -2,11 +2,9 @@
namespace App\Repository;
use App\Dto\FeaturedArticleCard;
use App\Entity\Article;
use App\Enum\EventStatusEnum;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\DBAL\ArrayParameterType;
use Doctrine\DBAL\Exception;
use Doctrine\Persistence\ManagerRegistry;
@ -55,42 +53,20 @@ class ArticleRepository extends ServiceEntityRepository @@ -55,42 +53,20 @@ class ArticleRepository extends ServiceEntityRepository
}
/**
* List-card fields only: avoids loading `content` / `raw` (can be very large) for home/category featured rows.
*
* @return list<FeaturedArticleCard>
* Find articles by multiple slugs
*/
public function findFeaturedCardsBySlugs(array $slugs): array
public function findBySlugsCriteria(array $slugs): array
{
if ($slugs === []) {
if (empty($slugs)) {
return [];
}
$conn = $this->getEntityManager()->getConnection();
$qb = $conn->createQueryBuilder();
$qb
->select('a.id', 'a.slug', 'a.title', 'a.summary', 'a.image', 'a.created_at', 'a.pubkey')
->from('article', 'a')
->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;
return $this->createQueryBuilder('a')
->where('a.slug IN (:slugs)')
->setParameter('slugs', $slugs)
->orderBy('a.createdAt', 'DESC')
->getQuery()
->getResult();
}
/**

169
src/Service/ArticleCommentThreadLoader.php

@ -4,7 +4,6 @@ declare(strict_types=1); @@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Service;
use App\Enum\KindsEnum;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException;
use Psr\Log\LoggerInterface;
@ -13,14 +12,9 @@ use Symfony\Contracts\Cache\ItemInterface; @@ -13,14 +12,9 @@ use Symfony\Contracts\Cache\ItemInterface;
/**
* 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
{
private const PARENT_REPLY_TEXT_PREVIEW_MAX = 200;
/** PSR-6 pool backing {@see $cache}; used for true cache-only reads (SSR) without invoking Nostr. */
public function __construct(
private NostrClient $nostrClient,
@ -163,7 +157,6 @@ final readonly class ArticleCommentThreadLoader @@ -163,7 +157,6 @@ final readonly class ArticleCommentThreadLoader
]);
$this->enrichThreadListForDisplay($list, $articleEventHexId);
$this->stripRepostEventBodies($list, $quotes);
$commentLinks = [];
$quoteLinks = [];
@ -196,39 +189,6 @@ final readonly class ArticleCommentThreadLoader @@ -196,39 +189,6 @@ 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, string> $processedContent
@ -271,51 +231,31 @@ final readonly class ArticleCommentThreadLoader @@ -271,51 +231,31 @@ final readonly class ArticleCommentThreadLoader
{
$threadIdSet = [];
foreach ($list as $ev) {
$hid = isset($ev->id) ? strtolower((string) $ev->id) : '';
if (64 === \strlen($hid) && ctype_xdigit($hid)) {
$hid = isset($ev->id) ? (string) $ev->id : '';
if ($hid !== '') {
$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 = [];
foreach ($list as $ev) {
$id = isset($ev->id) ? strtolower((string) $ev->id) : '';
if (64 !== \strlen($id) || !ctype_xdigit($id)) {
$id = isset($ev->id) ? (string) $ev->id : '';
if ($id === '') {
continue;
}
$p = $this->resolveInThreadParentId($ev, $threadIdSet, $articleEventHexId);
$p = $this->resolveParentCommentId($ev, $threadIdSet, $articleEventHexId);
if ($p !== null) {
$parentOf[$id] = $p;
}
}
foreach ($list as $ev) {
$id = isset($ev->id) ? strtolower((string) $ev->id) : '';
$id = isset($ev->id) ? (string) $ev->id : '';
$raw = isset($ev->content) ? (string) $ev->content : '';
$split = $this->splitNip22ReplyBlurb($raw);
$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_reply_blurb = $split['blurb'];
$ev->unfold_body = $split['body'];
$ev->unfold_depth = $id === '' || !ctype_xdigit($id) ? 0 : $this->threadDepthCapped($id, $parentOf, 3);
$ev->unfold_depth = $id === '' ? 0 : $this->threadDepthCapped($id, $parentOf, 3);
}
}
@ -341,103 +281,38 @@ final readonly class ArticleCommentThreadLoader @@ -341,103 +281,38 @@ final readonly class ArticleCommentThreadLoader
}
/**
* Truncated single-line text from a parent’s content (strips a leading NIP-22 quote block when present),
* 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.
* NIP-22 nested replies use a lowercase `e` tag for the immediate parent comment; root comments
* under the article usually have no such tag. Some clients also use `E` for the article root.
*
* 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
* @param array<string, true> $threadIdSet
*/
private function resolveInThreadParentId(object $event, array $threadIdSet, ?string $articleEventHexId): ?string
private function resolveParentCommentId(object $event, array $threadIdSet, ?string $articleEventHexId): ?string
{
$selfId = isset($event->id) ? strtolower((string) $event->id) : '';
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
$selfId = isset($event->id) ? (string) $event->id : '';
$last = null;
foreach ($event->tags ?? [] as $tag) {
if (!\is_array($tag) || \count($tag) < 2) {
continue;
}
if (!$isThreadTag((string) ($tag[0] ?? ''))) {
if ((string) ($tag[0] ?? '') !== 'e') {
continue;
}
if (($tag[3] ?? '') !== 'reply') {
$pid = (string) ($tag[1] ?? '');
if (64 !== \strlen($pid) || !ctype_xdigit($pid)) {
continue;
}
$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) {
if ($selfId !== '' && hash_equals($pid, $selfId)) {
continue;
}
if (!$isThreadTag((string) ($tag[0] ?? ''))) {
if ($articleEventHexId !== null && $articleEventHexId !== '' && hash_equals($pid, $articleEventHexId)) {
continue;
}
$pid = strtolower((string) ($tag[1] ?? ''));
if ($validInThread($pid)) {
$candidates[] = $pid;
if (isset($threadIdSet[$pid])) {
$last = $pid;
}
}
if ($candidates === []) {
return null;
}
if (\count($candidates) >= 2) {
return $candidates[\count($candidates) - 1];
}
return $candidates[0];
return $last;
}
/**

14
src/Service/MagazineContentService.php

@ -9,7 +9,6 @@ use App\Entity\Event; @@ -9,7 +9,6 @@ use App\Entity\Event;
use App\Enum\EventStatusEnum;
use App\Repository\ArticleRepository;
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
@ -22,7 +21,6 @@ final class MagazineContentService @@ -22,7 +21,6 @@ final class MagazineContentService
private readonly ParameterBagInterface $params,
private readonly ArticleRepository $articleRepository,
private readonly NostrClient $nostrClient,
private readonly RequestStack $requestStack,
) {
}
@ -44,17 +42,7 @@ final class MagazineContentService @@ -44,17 +42,7 @@ final class MagazineContentService
*/
public function getHomeCategoryAIndexTagsFromStoreOnly(): array
{
$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;
return $this->categoryATagsFromStoredRoot();
}
/**

7
src/Service/MagazineRefresher.php

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

6
src/Service/Nip05VerificationService.php

@ -54,14 +54,10 @@ final readonly class Nip05VerificationService @@ -54,14 +54,10 @@ final readonly class Nip05VerificationService
$verified = false;
try {
$item = $this->appCache->getItem($k);
if ($item->isHit() && \is_bool($item->get())) {
if ($item->isHit() && is_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) {
$verified = $this->verifyAndCache($h, $label);
}
$out[] = [...$r, 'verified' => $verified];
}

50
src/Service/NostrClient.php

@ -39,12 +39,6 @@ class NostrClient @@ -39,12 +39,6 @@ class NostrClient
*/
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
* each). Keep this low so HTTP /fragment/comments and browsers do not hit 60–90s proxy cuts.
@ -333,25 +327,6 @@ class NostrClient @@ -333,25 +327,6 @@ class NostrClient
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
* {@see getTopReputableRelaysForAuthor} when you only need a few relays.
@ -614,9 +589,9 @@ class NostrClient @@ -614,9 +589,9 @@ class NostrClient
*/
public function getNpubMetadata($npub): \stdClass
{
$relaysTried = $this->capSequentialRelaysForProfileFetches($this->profileMetadataQueryRelayUrlList());
$relaysTried = $this->profileMetadataQueryRelayUrlList();
$relaysTriedStr = implode(', ', array_map(self::relayLogLabel(...), $relaysTried));
$relaySet = $this->relaySetFromDistinctUrlList($relaysTried);
$relaySet = $this->relaySetForProfileMetadataFetch();
$this->logger->info(sprintf('Getting metadata for npub (relays: %s)', $relaysTriedStr), ['npub' => $npub, 'relays' => $relaysTried]);
$request = $this->createNostrRequest(
kinds: [KindsEnum::METADATA],
@ -649,9 +624,9 @@ class NostrClient @@ -649,9 +624,9 @@ class NostrClient
*/
public function getKind10133PaymentTargetEventsForNpub(string $npub, int $limit = 20): array
{
$relaysTried = $this->capSequentialRelaysForProfileFetches($this->profileMetadataQueryRelayUrlList());
$relaysTried = $this->profileMetadataQueryRelayUrlList();
$relaysTriedStr = implode(', ', array_map(self::relayLogLabel(...), $relaysTried));
$relaySet = $this->relaySetFromDistinctUrlList($relaysTried);
$relaySet = $this->relaySetForProfileMetadataFetch();
try {
$request = $this->createNostrRequest(
kinds: [KindsEnum::PAYMENT_TARGETS],
@ -1552,20 +1527,13 @@ class NostrClient @@ -1552,20 +1527,13 @@ class NostrClient
*/
public function getLongFormContentForPubkey(string $ident): array
{
// Add user relays to the default set
$authorRelays = $this->getTopReputableRelaysForAuthor($ident);
$base = $this->configuredArticleRelayUrlList();
$merged = $authorRelays !== [] ? array_merge($base, $authorRelays) : $base;
$seen = [];
$deduped = [];
foreach ($merged as $url) {
if (!\is_string($url) || $url === '' || isset($seen[$url])) {
continue;
}
$seen[$url] = true;
$deduped[] = $url;
// Create a RelaySet from the author's relays
$relaySet = $this->defaultRelaySet;
if (!empty($authorRelays)) {
$relaySet = $this->createRelaySet($authorRelays);
}
$capped = $this->capSequentialRelaysForProfileFetches($deduped);
$relaySet = $this->relaySetFromDistinctUrlList($capped);
// Create request using the helper method
$request = $this->createNostrRequest(

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

@ -2,7 +2,6 @@ @@ -2,7 +2,6 @@
namespace App\Twig\Components\Organisms;
use App\Dto\FeaturedArticleCard;
use App\Repository\ArticleRepository;
use App\Service\MagazineIndexStore;
use Psr\Cache\InvalidArgumentException;
@ -67,7 +66,7 @@ final class FeaturedList @@ -67,7 +66,7 @@ final class FeaturedList
return;
}
$articles = $this->articleRepository->findFeaturedCardsBySlugs($slugs);
$articles = $this->articleRepository->findBySlugsCriteria($slugs);
$slugMap = [];
foreach ($articles as $article) {
@ -75,7 +74,7 @@ final class FeaturedList @@ -75,7 +74,7 @@ final class FeaturedList
if ($articleSlug !== '') {
if (!isset($slugMap[$articleSlug])) {
$slugMap[$articleSlug] = $article;
} elseif (self::isNewer($article, $slugMap[$articleSlug])) {
} elseif ($article->getCreatedAt() > $slugMap[$articleSlug]->getCreatedAt()) {
$slugMap[$articleSlug] = $article;
}
}
@ -91,18 +90,4 @@ final class FeaturedList @@ -91,18 +90,4 @@ final class FeaturedList
$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,7 +63,6 @@ @@ -63,7 +63,6 @@
{% set cpk = item.pubkey|default('') %}
{% set cts = item.created_at|default(null) %}
{% 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="metadata">
<p>
@ -71,26 +70,20 @@ @@ -71,26 +70,20 @@
<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 %}
<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 %}
{% if cpk != '' %}<twig:Molecules:UserFromNpub ident="{{ cpk }}" />{% else %}<span class="text-subtle">Unknown</span>{% endif %}
</p>
<small>{% if cts is not null and cts != '' %}{{ cts|date('F j Y') }}{% endif %}</small>
</div>
{% if not is_nip18_repost and item.unfold_reply_blurb|default('')|trim != '' %}
{% if item.unfold_reply_blurb|default('')|trim != '' %}
<div class="comment__reply-blurb" role="note" aria-label="Reply context">
<twig:Atoms:Content content="{{ item.unfold_reply_blurb }}" />
</div>
{% endif %}
<div class="card-body">
{% if is_nip18_repost %}
<p class="text-subtle">Repost</p>
{% else %}
<twig:Atoms:Content content="{{ item.unfold_body|default(item.content|default('')) }}" />
{% endif %}
<twig:Atoms:Content content="{{ item.unfold_body|default(item.content|default('')) }}" />
</div>
{% if not is_nip18_repost and cid != '' and commentLinks[cid] is defined and commentLinks[cid]|length > 0 %}
{% if cid != '' and commentLinks[cid] is defined and commentLinks[cid]|length > 0 %}
<div class="card-footer nostr-previews mt-3">
<div class="preview-container">
{% for link in commentLinks[cid] %}
@ -168,15 +161,10 @@ @@ -168,15 +161,10 @@
{% set cid = item.id|default('') %}
{% set cpk = item.pubkey|default('') %}
{% 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="metadata">
<p>
{% 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 %}
<span class="ui-badge ui-badge--neutral">kind {{ item.kind|default('?') }}</span>
{% if cpk != '' %}<twig:Molecules:UserFromNpub ident="{{ cpk }}" />{% else %}<span class="text-subtle">Unknown</span>{% endif %}
</p>
<small>
@ -188,13 +176,9 @@ @@ -188,13 +176,9 @@
</small>
</div>
<div class="card-body">
{% if q_repost %}
<p class="text-subtle">Repost</p>
{% else %}
<twig:Atoms:Content content="{{ item.content|default('') }}" />
{% endif %}
<twig:Atoms:Content content="{{ item.content|default('') }}" />
</div>
{% if not q_repost and cid != '' and quoteLinks[cid] is defined and quoteLinks[cid]|length > 0 %}
{% if cid != '' and quoteLinks[cid] is defined and quoteLinks[cid]|length > 0 %}
<div class="card-footer nostr-previews mt-3">
<div class="preview-container">
{% for link in quoteLinks[cid] %}

49
templates/partial/author_profile_header.html.twig

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

Loading…
Cancel
Save