diff --git a/assets/bootstrap.js b/assets/bootstrap.js
index b85d1d0..306468d 100644
--- a/assets/bootstrap.js
+++ b/assets/bootstrap.js
@@ -1,7 +1,6 @@
import { startStimulusApp } from '@symfony/stimulus-bundle';
import ArticleCommentsController from './controllers/article_comments_controller.js';
import CommentReplyController from './controllers/comment_reply_controller.js';
-import MagazineSyncController from './controllers/magazine_sync_controller.js';
const app = startStimulusApp();
@@ -11,11 +10,6 @@ try {
} catch {
/* already registered by the bundle */
}
-try {
- app.register('magazine-sync', MagazineSyncController);
-} catch {
- /* already registered by the bundle */
-}
try {
app.register('comment-reply', CommentReplyController);
} catch {
diff --git a/assets/controllers/magazine_sync_controller.js b/assets/controllers/magazine_sync_controller.js
deleted file mode 100644
index 634a112..0000000
--- a/assets/controllers/magazine_sync_controller.js
+++ /dev/null
@@ -1,49 +0,0 @@
-import { Controller } from "@hotwired/stimulus";
-
-/**
- * After first paint, refreshes Nostr magazine indices (server-side, ≤5s) and swaps header/body HTML.
- */
-export default class extends Controller {
- static targets = ["headerNav", "pageBody"];
- static values = {
- page: String,
- slug: String,
- url: String,
- };
-
- connect() {
- this.sync();
- }
-
- async sync() {
- const base = this.urlValue || "/ux/magazine-sync";
- const params = new URLSearchParams();
- params.set("page", this.pageValue || "article");
- const slug = this.slugValue || "";
- if (slug !== "") {
- params.set("slug", slug);
- }
- const url = `${base}?${params.toString()}`;
- try {
- const res = await fetch(url, {
- headers: { Accept: "application/json" },
- credentials: "same-origin",
- });
- if (!res.ok) {
- return;
- }
- const data = await res.json();
- if (!data.ok) {
- return;
- }
- if (this.hasHeaderNavTarget && data.header) {
- this.headerNavTarget.outerHTML = data.header;
- }
- if (this.hasPageBodyTarget && data.body) {
- this.pageBodyTarget.outerHTML = data.body;
- }
- } catch {
- /* ignore network errors */
- }
- }
-}
diff --git a/assets/controllers/progress_bar_controller.js b/assets/controllers/progress_bar_controller.js
index eb37deb..43dbcd5 100644
--- a/assets/controllers/progress_bar_controller.js
+++ b/assets/controllers/progress_bar_controller.js
@@ -1,20 +1,84 @@
-// assets/controllers/progress_bar_controller.js
-import { Controller } from "@hotwired/stimulus";
+// Top-of-page progress: indeterminate while navigating; completes on the next page’s load.
+import { Controller } from '@hotwired/stimulus';
+
+const STORAGE_KEY = 'unfold_pb';
export default class extends Controller {
- static targets = ["bar"];
+ static targets = ['bar'];
connect() {
this.boundHandleInteraction = this.handleInteraction.bind(this);
- document.addEventListener("click", this.boundHandleInteraction);
- document.addEventListener("touchstart", this.handleTouchStart);
- document.addEventListener("touchend", this.handleTouchEnd);
+ this.boundPageShow = this.onPageShow.bind(this);
+ document.addEventListener('click', this.boundHandleInteraction);
+ document.addEventListener('touchstart', this.handleTouchStart);
+ document.addEventListener('touchend', this.handleTouchEnd);
+ window.addEventListener('pageshow', this.boundPageShow);
+ this.resumeIfPending();
}
disconnect() {
- document.removeEventListener("click", this.boundHandleInteraction);
- document.removeEventListener("touchstart", this.handleTouchStart);
- document.removeEventListener("touchend", this.handleTouchEnd);
+ document.removeEventListener('click', this.boundHandleInteraction);
+ document.removeEventListener('touchstart', this.handleTouchStart);
+ document.removeEventListener('touchend', this.handleTouchEnd);
+ window.removeEventListener('pageshow', this.boundPageShow);
+ if (this.loadListener) {
+ window.removeEventListener('load', this.loadListener);
+ this.loadListener = null;
+ }
+ }
+
+ onPageShow(event) {
+ if (event.persisted) {
+ sessionStorage.removeItem(STORAGE_KEY);
+ this.resetBar();
+ }
+ }
+
+ /**
+ * After a same-tab navigation, finish the bar as soon as the new document is fully loaded
+ * (or immediately if the load event already happened).
+ */
+ resumeIfPending() {
+ if (sessionStorage.getItem(STORAGE_KEY) !== '1' || !this.hasBarTarget) {
+ return;
+ }
+ this.barTarget.classList.add('pb-indeterminate');
+ const finish = () => {
+ this.completeToDone();
+ };
+ if (document.readyState === 'complete') {
+ requestAnimationFrame(finish);
+ } else {
+ this.loadListener = finish;
+ window.addEventListener('load', finish, { once: true });
+ }
+ }
+
+ completeToDone() {
+ if (sessionStorage.getItem(STORAGE_KEY) !== '1' || !this.hasBarTarget) {
+ return;
+ }
+ if (this.loadListener) {
+ window.removeEventListener('load', this.loadListener);
+ this.loadListener = null;
+ }
+ this.barTarget.classList.remove('pb-indeterminate');
+ this.barTarget.style.transition = 'width 0.18s ease-out';
+ this.barTarget.style.width = '100%';
+ window.setTimeout(() => {
+ this.barTarget.style.transition = 'none';
+ this.barTarget.style.width = '0';
+ this.barTarget.style.removeProperty('transition');
+ sessionStorage.removeItem(STORAGE_KEY);
+ }, 220);
+ }
+
+ resetBar() {
+ if (!this.hasBarTarget) {
+ return;
+ }
+ this.barTarget.classList.remove('pb-indeterminate');
+ this.barTarget.style.width = '0';
}
handleTouchStart = (event) => {
@@ -33,19 +97,46 @@ export default class extends Controller {
};
handleInteraction(event) {
- const link = event.target.closest("a");
- if (link && !link.hasAttribute("data-no-progress") &&
- !event.ctrlKey && !event.metaKey && !event.shiftKey) {
- this.start();
+ const link = event.target.closest('a');
+ if (!link || link.hasAttribute('data-no-progress')) {
+ return;
+ }
+ if (event.ctrlKey || event.metaKey || event.shiftKey) {
+ return;
+ }
+ const t = link.getAttribute('target');
+ if (t && t !== '' && t !== '_self') {
+ return;
}
+ if (link.hasAttribute('download')) {
+ return;
+ }
+ const href = link.getAttribute('href');
+ if (!href || href.startsWith('mailto:') || href.startsWith('tel:') || href.startsWith('javascript:')) {
+ return;
+ }
+ let url;
+ try {
+ url = new URL(href, window.location.href);
+ } catch {
+ return;
+ }
+ if (url.origin !== window.location.origin) {
+ return;
+ }
+ if (url.href.split('#')[0] === window.location.href.split('#')[0] && url.hash) {
+ return;
+ }
+ this.start();
}
start() {
- this.barTarget.style.width = "0";
- this.barTarget.style.transition = "none";
- setTimeout(() => {
- this.barTarget.style.transition = "width 5s ease-in-out";
- this.barTarget.style.width = "100%";
- }, 10);
+ if (!this.hasBarTarget) {
+ return;
+ }
+ sessionStorage.setItem(STORAGE_KEY, '1');
+ this.barTarget.style.transition = 'none';
+ this.barTarget.style.width = '0';
+ this.barTarget.classList.add('pb-indeterminate');
}
}
diff --git a/assets/styles/layout.css b/assets/styles/layout.css
index d58eb24..9e12989 100644
--- a/assets/styles/layout.css
+++ b/assets/styles/layout.css
@@ -84,11 +84,32 @@ header {
bottom: 0;
height: 4px;
width: 0;
+ transform-origin: left center;
background: var(--color-primary);
transition: width 0.4s ease;
z-index: 1000;
}
+/* In-flight navigation: loops until the next page fires `load`, then the bar completes. */
+#progress-bar.pb-indeterminate {
+ transition: none;
+ animation: pb-indeterminate 0.9s ease-in-out infinite;
+}
+
+@keyframes pb-indeterminate {
+ 0% {
+ width: 20%;
+ }
+
+ 50% {
+ width: 55%;
+ }
+
+ 100% {
+ width: 28%;
+ }
+}
+
/* Mobile Styles */
@media (max-width: 1024px) {
.header__logo {
diff --git a/docker/cron/README.md b/docker/cron/README.md
index ba20824..65ca204 100644
--- a/docker/cron/README.md
+++ b/docker/cron/README.md
@@ -1,6 +1,6 @@
# `cron` service (Docker)
-The `cron` image runs a single job: **`php bin/console app:prewarm` every 10 minutes**, against the app tree bind-mounted at `/var/www/html`.
+The `cron` image runs a single job: **`php bin/console app:prewarm` every 10 minutes**, against the app tree bind-mounted at `/var/www/html`. Magazine **30040** indices, **MySQL backfill** for category `a` long-form rows, profile metadata, and comment cache are updated here (or by running `app:prewarm` manually)—not from a browser request.
- **Flags:** set **`PREWARM_FLAGS`** in the project `.env` (Compose injects it). Example: `PREWARM_FLAGS="--metadata-limit=30 --no-magazine"`. After editing, run `docker compose up -d --force-recreate cron` (or `docker compose up -d cron`) so the container gets the new value. If unset, `app:prewarm` uses its **built-in defaults** (same idea as running the console with no args).
diff --git a/src/Command/PrewarmCommand.php b/src/Command/PrewarmCommand.php
index 62f8e2b..71aa5ff 100644
--- a/src/Command/PrewarmCommand.php
+++ b/src/Command/PrewarmCommand.php
@@ -117,6 +117,19 @@ final class PrewarmCommand extends Command
$io->note('Skipping magazine (--no-magazine).');
}
+ $io->section('Long-form in DB (category `a` tags missing from MySQL)');
+ try {
+ $n = $this->magazineContent->ingestMissingLongformForAllMagazineCategories();
+ if ($n === 0) {
+ $io->note('No missing long-form rows for category `a` coordinates (or empty magazine store).');
+ } else {
+ $io->writeln(sprintf('Fetched or attempted ingest for %d missing coordinate(s).', $n));
+ }
+ } catch (\Throwable $e) {
+ $this->logger->error('app:prewarm longform ingest failed', ['e' => $e]);
+ $io->warning('Long-form backfill failed: '.$e->getMessage());
+ }
+
// MagazineRefresher sets max_execution_time (e.g. 60 for budget 30); restore before metadata.
$this->disableCliExecutionTimeLimit();
diff --git a/src/Controller/DefaultController.php b/src/Controller/DefaultController.php
index 611ea6c..e4a2cdd 100644
--- a/src/Controller/DefaultController.php
+++ b/src/Controller/DefaultController.php
@@ -22,7 +22,7 @@ class DefaultController extends AbstractController
public function index(): Response
{
return $this->render('home.html.twig', [
- 'indices' => $this->magazineContent->getHomeCategoryIndexTags(),
+ 'indices' => $this->magazineContent->getHomeCategoryAIndexTagsFromStoreOnly(),
]);
}
diff --git a/src/Controller/MagazineSyncController.php b/src/Controller/MagazineSyncController.php
deleted file mode 100644
index 835492a..0000000
--- a/src/Controller/MagazineSyncController.php
+++ /dev/null
@@ -1,103 +0,0 @@
-query->get('page', 'article');
- if (!\in_array($page, ['home', 'category', 'article', 'articles'], true)) {
- $page = 'article';
- }
- $slug = (string) $request->query->get('slug', '');
-
- $prefer = $slug !== '' ? [$slug] : [];
-
- try {
- $this->refresher->refreshFromRelays(20, $prefer);
- } catch (\Throwable $e) {
- $this->logger->warning('MagazineSyncController: refresh failed', [
- 'message' => $e->getMessage(),
- 'exception' => $e,
- ]);
-
- return new JsonResponse(
- ['ok' => false, 'error' => 'refresh_failed', 'message' => $e->getMessage()],
- Response::HTTP_OK
- );
- }
-
- $community = (bool) $this->params->get('community_articles');
- $tags = $this->magazineContent->getHomeCategoryAIndexTagsFromStoreOnly();
- $globals = [
- 'magazine_community_articles' => $community,
- ];
-
- $header = $this->twig->render('ux/magazine/header_ul.html.twig', array_merge($globals, [
- 'cats' => $tags,
- ]));
-
- $body = null;
- if ($page === 'home') {
- $body = $this->twig->render('ux/magazine/home_body.html.twig', array_merge($globals, [
- 'indices' => $tags,
- ]));
- } elseif ($page === 'category' && $slug !== '') {
- $data = $this->magazineContent->getCategoryPageData($slug);
- $body = $this->twig->render('ux/magazine/category_body.html.twig', array_merge($globals, [
- 'list' => $data['list'],
- 'category' => $data['category'],
- ]));
- } elseif ($page === 'articles') {
- $body = null;
- }
-
- return new JsonResponse([
- 'ok' => true,
- 'header' => $header,
- 'body' => $body,
- ]);
- } catch (\Throwable $e) {
- $this->logger->error('MagazineSyncController: unexpected failure', [
- 'message' => $e->getMessage(),
- 'exception' => $e,
- ]);
-
- return new JsonResponse(
- [
- 'ok' => false,
- 'error' => 'server_error',
- 'message' => 'Magazine UI sync could not be rendered.',
- ],
- Response::HTTP_OK
- );
- }
- }
-}
diff --git a/src/Service/CacheService.php b/src/Service/CacheService.php
index eb3c56c..4b1d1e0 100644
--- a/src/Service/CacheService.php
+++ b/src/Service/CacheService.php
@@ -35,8 +35,9 @@ readonly class CacheService
*/
public function getMetadataBundle(string $npub): array
{
- $aggr = $this->nostrClient->getNostrLandAggrReaderCacheSuffix();
- $cacheKey = $aggr === '' ? '0_'.$npub : '0_'.$aggr.'_'.$npub;
+ // One key per author: do not split on Nostr.Land / aggr (see comment thread cache). Otherwise
+ // prewarm and anonymous hits do not match logged-in readers → cold Nostr on every article view.
+ $cacheKey = '0_'.$npub;
try {
$cached = $this->cache->get($cacheKey, function (ItemInterface $item) use ($npub) {
$item->expiresAfter(3600); // 1 hour, adjust as needed
diff --git a/src/Service/MagazineContentService.php b/src/Service/MagazineContentService.php
index d7f160c..623f187 100644
--- a/src/Service/MagazineContentService.php
+++ b/src/Service/MagazineContentService.php
@@ -11,18 +11,13 @@ use App\Repository\ArticleRepository;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
/**
- * Magazine index events for templates. Reads {@see MagazineIndexStore} first; on a cold cache or when
- * the last successful relay sync is older than {@see self::ROOT_REVALIDATE_SECONDS}, the service
- * calls {@see MagazineRefresher} so the root index (and nav) can pick up new categories.
+ * Magazine index for templates. Reads {@see MagazineIndexStore} only on HTTP; relay refresh and DB
+ * backfill for category long-form are done by `app:prewarm` (cron) / CLI.
*/
final class MagazineContentService
{
- /** Re-fetch root from relays at most this often so new `a` tags appear in the header. */
- private const ROOT_REVALIDATE_SECONDS = 300;
-
public function __construct(
private readonly MagazineIndexStore $store,
- private readonly MagazineRefresher $refresher,
private readonly ParameterBagInterface $params,
private readonly ArticleRepository $articleRepository,
private readonly NostrClient $nostrClient,
@@ -30,26 +25,18 @@ final class MagazineContentService
}
/**
- * "indices" for the home template: Nostr `a` tag rows for each category.
+ * @deprecated use {@see getHomeCategoryAIndexTagsFromStoreOnly} (identical; no blocking relay I/O)
*
* @return list>
*/
public function getHomeCategoryIndexTags(): array
{
- $npub = (string) $this->params->get('npub');
- $dTag = (string) $this->params->get('d_tag');
- if ($this->store->getRoot($npub, $dTag) === null) {
- $this->refresher->refreshFromRelays(20, []);
- } elseif ($this->shouldRevalidateRootFromRelay()) {
- $this->refresher->refreshFromRelays(20, []);
- }
-
return $this->getHomeCategoryAIndexTagsFromStoreOnly();
}
/**
- * Category `a` tags from the persisted root only (no relay). Used after /ux/magazine-sync
- * has already called {@see MagazineRefresher::refreshFromRelays}.
+ * Category `a` tags from the persisted root only (no relay). The store is filled by
+ * `app:prewarm` / cron ({@see MagazineRefresher::refreshFromRelays}), not from HTTP.
*
* @return list>
*/
@@ -86,16 +73,6 @@ final class MagazineContentService
return array_values($cats);
}
- private function shouldRevalidateRootFromRelay(): bool
- {
- $age = $this->refresher->getSecondsSinceLastRelayRun();
- if ($age === null) {
- return true;
- }
-
- return $age > self::ROOT_REVALIDATE_SECONDS;
- }
-
/**
* Category path slugs from the persisted root index (third segment of each category `a` tag).
*
@@ -145,15 +122,14 @@ final class MagazineContentService
}
/**
+ * Category listing from the persisted 30040 index and DB only. Does not call relays.
+ * Missing `Article` rows (not yet in MySQL) appear until `app:prewarm` backfills.
+ *
* @return array{list: list, category: array{title: string, summary: string}}
*/
public function getCategoryPageData(string $slug): array
{
$catIndex = $this->store->getCategory($slug);
- if ($catIndex === null) {
- $this->refresher->refreshFromRelays(20, [$slug]);
- $catIndex = $this->store->getCategory($slug);
- }
$list = [];
$coordinates = [];
$category = [];
@@ -188,21 +164,6 @@ final class MagazineContentService
];
}
$byAddress = $this->articleRepository->findByAuthorAndSlugIndexed($pairs);
- $missing = [];
- foreach ($coordinates as $coordinate) {
- $parts = explode(':', (string) $coordinate, 3);
- if (\count($parts) < 3) {
- continue;
- }
- $k = (string) $parts[1]."\0".trim((string) $parts[2]);
- if (!isset($byAddress[$k])) {
- $missing[] = (string) $coordinate;
- }
- }
- if ($missing !== []) {
- $this->nostrClient->ingestMissingLongformForCategoryCoordinates($missing);
- $byAddress = $this->articleRepository->findByAuthorAndSlugIndexed($pairs);
- }
foreach ($coordinates as $coordinate) {
$parts = explode(':', (string) $coordinate, 3);
if (\count($parts) < 3) {
@@ -224,6 +185,77 @@ final class MagazineContentService
];
}
+ /**
+ * For every category in the root index, fetch Nostr long-form for `a` tags missing in MySQL.
+ * Nostr I/O; intended for {@see PrewarmCommand} / cron only.
+ */
+ public function ingestMissingLongformForAllMagazineCategories(): int
+ {
+ $n = 0;
+ foreach ($this->getCategorySlugsFromStore() as $catSlug) {
+ $missing = $this->findMissingLongformCoordinatesForCategory($catSlug);
+ if ($missing === []) {
+ continue;
+ }
+ $this->nostrClient->ingestMissingLongformForCategoryCoordinates($missing);
+ $n += \count($missing);
+ }
+
+ return $n;
+ }
+
+ /**
+ * @return list Nostr coordinates kind:pubkey:identifier
+ */
+ private function findMissingLongformCoordinatesForCategory(string $slug): array
+ {
+ $catIndex = $this->store->getCategory($slug);
+ if ($catIndex === null) {
+ return [];
+ }
+ $coordinates = [];
+ foreach ($catIndex->getTags() as $tag) {
+ if (($tag[0] ?? null) === 'a' && isset($tag[1])) {
+ $coordinates[] = (string) $tag[1];
+ }
+ }
+ if ($coordinates === []) {
+ return [];
+ }
+ $pairs = [];
+ foreach ($coordinates as $coordinate) {
+ $parts = explode(':', (string) $coordinate, 3);
+ if (\count($parts) < 3) {
+ continue;
+ }
+ $slugPart = trim((string) $parts[2]);
+ if ($slugPart === '') {
+ continue;
+ }
+ $pairs[] = [
+ 'pubkey' => (string) $parts[1],
+ 'slug' => $slugPart,
+ ];
+ }
+ if ($pairs === []) {
+ return [];
+ }
+ $byAddress = $this->articleRepository->findByAuthorAndSlugIndexed($pairs);
+ $missing = [];
+ foreach ($coordinates as $coordinate) {
+ $parts = explode(':', (string) $coordinate, 3);
+ if (\count($parts) < 3) {
+ continue;
+ }
+ $k = (string) $parts[1]."\0".trim((string) $parts[2]);
+ if (!isset($byAddress[$k])) {
+ $missing[] = (string) $coordinate;
+ }
+ }
+
+ return $missing;
+ }
+
/**
* Union of every article referenced by a category index (root 30040). Use this for magazine-wide
* Atom and comment prewarm so "newest" tracks the magazine, not the generic community list.
diff --git a/src/Service/MagazineIndexStore.php b/src/Service/MagazineIndexStore.php
index 87f8a02..341bf47 100644
--- a/src/Service/MagazineIndexStore.php
+++ b/src/Service/MagazineIndexStore.php
@@ -10,7 +10,7 @@ use Psr\Cache\InvalidArgumentException;
/**
* Read/write persisted magazine Nostr index events (kinds 30040) without callback-based relay I/O
- * on the request path. Updated by {@see MagazineRefresher} or the /ux/magazine-sync action.
+ * on the request path. Updated by {@see MagazineRefresher} (via `app:prewarm` / cron, or explicit CLI use).
*/
final class MagazineIndexStore
{
diff --git a/src/Twig/Components/Header.php b/src/Twig/Components/Header.php
index c19cc09..82c6377 100644
--- a/src/Twig/Components/Header.php
+++ b/src/Twig/Components/Header.php
@@ -15,6 +15,7 @@ class Header
public function __construct(
private readonly MagazineContentService $magazineContent,
) {
- $this->cats = $this->magazineContent->getHomeCategoryIndexTags();
+ // Store only: never block the response on relay I/O (cron/pre-warm updates the store).
+ $this->cats = $this->magazineContent->getHomeCategoryAIndexTagsFromStoreOnly();
}
}
diff --git a/src/Twig/Components/Organisms/Comments.php b/src/Twig/Components/Organisms/Comments.php
deleted file mode 100644
index 3fd83ba..0000000
--- a/src/Twig/Components/Organisms/Comments.php
+++ /dev/null
@@ -1,28 +0,0 @@
-commentThreadLoader->load((string) $current);
- $this->list = $data['list'];
- $this->commentLinks = $data['commentLinks'];
- $this->processedContent = $data['processedContent'];
- }
-}
diff --git a/templates/base.html.twig b/templates/base.html.twig
index c687b11..7699e3b 100644
--- a/templates/base.html.twig
+++ b/templates/base.html.twig
@@ -29,12 +29,7 @@
{% endblock %}
-
+
diff --git a/templates/components/Header.html.twig b/templates/components/Header.html.twig
index 7306afc..402533f 100644
--- a/templates/components/Header.html.twig
+++ b/templates/components/Header.html.twig
@@ -11,7 +11,7 @@