Browse Source

bug-fixes

imwald
Silberengel 3 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 { @@ -109,10 +109,23 @@ export default class extends Controller {
}
const data = await res.json().catch(() => ({}));
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;
}
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) {
ta.value = '';
}
@ -203,4 +216,20 @@ export default class extends Controller { @@ -203,4 +216,20 @@ export default class extends Controller {
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 { @@ -957,16 +957,17 @@ a:focus-visible {
.pager {
margin-top: 1.25rem;
display: flex;
justify-content: center;
width: 100%;
box-sizing: border-box;
}
.pager__inner {
width: min(100%, 36rem);
width: 100%;
max-width: 100%;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border);
@ -974,7 +975,8 @@ a:focus-visible { @@ -974,7 +975,8 @@ a:focus-visible {
}
.pager__status {
min-width: 8rem;
flex: 1 1 auto;
min-width: 0;
text-align: center;
}
@ -990,14 +992,12 @@ a:focus-visible { @@ -990,14 +992,12 @@ a:focus-visible {
@media (max-width: 640px) {
.pager__inner {
width: 100%;
gap: 0.5rem;
}
.pager__btn {
min-width: 5.25rem;
}
.pager__status {
min-width: 7rem;
font-size: 0.92rem;
}
}

32
assets/styles/article.css

@ -338,3 +338,35 @@ blockquote p { @@ -338,3 +338,35 @@ blockquote p {
font-size: 0.9rem;
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 @@ @@ -22,7 +22,8 @@
flex-grow: 1;
}
nav {
/* Only the app chrome sidebar — not <nav> in main (pagination) or footer. */
.layout > nav {
width: 21vw;
min-width: 150px;
max-width: 280px;
@ -31,21 +32,21 @@ nav { @@ -31,21 +32,21 @@ nav {
overflow-y: auto; /* Ensure the menu is scrollable if content is too long */
}
nav ul {
.layout > nav ul {
list-style-type: none;
padding: 0;
}
nav li {
.layout > nav li {
margin: 0.5em 0;
}
nav a {
.layout > nav a {
color: var(--color-primary);
text-decoration: none;
}
nav a:hover {
.layout > nav a:hover {
color: var(--color-text-mid);
text-decoration: none;
}
@ -642,6 +643,71 @@ footer .footer-links { @@ -642,6 +643,71 @@ footer .footer-links {
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 {
margin-bottom: 2rem;
overflow: visible; /* do not clip heading ascenders */

103
src/Command/PrewarmCommand.php

@ -174,15 +174,6 @@ final class PrewarmCommand extends Command @@ -174,15 +174,6 @@ final class PrewarmCommand extends Command
}
} else {
$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)');
@ -193,11 +184,41 @@ final class PrewarmCommand extends Command @@ -193,11 +184,41 @@ final class PrewarmCommand extends Command
} else {
$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) {
$this->logger->error('app:prewarm longform ingest failed', ['e' => $e]);
$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.
$this->disableCliExecutionTimeLimit();
@ -556,4 +577,68 @@ final class PrewarmCommand extends Command @@ -556,4 +577,68 @@ final class PrewarmCommand extends Command
@set_time_limit(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 @@ -54,7 +54,12 @@ final class CommentReplyController extends AbstractController
$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 */

22
src/Controller/FeaturedAuthorsController.php

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

4
src/Service/ArticleCommentThreadLoader.php

@ -379,7 +379,9 @@ final readonly class ArticleCommentThreadLoader @@ -379,7 +379,9 @@ final readonly class ArticleCommentThreadLoader
continue;
}
$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;
}
$coord = (string) $row[1];

26
src/Service/CommentReplyService.php

@ -26,7 +26,7 @@ final readonly class CommentReplyService @@ -26,7 +26,7 @@ final readonly class CommentReplyService
/**
* @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
{
@ -108,12 +108,34 @@ final readonly class CommentReplyService @@ -108,12 +108,34 @@ final readonly class CommentReplyService
$relays = $this->nostrClient->getRelayUrlsForCommentPublish($expectedCoordinate, $parentAuthorHex);
$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', [
'id' => $wire->getId(),
'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; @@ -11,8 +11,8 @@ use Psr\Log\LoggerInterface;
use swentel\nostr\Key\Key;
/**
* Adds {@see FeaturedAuthor} rows for pubkeys found in magazine category indices; assigns
* unique NIP-05 local-parts from kind-0 name when possible. Does not remove or re-list rows.
* Reconciles {@see FeaturedAuthor} rows with pubkeys found in magazine category `a` tags.
* The listed set is derived from current category indices during prewarm.
*/
final class FeaturedAuthorSync
{
@ -26,34 +26,87 @@ 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();
if ($pubkeys === []) {
return 0;
$target = [];
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();
$n = 0;
foreach ($pubkeys as $hex) {
if ($this->featuredAuthorRepository->findOneByPubkeyHex($hex) !== null) {
$added = 0;
$relisted = 0;
$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;
}
$entity = new FeaturedAuthor();
$entity->setPubkeyHex($hex);
$base = $this->deriveBaseLocalPart($keys, $hex);
$entity->setLocalPart($this->allocateUniqueLocalPart($base));
$this->entityManager->persist($entity);
++$n;
if ($row->isListed()) {
$row->setIsListed(false);
$unlisted++;
$changed = true;
}
}
if ($n > 0) {
if ($changed) {
$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

168
src/Service/MagazineContentService.php

@ -301,6 +301,174 @@ final class MagazineContentService @@ -301,6 +301,174 @@ final class MagazineContentService
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
*/

32
src/Service/NostrClient.php

@ -918,15 +918,33 @@ class NostrClient @@ -918,15 +918,33 @@ class NostrClient
public function publishEvent(Event $event, array $relays): array
{
$eventMessage = new EventMessage($event);
$relaySet = new RelaySet();
$results = [];
foreach ($relays as $relayWss) {
$relay = new Relay($relayWss);
$relaySet->addRelay($relay);
if (!\is_string($relayWss) || $relayWss === '') {
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);
// TODO handle responses appropriately
return $relaySet->send();
return $results;
}
/**

45
templates/pages/featured_authors.html.twig

@ -5,7 +5,7 @@ @@ -5,7 +5,7 @@
<header class="featured-authors__intro">
<h1>Featured authors</h1>
<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
{% if nip05_domain|default('')|trim != '' %}
under <strong>{{ nip05_domain|e }}</strong>
@ -14,27 +14,32 @@ @@ -14,27 +14,32 @@
</p>
</header>
{% for row in authors %}
{% set _fa_label = row.author.name|default('')|trim != '' ? row.author.name : (row.npub|shortenNpub) %}
<article class="featured-authors__card" aria-label="{{ _fa_label|e('html_attr') }}">
<div class="author-profile author-profile--featured">
{% include 'partial/author_profile_header.html.twig' with {
author: row.author,
npub: row.npub,
header_tag: 'h2',
show_nip05: false,
profile_nip05: [],
profile_websites: row.profile_websites,
profile_payment_links: row.profile_payment_links,
} only %}
</div>
<div class="featured-authors__actions">
<a class="btn btn-secondary" href="{{ path('author-profile', { npub: row.npub }) }}">Full profile</a>
</div>
</article>
{% if authors is not empty %}
<div class="featured-authors-grid" role="list">
{% for row in authors %}
{% set _name = row.display_name|default('')|trim %}
{% set _label = _name != '' ? _name : (row.npub|shortenNpub) %}
<a
class="featured-authors-grid__card"
href="{{ path('author-profile', { npub: row.npub }) }}"
aria-label="Open profile for {{ _label|e('html_attr') }}"
role="listitem"
>
<div class="featured-authors-grid__avatar">
{% if row.picture|default('')|trim != '' %}
<img src="{{ row.picture|e('html_attr') }}" alt="{{ _label|e('html_attr') }}">
{% else %}
<span class="featured-authors-grid__avatar-fallback">{{ _label|slice(0, 1)|upper }}</span>
{% endif %}
</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 %}
<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 %}
{% set _page = pagination.page|default(1) %}

Loading…
Cancel
Save