diff --git a/assets/controllers/article_comments_controller.js b/assets/controllers/article_comments_controller.js
index c9000c0..cb1dc5a 100644
--- a/assets/controllers/article_comments_controller.js
+++ b/assets/controllers/article_comments_controller.js
@@ -18,14 +18,9 @@ export default class extends Controller {
return;
}
if (this.preloadedValue) {
- const run = () => {
- void this.load();
- };
- if (typeof requestIdleCallback !== 'undefined') {
- requestIdleCallback(run, { timeout: 8_000 });
- } else {
- setTimeout(run, 800);
- }
+ // Article SSR already included comments. Do not re-fetch: a slow or dropped
+ // request would replace working HTML with a generic error. Re-fetch on auth
+ // only (reply UI may need fresh permission state).
return;
}
void this.load();
@@ -44,22 +39,41 @@ export default class extends Controller {
async load() {
const t0 = performance.now();
- try {
- const res = await fetch(this.urlValue, {
- headers: { Accept: 'text/html', 'X-Requested-With': 'XMLHttpRequest' },
- });
- if (!res.ok) {
- throw new Error(`HTTP ${res.status}`);
+ const perAttemptMs = 45_000;
+ const maxAttempts = 3;
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
+ const controller = new AbortController();
+ const timer = window.setTimeout(() => controller.abort(), perAttemptMs);
+ try {
+ const res = await fetch(this.urlValue, {
+ signal: controller.signal,
+ headers: { Accept: 'text/html', 'X-Requested-With': 'XMLHttpRequest' },
+ });
+ if (!res.ok) {
+ throw new Error(`HTTP ${res.status}`);
+ }
+ const html = await res.text();
+ this.containerTarget.innerHTML = html;
+ const ms = Math.round(performance.now() - t0);
+ if (attempt > 1) {
+ console.info(`[article-comments] fragment OK in ${ms}ms (after ${attempt} attempts)`, this.urlValue);
+ } else {
+ console.info(`[article-comments] fragment OK in ${ms}ms`, this.urlValue);
+ }
+ window.clearTimeout(timer);
+ return;
+ } catch (err) {
+ window.clearTimeout(timer);
+ if (attempt < maxAttempts) {
+ const delay = 1_200 * 2 ** (attempt - 1);
+ await new Promise((r) => setTimeout(r, delay));
+ continue;
+ }
+ const ms = Math.round(performance.now() - t0);
+ console.warn(`[article-comments] fragment failed after ${ms}ms`, this.urlValue, err);
+ this.containerTarget.innerHTML =
+ '
Comments could not be loaded.
';
}
- const html = await res.text();
- this.containerTarget.innerHTML = html;
- const ms = Math.round(performance.now() - t0);
- console.info(`[article-comments] fragment OK in ${ms}ms`, this.urlValue);
- } catch (err) {
- const ms = Math.round(performance.now() - t0);
- console.warn(`[article-comments] fragment failed after ${ms}ms`, this.urlValue, err);
- this.containerTarget.innerHTML =
- 'Comments could not be loaded.
';
}
}
}
diff --git a/src/Command/PrewarmCommand.php b/src/Command/PrewarmCommand.php
index 30b10a1..f868531 100644
--- a/src/Command/PrewarmCommand.php
+++ b/src/Command/PrewarmCommand.php
@@ -17,11 +17,13 @@ use Psr\Log\LoggerInterface;
use swentel\nostr\Key\Key;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Helper\Helper;
use Symfony\Component\Console\Helper\ProgressBar;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
+use Symfony\Component\Console\Terminal;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
/**
@@ -363,6 +365,27 @@ final class PrewarmCommand extends Command
$bar->setFormat(' %current%/%max% [%bar%] %percent:3s%%'."\n".' %message% %elapsed:6s% ');
$bar->setMessage($message);
+ // Long %message% lines (e.g. category slugs) wider than the terminal make Symfony’s ProgressBar
+ // shrink/expand the bar on every redraw; truncate so each line fits and the bar stays stable
+ // and can use the full width to the right.
+ $tw = (new Terminal())->getWidth();
+ if ($tw < 40) {
+ $tw = 80;
+ }
+ $messageMaxWidth = max(12, $tw - 18);
+ $bar->setPlaceholderFormatter('message', function (ProgressBar $b) use ($messageMaxWidth): string {
+ $m = (string) ($b->getMessage() ?? '');
+ if ($m === '') {
+ return '';
+ }
+ if (Helper::width($m) > $messageMaxWidth) {
+ return Helper::substr($m, 0, max(1, $messageMaxWidth - 1)).'…';
+ }
+
+ return $m;
+ });
+ $bar->setBarWidth(max(20, $tw - 32));
+
return $bar;
}
diff --git a/src/Entity/FeaturedAuthor.php b/src/Entity/FeaturedAuthor.php
index b183d8d..7b4a1d4 100644
--- a/src/Entity/FeaturedAuthor.php
+++ b/src/Entity/FeaturedAuthor.php
@@ -17,7 +17,7 @@ use Doctrine\ORM\Mapping as ORM;
class FeaturedAuthor
{
#[ORM\Id]
- #[ORM\GeneratedValue]
+ #[ORM\GeneratedValue(strategy: 'IDENTITY')]
#[ORM\Column]
private ?int $id = null;
diff --git a/src/Service/NostrClient.php b/src/Service/NostrClient.php
index 4467d52..229b366 100644
--- a/src/Service/NostrClient.php
+++ b/src/Service/NostrClient.php
@@ -33,6 +33,18 @@ class NostrClient
/** Extra wall time for {@see bin/nostr_relay_request_worker.php} process vs. WebSocket timeout. */
private const DISCUSSION_WORKER_GRACE_SEC = 5.0;
+ /**
+ * Hard cap on unique relay URLs for article discussion. More relays do not help much (indexers duplicate)
+ * but blow up wall time when we fall back to sequential in-process {@see Request::send()}.
+ */
+ private const MAX_DISCUSSION_RELAY_URLS = 10;
+
+ /**
+ * {@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.
+ */
+ private const MAX_SEQUENTIAL_RELAY_URLS = 3;
+
/** When a logged-in user lists this relay, also use {@see self::AGGR_NOSTR_LAND} for comment + profile reads. */
private const NOSTR_LAND = 'wss://nostr.land';
@@ -972,6 +984,12 @@ class NostrClient
array_merge($baseForDiscussion, $authorRelays)
);
$plannedRelayUrls = array_values(array_unique($mergedForDiscussion, \SORT_REGULAR));
+ if (\count($plannedRelayUrls) > self::MAX_DISCUSSION_RELAY_URLS) {
+ $plannedRelayUrls = \array_slice($plannedRelayUrls, 0, self::MAX_DISCUSSION_RELAY_URLS);
+ $this->logger->notice('nostr.article_discussion.relay_list_capped', [
+ 'max' => self::MAX_DISCUSSION_RELAY_URLS,
+ ]);
+ }
$filters = $this->createArticleDiscussionFilters($coordinate, $rootEventHexId);
$subscription = new Subscription();
@@ -990,7 +1008,8 @@ class NostrClient
$tSend = microtime(true);
$workerPath = $this->projectDir.'/bin/nostr_relay_request_worker.php';
if (!\is_file($workerPath) || \count($plannedRelayUrls) <= 1) {
- $response = $this->sendArticleDiscussionToRelaysSequential($plannedRelayUrls, $requestMessage);
+ $forSeq = $this->capRelayUrlsForSequentialPath($plannedRelayUrls);
+ $response = $this->sendArticleDiscussionToRelaysSequential($forSeq, $requestMessage);
} else {
try {
$response = $this->sendArticleDiscussionToRelaysParallel($plannedRelayUrls, $requestMessage);
@@ -999,7 +1018,11 @@ class NostrClient
'message' => $e->getMessage(),
'exception_class' => \get_class($e),
]);
- $response = $this->sendArticleDiscussionToRelaysSequential($plannedRelayUrls, $requestMessage);
+ $forSeq = $this->capRelayUrlsForSequentialPath($plannedRelayUrls);
+ $this->logger->warning('nostr.article_discussion.sequential_fallback', [
+ 'relays' => $forSeq,
+ ]);
+ $response = $this->sendArticleDiscussionToRelaysSequential($forSeq, $requestMessage);
}
}
$sendMs = (int) round((microtime(true) - $tSend) * 1000);
@@ -1084,6 +1107,24 @@ class NostrClient
return ['thread' => $thread, 'quotes' => $quotes];
}
+ /**
+ * @param list $relayUrls
+ *
+ * @return list
+ */
+ private function capRelayUrlsForSequentialPath(array $relayUrls): array
+ {
+ if (\count($relayUrls) <= self::MAX_SEQUENTIAL_RELAY_URLS) {
+ return $relayUrls;
+ }
+ $this->logger->notice('nostr.article_discussion.sequential_relay_cap', [
+ 'used' => self::MAX_SEQUENTIAL_RELAY_URLS,
+ 'had' => \count($relayUrls),
+ ]);
+
+ return \array_values(\array_slice($relayUrls, 0, self::MAX_SEQUENTIAL_RELAY_URLS));
+ }
+
/**
* One {@see Request} over all relays (library visits each wss:// in series).
*