Browse Source

bug-fixes

imwald
Silberengel 5 days ago
parent
commit
cf51a1b4d9
  1. 33
      assets/controllers/comment_reply_controller.js
  2. 14
      assets/styles/app.css
  3. 32
      assets/styles/article.css
  4. 76
      assets/styles/layout.css
  5. 103
      src/Command/PrewarmCommand.php
  6. 7
      src/Controller/CommentReplyController.php
  7. 22
      src/Controller/FeaturedAuthorsController.php
  8. 4
      src/Service/ArticleCommentThreadLoader.php
  9. 26
      src/Service/CommentReplyService.php
  10. 89
      src/Service/FeaturedAuthorSync.php
  11. 168
      src/Service/MagazineContentService.php
  12. 32
      src/Service/NostrClient.php
  13. 45
      templates/pages/featured_authors.html.twig

33
assets/controllers/comment_reply_controller.js

@ -109,10 +109,23 @@ export default class extends Controller {
} }
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
if (!res.ok) { if (!res.ok) {
this.setHint(data.error || `HTTP ${res.status}`); const msg = data.error || `HTTP ${res.status}`;
this.setHint(msg);
this.showToast(msg, 'error');
return; return;
} }
this.setHint('Published.'); const okRelaysRaw = Number(data.ok_relays);
const totalRelaysRaw = Number(data.total_relays);
const okRelays = Number.isFinite(okRelaysRaw) ? okRelaysRaw : null;
const totalRelays = Number.isFinite(totalRelaysRaw) ? totalRelaysRaw : null;
const successMsg =
okRelays !== null && totalRelays !== null
? `Published to ${okRelays}/${totalRelays} relays.`
: 'Published.';
this.setHint(successMsg);
this.showToast(successMsg, 'success');
// Keep form content until the success toast is visible.
await new Promise((r) => window.setTimeout(r, 180));
if (ta) { if (ta) {
ta.value = ''; ta.value = '';
} }
@ -203,4 +216,20 @@ export default class extends Controller {
this.hintTarget.textContent = msg; this.hintTarget.textContent = msg;
} }
} }
showToast(message, tone = 'success') {
const el = document.createElement('div');
el.className = `reply-toast reply-toast--${tone === 'error' ? 'error' : 'success'}`;
el.setAttribute('role', 'status');
el.setAttribute('aria-live', 'polite');
el.textContent = message;
document.body.appendChild(el);
window.setTimeout(() => {
el.classList.add('reply-toast--visible');
}, 10);
window.setTimeout(() => {
el.classList.remove('reply-toast--visible');
window.setTimeout(() => el.remove(), 220);
}, 2600);
}
} }

14
assets/styles/app.css

@ -957,16 +957,17 @@ a:focus-visible {
.pager { .pager {
margin-top: 1.25rem; margin-top: 1.25rem;
display: flex;
justify-content: center;
width: 100%; width: 100%;
box-sizing: border-box;
} }
.pager__inner { .pager__inner {
width: min(100%, 36rem); width: 100%;
max-width: 100%;
box-sizing: border-box;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: space-between;
gap: 0.75rem; gap: 0.75rem;
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
@ -974,7 +975,8 @@ a:focus-visible {
} }
.pager__status { .pager__status {
min-width: 8rem; flex: 1 1 auto;
min-width: 0;
text-align: center; text-align: center;
} }
@ -990,14 +992,12 @@ a:focus-visible {
@media (max-width: 640px) { @media (max-width: 640px) {
.pager__inner { .pager__inner {
width: 100%;
gap: 0.5rem; gap: 0.5rem;
} }
.pager__btn { .pager__btn {
min-width: 5.25rem; min-width: 5.25rem;
} }
.pager__status { .pager__status {
min-width: 7rem;
font-size: 0.92rem; font-size: 0.92rem;
} }
} }

32
assets/styles/article.css

@ -338,3 +338,35 @@ blockquote p {
font-size: 0.9rem; font-size: 0.9rem;
margin: 0.5rem 0 0; margin: 0.5rem 0 0;
} }
.reply-toast {
position: fixed;
left: 50%;
bottom: 1.2rem;
transform: translateX(-50%) translateY(8px);
z-index: 1200;
min-width: 16rem;
max-width: min(92vw, 32rem);
padding: 0.55rem 0.85rem;
border: 1px solid var(--color-border);
color: var(--color-text);
background: var(--color-bg);
opacity: 0;
transition: opacity 0.2s ease, transform 0.2s ease;
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.18);
}
.reply-toast--visible {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
.reply-toast--success {
border-color: #2f7a4b;
background: #e7f5eb;
}
.reply-toast--error {
border-color: #a12b2b;
background: #fdecec;
}

76
assets/styles/layout.css

@ -22,7 +22,8 @@
flex-grow: 1; flex-grow: 1;
} }
nav { /* Only the app chrome sidebar — not <nav> in main (pagination) or footer. */
.layout > nav {
width: 21vw; width: 21vw;
min-width: 150px; min-width: 150px;
max-width: 280px; max-width: 280px;
@ -31,21 +32,21 @@ nav {
overflow-y: auto; /* Ensure the menu is scrollable if content is too long */ overflow-y: auto; /* Ensure the menu is scrollable if content is too long */
} }
nav ul { .layout > nav ul {
list-style-type: none; list-style-type: none;
padding: 0; padding: 0;
} }
nav li { .layout > nav li {
margin: 0.5em 0; margin: 0.5em 0;
} }
nav a { .layout > nav a {
color: var(--color-primary); color: var(--color-primary);
text-decoration: none; text-decoration: none;
} }
nav a:hover { .layout > nav a:hover {
color: var(--color-text-mid); color: var(--color-text-mid);
text-decoration: none; text-decoration: none;
} }
@ -642,6 +643,71 @@ footer .footer-links {
padding: 0 0.5rem 2rem; padding: 0 0.5rem 2rem;
} }
.featured-authors-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
gap: 0.85rem;
}
.featured-authors-grid__card {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.35rem;
padding: 0.75rem 0.55rem;
border: 1px solid var(--color-border);
background: var(--color-bg);
text-decoration: none;
color: inherit;
}
.featured-authors-grid__card:hover {
text-decoration: none;
background: var(--color-bg-light);
}
.featured-authors-grid__avatar {
width: 64px;
height: 64px;
border-radius: 50%;
overflow: hidden;
box-shadow: 0 0 0 1px var(--color-border);
display: flex;
align-items: center;
justify-content: center;
background: var(--color-bg-light);
}
.featured-authors-grid__avatar > img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.featured-authors-grid__avatar-fallback {
font-size: 1.25rem;
color: var(--color-text-mid);
}
.featured-authors-grid__name {
width: 100%;
text-align: center;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.featured-authors-grid__handle {
width: 100%;
text-align: center;
font-size: 0.82rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.featured-authors__intro { .featured-authors__intro {
margin-bottom: 2rem; margin-bottom: 2rem;
overflow: visible; /* do not clip heading ascenders */ overflow: visible; /* do not clip heading ascenders */

103
src/Command/PrewarmCommand.php

@ -174,15 +174,6 @@ final class PrewarmCommand extends Command
} }
} else { } else {
$io->note('Skipping magazine (--no-magazine).'); $io->note('Skipping magazine (--no-magazine).');
try {
$fa = $this->featuredAuthorSync->syncNewAuthorsFromMagazineCategories();
if ($fa > 0) {
$io->writeln(sprintf(' Featured authors: added <info>%d</info> new NIP-05 row(s) from the cached category index.', $fa));
}
} catch (\Throwable $e) {
$this->logger->warning('app:prewarm featured author sync (no-magazine)', ['e' => $e->getMessage()]);
$io->warning('Featured author sync failed: '.$e->getMessage());
}
} }
$io->section('Long-form in DB (category `a` tags — refresh from Nostr)'); $io->section('Long-form in DB (category `a` tags — refresh from Nostr)');
@ -193,11 +184,41 @@ final class PrewarmCommand extends Command
} else { } else {
$io->writeln(sprintf('Fetched latest long-form for <info>%d</info> coordinate(s) (new rows + NIP-33 updates).', $n)); $io->writeln(sprintf('Fetched latest long-form for <info>%d</info> coordinate(s) (new rows + NIP-33 updates).', $n));
} }
$report = $this->magazineContent->buildCategoryArticleDbCoverageReport();
$missingCoords = $this->magazineContent->missingInDbCoordinatesFromCoverageReport($report);
$attempt = 0;
while ($missingCoords !== [] && $attempt < 2) {
$attempt++;
$io->writeln(sprintf(
'Retrying unresolved category coordinates from relays (attempt <info>%d</info>, coordinates: <comment>%d</comment>)…',
$attempt,
\count($missingCoords)
));
$this->nostrClient->ingestLongformForCategoryCoordinates($missingCoords);
$report = $this->magazineContent->buildCategoryArticleDbCoverageReport();
$missingCoords = $this->magazineContent->missingInDbCoordinatesFromCoverageReport($report);
}
$this->printCategoryCoverageSummary($io, $report);
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->logger->error('app:prewarm longform ingest failed', ['e' => $e]); $this->logger->error('app:prewarm longform ingest failed', ['e' => $e]);
$io->warning('Long-form backfill failed: '.$e->getMessage()); $io->warning('Long-form backfill failed: '.$e->getMessage());
} }
$io->section('Featured authors / NIP-05 source list');
try {
$fa = $this->featuredAuthorSync->reconcileListedAuthorsFromMagazineCategories();
$io->writeln(sprintf(
'Derived from category `a` tags: listed now <info>%d</info> · added <info>%d</info> · relisted <info>%d</info> · unlisted <comment>%d</comment>',
$fa['listed_total'],
$fa['added'],
$fa['relisted'],
$fa['unlisted'],
));
} catch (\Throwable $e) {
$this->logger->warning('app:prewarm featured author reconcile', ['e' => $e->getMessage()]);
$io->warning('Featured author reconcile failed: '.$e->getMessage());
}
// MagazineRefresher sets max_execution_time (budget + headroom); restore before metadata. // MagazineRefresher sets max_execution_time (budget + headroom); restore before metadata.
$this->disableCliExecutionTimeLimit(); $this->disableCliExecutionTimeLimit();
@ -556,4 +577,68 @@ final class PrewarmCommand extends Command
@set_time_limit(0); @set_time_limit(0);
@ini_set('max_execution_time', '0'); @ini_set('max_execution_time', '0');
} }
/**
* @param array{
* categories: list<array{
* slug: string,
* title: string,
* event_id: string,
* listed_total: int,
* resolved_total: int,
* missing_total: int,
* entries: list<array{
* coordinate: string,
* status: string,
* reason: string,
* article_title?: string
* }>
* }>,
* totals: array{categories: int, listed: int, resolved: int, missing: int}
* } $report
*/
private function printCategoryCoverageSummary(SymfonyStyle $io, array $report): void
{
$io->section('Category index -> DB coverage');
$tot = $report['totals'] ?? ['categories' => 0, 'listed' => 0, 'resolved' => 0, 'missing' => 0];
$io->writeln(sprintf(
'Categories: <info>%d</info> · listed coordinates: <info>%d</info> · in DB: <info>%d</info> · missing: <comment>%d</comment>',
(int) ($tot['categories'] ?? 0),
(int) ($tot['listed'] ?? 0),
(int) ($tot['resolved'] ?? 0),
(int) ($tot['missing'] ?? 0),
));
foreach ($report['categories'] ?? [] as $cat) {
$title = trim((string) ($cat['title'] ?? ''));
$slug = (string) ($cat['slug'] ?? '');
$eventId = (string) ($cat['event_id'] ?? '');
$io->writeln(sprintf(
' - <info>%s</info> (%s) · event <comment>%s</comment> · listed <info>%d</info>, in DB <info>%d</info>, missing <comment>%d</comment>',
$title !== '' ? $title : $slug,
$slug,
$eventId !== '' ? $eventId : 'n/a',
(int) ($cat['listed_total'] ?? 0),
(int) ($cat['resolved_total'] ?? 0),
(int) ($cat['missing_total'] ?? 0),
));
foreach ($cat['entries'] ?? [] as $entry) {
$coord = (string) ($entry['coordinate'] ?? '');
if ($coord === '') {
continue;
}
$status = (string) ($entry['status'] ?? 'missing');
if ($status === 'resolved') {
$titleOut = trim((string) ($entry['article_title'] ?? ''));
$io->writeln(sprintf(
' + <info>OK</info> %s%s',
$coord,
$titleOut !== '' ? ' -> '.$titleOut : ''
));
} else {
$reason = (string) ($entry['reason'] ?? 'unknown');
$io->writeln(sprintf(' - <comment>MISSING</comment> %s (%s)', $coord, $reason));
}
}
}
}
} }

7
src/Controller/CommentReplyController.php

@ -54,7 +54,12 @@ final class CommentReplyController extends AbstractController
$commentThreadLoader->invalidateThread($coord, 64 === \strlen((string) $eid) && ctype_xdigit((string) $eid) ? $eid : null); $commentThreadLoader->invalidateThread($coord, 64 === \strlen((string) $eid) && ctype_xdigit((string) $eid) ? $eid : null);
} }
return $this->json(['ok' => true, 'id' => $out['id']]); return $this->json([
'ok' => true,
'id' => $out['id'],
'ok_relays' => $out['ok_relays'] ?? null,
'total_relays' => $out['total_relays'] ?? null,
]);
} }
/** @var array{ok: false, error: string, code: int} $out */ /** @var array{ok: false, error: string, code: int} $out */

22
src/Controller/FeaturedAuthorsController.php

@ -6,9 +6,6 @@ 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\ProfilePaymentLinksBuilder;
use swentel\nostr\Key\Key; use swentel\nostr\Key\Key;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
@ -26,9 +23,6 @@ final class FeaturedAuthorsController extends AbstractController
Request $request, Request $request,
FeaturedAuthorRepository $featuredAuthorRepository, FeaturedAuthorRepository $featuredAuthorRepository,
CacheService $cacheService, CacheService $cacheService,
NostrClient $nostrClient,
ProfileIdentityLinksBuilder $profileIdentityLinks,
ProfilePaymentLinksBuilder $profilePaymentLinks,
ParameterBagInterface $params, ParameterBagInterface $params,
): Response { ): Response {
$domain = trim((string) $params->get('nip05_domain')); $domain = trim((string) $params->get('nip05_domain'));
@ -46,18 +40,14 @@ final class FeaturedAuthorsController extends AbstractController
$npub = $keys->convertPublicKeyToBech32($fa->getPubkeyHex()); $npub = $keys->convertPublicKeyToBech32($fa->getPubkeyHex());
$bundle = $cacheService->getMetadataBundle($npub); $bundle = $cacheService->getMetadataBundle($npub);
$author = $bundle['content']; $author = $bundle['content'];
$kind0Tags = $bundle['kind0_tags']; $displayName = trim((string) ($author->display_name ?? $author->name ?? ''));
$kind10133 = []; $picture = trim((string) ($author->picture ?? ''));
try {
$kind10133 = $nostrClient->getKind10133PaymentTargetEventsForNpub($npub, 20);
} catch (\Throwable) {
}
$extraPayto = $profilePaymentLinks->collectPaytoUrisFromNipA3Kind10133Events($kind10133);
$authors[] = [ $authors[] = [
'author' => $author,
'npub' => $npub, 'npub' => $npub,
'profile_websites' => $profileIdentityLinks->buildWebsites($author, $kind0Tags), 'pubkey' => strtolower($fa->getPubkeyHex()),
'profile_payment_links' => $profilePaymentLinks->buildPaymentRows($author, $kind0Tags, $extraPayto), 'display_name' => $displayName,
'picture' => $picture,
'local_part' => $fa->getLocalPart(),
]; ];
} }

4
src/Service/ArticleCommentThreadLoader.php

@ -379,7 +379,9 @@ final readonly class ArticleCommentThreadLoader
continue; continue;
} }
$name = (string) $row[0]; $name = (string) $row[0];
if ($name !== 'a' && $name !== 'A') { // Use only direct lowercase `a` tags here; uppercase `A` is often thread-root context.
// Nested replies should derive blurbs from the direct `e` parent (handled via parentOf fallback).
if ($name !== 'a') {
continue; continue;
} }
$coord = (string) $row[1]; $coord = (string) $row[1];

26
src/Service/CommentReplyService.php

@ -26,7 +26,7 @@ final readonly class CommentReplyService
/** /**
* @param array<string, mixed> $payload Decoded JSON body * @param array<string, mixed> $payload Decoded JSON body
* *
* @return array{ok: true, id: string, relays: array<string, mixed>}|array{ok: false, error: string, code: int} * @return array{ok: true, id: string, relays: array<string, mixed>, ok_relays: int, total_relays: int}|array{ok: false, error: string, code: int}
*/ */
public function publishFromRequestPayload(User $user, array $payload): array public function publishFromRequestPayload(User $user, array $payload): array
{ {
@ -108,12 +108,34 @@ final readonly class CommentReplyService
$relays = $this->nostrClient->getRelayUrlsForCommentPublish($expectedCoordinate, $parentAuthorHex); $relays = $this->nostrClient->getRelayUrlsForCommentPublish($expectedCoordinate, $parentAuthorHex);
$result = $this->nostrClient->publishEvent($wire, $relays); $result = $this->nostrClient->publishEvent($wire, $relays);
$okRelays = 0;
foreach ($result as $relayRes) {
if ($relayRes instanceof \Throwable) {
continue;
}
$okRelays++;
}
if ($okRelays < 1) {
$this->logger->warning('comment_reply.publish_failed_all_relays', [
'id' => $wire->getId(),
'relay_count' => \count($result),
]);
return ['ok' => false, 'error' => 'Publish failed on all relays (network/relay error). Please retry.', 'code' => 502];
}
$this->logger->info('comment_reply.published', [ $this->logger->info('comment_reply.published', [
'id' => $wire->getId(), 'id' => $wire->getId(),
'relays' => \array_keys($result), 'relays' => \array_keys($result),
'ok_relays' => $okRelays,
]); ]);
return ['ok' => true, 'id' => $wire->getId(), 'relays' => $result]; return [
'ok' => true,
'id' => $wire->getId(),
'relays' => $result,
'ok_relays' => $okRelays,
'total_relays' => \count($result),
];
} }
/** /**

89
src/Service/FeaturedAuthorSync.php

@ -11,8 +11,8 @@ use Psr\Log\LoggerInterface;
use swentel\nostr\Key\Key; use swentel\nostr\Key\Key;
/** /**
* Adds {@see FeaturedAuthor} rows for pubkeys found in magazine category indices; assigns * Reconciles {@see FeaturedAuthor} rows with pubkeys found in magazine category `a` tags.
* unique NIP-05 local-parts from kind-0 name when possible. Does not remove or re-list rows. * The listed set is derived from current category indices during prewarm.
*/ */
final class FeaturedAuthorSync final class FeaturedAuthorSync
{ {
@ -26,34 +26,87 @@ final class FeaturedAuthorSync
} }
/** /**
* @return int Number of newly persisted authors * @return array{added: int, relisted: int, unlisted: int, listed_total: int}
*/ */
public function syncNewAuthorsFromMagazineCategories(): int public function reconcileListedAuthorsFromMagazineCategories(): array
{ {
$pubkeys = $this->magazineContent->getAllDistinctCategoryAuthorPubkeyHexes(); $pubkeys = $this->magazineContent->getAllDistinctCategoryAuthorPubkeyHexes();
if ($pubkeys === []) { $target = [];
return 0; foreach ($pubkeys as $hex) {
$h = strtolower(trim($hex));
if (64 === \strlen($h) && ctype_xdigit($h)) {
$target[$h] = true;
}
} }
$existingByPubkey = [];
foreach ($this->featuredAuthorRepository->findAll() as $row) {
$existingByPubkey[strtolower($row->getPubkeyHex())] = $row;
}
$keys = new Key(); $keys = new Key();
$n = 0; $added = 0;
foreach ($pubkeys as $hex) { $relisted = 0;
if ($this->featuredAuthorRepository->findOneByPubkeyHex($hex) !== null) { $unlisted = 0;
$changed = false;
foreach (array_keys($target) as $hex) {
$row = $existingByPubkey[$hex] ?? null;
if ($row === null) {
$entity = new FeaturedAuthor();
$entity->setPubkeyHex($hex);
$base = $this->deriveBaseLocalPart($keys, $hex);
$entity->setLocalPart($this->allocateUniqueLocalPart($base));
$entity->setIsListed(true);
$this->entityManager->persist($entity);
$existingByPubkey[$hex] = $entity;
$added++;
$changed = true;
continue;
}
if (!$row->isListed()) {
$row->setIsListed(true);
$relisted++;
$changed = true;
}
}
foreach ($existingByPubkey as $hex => $row) {
if (isset($target[$hex])) {
continue; continue;
} }
$entity = new FeaturedAuthor(); if ($row->isListed()) {
$entity->setPubkeyHex($hex); $row->setIsListed(false);
$base = $this->deriveBaseLocalPart($keys, $hex); $unlisted++;
$entity->setLocalPart($this->allocateUniqueLocalPart($base)); $changed = true;
$this->entityManager->persist($entity); }
++$n;
} }
if ($n > 0) {
if ($changed) {
$this->entityManager->flush(); $this->entityManager->flush();
$this->logger->info('featured_author.sync', ['new_count' => $n]); $this->logger->info('featured_author.sync', [
'added' => $added,
'relisted' => $relisted,
'unlisted' => $unlisted,
'listed_total' => \count($target),
]);
} }
return $n; return [
'added' => $added,
'relisted' => $relisted,
'unlisted' => $unlisted,
'listed_total' => \count($target),
];
}
/**
* @deprecated use {@see reconcileListedAuthorsFromMagazineCategories}
*/
public function syncNewAuthorsFromMagazineCategories(): int
{
$st = $this->reconcileListedAuthorsFromMagazineCategories();
return $st['added'];
} }
private function deriveBaseLocalPart(Key $keys, string $pubkeyHex): string private function deriveBaseLocalPart(Key $keys, string $pubkeyHex): string

168
src/Service/MagazineContentService.php

@ -301,6 +301,174 @@ final class MagazineContentService
return $n; return $n;
} }
/**
* Human-readable prewarm/audit data: what each cached category index (30040) lists and which
* coordinates are unresolved in local MySQL `article`.
*
* @return array{
* categories: list<array{
* slug: string,
* title: string,
* event_id: string,
* listed_total: int,
* resolved_total: int,
* missing_total: int,
* entries: list<array{
* coordinate: string,
* status: 'resolved'|'missing',
* reason: string,
* article_title?: string,
* article_slug?: string
* }>
* }>,
* totals: array{categories: int, listed: int, resolved: int, missing: int}
* }
*/
public function buildCategoryArticleDbCoverageReport(): array
{
$categories = [];
$totListed = 0;
$totResolved = 0;
$totMissing = 0;
foreach ($this->getCategorySlugsFromStore() as $slug) {
$catIndex = $this->store->getCategory($slug);
if ($catIndex === null) {
continue;
}
$title = $slug;
$coords = [];
foreach ($catIndex->getTags() as $tag) {
$seq = NostrEventTags::rowToStringList($tag);
if ($seq === null) {
continue;
}
$name = strtolower((string) ($seq[0] ?? ''));
if ($name === 'title' && isset($seq[1]) && trim((string) $seq[1]) !== '') {
$title = trim((string) $seq[1]);
}
if ($name === 'a' && isset($seq[1]) && trim((string) $seq[1]) !== '') {
$coords[] = trim((string) $seq[1]);
}
}
$coords = array_values(array_unique($coords));
$pairs = [];
foreach ($coords as $coordinate) {
$parts = explode(':', $coordinate, 3);
if (\count($parts) < 3) {
continue;
}
$pub = strtolower(trim((string) $parts[1]));
$d = trim((string) $parts[2]);
if ($d === '' || 64 !== \strlen($pub) || !ctype_xdigit($pub)) {
continue;
}
$pairs[] = ['pubkey' => $pub, 'slug' => $d];
}
$byAddress = $this->articleRepository->findByAuthorAndSlugIndexed($pairs);
$entries = [];
$resolved = 0;
$missing = 0;
foreach ($coords as $coordinate) {
$parts = explode(':', $coordinate, 3);
if (\count($parts) < 3) {
$entries[] = ['coordinate' => $coordinate, 'status' => 'missing', 'reason' => 'malformed_coordinate'];
$missing++;
continue;
}
$kind = (int) ($parts[0] ?? 0);
if (!\in_array($kind, [30023, 30024], true)) {
$entries[] = ['coordinate' => $coordinate, 'status' => 'missing', 'reason' => 'unsupported_kind'];
$missing++;
continue;
}
$pub = strtolower(trim((string) $parts[1]));
$d = trim((string) $parts[2]);
if (64 !== \strlen($pub) || !ctype_xdigit($pub)) {
$entries[] = ['coordinate' => $coordinate, 'status' => 'missing', 'reason' => 'invalid_pubkey'];
$missing++;
continue;
}
if ($d === '') {
$entries[] = ['coordinate' => $coordinate, 'status' => 'missing', 'reason' => 'empty_identifier'];
$missing++;
continue;
}
$k = $pub."\0".$d;
if (!isset($byAddress[$k])) {
$entries[] = ['coordinate' => $coordinate, 'status' => 'missing', 'reason' => 'article_not_in_db'];
$missing++;
continue;
}
$article = $byAddress[$k];
$entries[] = [
'coordinate' => $coordinate,
'status' => 'resolved',
'reason' => 'ok',
'article_title' => (string) ($article->getTitle() ?? ''),
'article_slug' => (string) ($article->getSlug() ?? ''),
];
$resolved++;
}
$listed = \count($coords);
$totListed += $listed;
$totResolved += $resolved;
$totMissing += $missing;
$categories[] = [
'slug' => $slug,
'title' => $title,
'event_id' => $catIndex->getId(),
'listed_total' => $listed,
'resolved_total' => $resolved,
'missing_total' => $missing,
'entries' => $entries,
];
}
return [
'categories' => $categories,
'totals' => [
'categories' => \count($categories),
'listed' => $totListed,
'resolved' => $totResolved,
'missing' => $totMissing,
],
];
}
/**
* @param array{
* categories: list<array{
* entries: list<array{coordinate: string, status: string, reason: string}>
* }>
* } $report
* @return list<string>
*/
public function missingInDbCoordinatesFromCoverageReport(array $report): array
{
$out = [];
foreach ($report['categories'] ?? [] as $cat) {
foreach ($cat['entries'] ?? [] as $entry) {
if (($entry['status'] ?? '') !== 'missing') {
continue;
}
if (($entry['reason'] ?? '') !== 'article_not_in_db') {
continue;
}
$coord = isset($entry['coordinate']) ? (string) $entry['coordinate'] : '';
if ($coord !== '') {
$out[] = $coord;
}
}
}
return array_values(array_unique($out));
}
/** /**
* @return list<string> Nostr coordinates kind:pubkey:identifier * @return list<string> Nostr coordinates kind:pubkey:identifier
*/ */

32
src/Service/NostrClient.php

@ -918,15 +918,33 @@ class NostrClient
public function publishEvent(Event $event, array $relays): array public function publishEvent(Event $event, array $relays): array
{ {
$eventMessage = new EventMessage($event); $eventMessage = new EventMessage($event);
$relaySet = new RelaySet(); $results = [];
foreach ($relays as $relayWss) { foreach ($relays as $relayWss) {
$relay = new Relay($relayWss); if (!\is_string($relayWss) || $relayWss === '') {
$relaySet->addRelay($relay); continue;
}
try {
$relaySet = new RelaySet();
$relaySet->addRelay(new Relay($relayWss));
$relaySet->setMessage($eventMessage);
$this->applyRelaySocketTimeoutToSet($relaySet);
$sent = $relaySet->send();
if (\is_array($sent) && \array_key_exists($relayWss, $sent)) {
$results[$relayWss] = $sent[$relayWss];
} else {
$results[$relayWss] = $sent;
}
} catch (\Throwable $e) {
$this->logger->warning('nostr.publish.relay_failed', [
'relay' => $relayWss,
'error' => $e->getMessage(),
'exception_class' => \get_class($e),
]);
$results[$relayWss] = $e;
}
} }
$relaySet->setMessage($eventMessage);
$this->applyRelaySocketTimeoutToSet($relaySet); return $results;
// TODO handle responses appropriately
return $relaySet->send();
} }
/** /**

45
templates/pages/featured_authors.html.twig

@ -5,7 +5,7 @@
<header class="featured-authors__intro"> <header class="featured-authors__intro">
<h1>Featured authors</h1> <h1>Featured authors</h1>
<p class="text-subtle"> <p class="text-subtle">
Authors whose long-form has been placed in a magazine category receive a Authors are derived from pubkeys referenced by category index <code>a</code> tags and receive a
<abbr title="NIP-05">NIP-05</abbr> identifier <abbr title="NIP-05">NIP-05</abbr> identifier
{% if nip05_domain|default('')|trim != '' %} {% if nip05_domain|default('')|trim != '' %}
under <strong>{{ nip05_domain|e }}</strong> under <strong>{{ nip05_domain|e }}</strong>
@ -14,27 +14,32 @@
</p> </p>
</header> </header>
{% for row in authors %} {% if authors is not empty %}
{% set _fa_label = row.author.name|default('')|trim != '' ? row.author.name : (row.npub|shortenNpub) %} <div class="featured-authors-grid" role="list">
<article class="featured-authors__card" aria-label="{{ _fa_label|e('html_attr') }}"> {% for row in authors %}
<div class="author-profile author-profile--featured"> {% set _name = row.display_name|default('')|trim %}
{% include 'partial/author_profile_header.html.twig' with { {% set _label = _name != '' ? _name : (row.npub|shortenNpub) %}
author: row.author, <a
npub: row.npub, class="featured-authors-grid__card"
header_tag: 'h2', href="{{ path('author-profile', { npub: row.npub }) }}"
show_nip05: false, aria-label="Open profile for {{ _label|e('html_attr') }}"
profile_nip05: [], role="listitem"
profile_websites: row.profile_websites, >
profile_payment_links: row.profile_payment_links, <div class="featured-authors-grid__avatar">
} only %} {% if row.picture|default('')|trim != '' %}
</div> <img src="{{ row.picture|e('html_attr') }}" alt="{{ _label|e('html_attr') }}">
<div class="featured-authors__actions"> {% else %}
<a class="btn btn-secondary" href="{{ path('author-profile', { npub: row.npub }) }}">Full profile</a> <span class="featured-authors-grid__avatar-fallback">{{ _label|slice(0, 1)|upper }}</span>
</div> {% endif %}
</article> </div>
<div class="featured-authors-grid__name">{{ _label }}</div>
<div class="featured-authors-grid__handle text-subtle">@{{ row.local_part|e }}</div>
</a>
{% endfor %}
</div>
{% else %} {% else %}
<p class="text-subtle">No featured authors are listed yet. They appear when authors are added to magazine category indices and synced.</p> <p class="text-subtle">No featured authors are listed yet. They appear when authors are added to magazine category indices and synced.</p>
{% endfor %} {% endif %}
{% if pagination is defined and pagination.last_page > 1 %} {% if pagination is defined and pagination.last_page > 1 %}
{% set _page = pagination.page|default(1) %} {% set _page = pagination.page|default(1) %}

Loading…
Cancel
Save