Compare commits

...

6 Commits

  1. 6
      assets/bootstrap.js
  2. 69
      assets/controllers/article_comments_controller.js
  3. 44
      assets/controllers/comment_reply_controller.js
  4. 4
      assets/controllers/login_controller.js
  5. 49
      assets/controllers/magazine_sync_controller.js
  6. 132
      assets/controllers/progress_bar_controller.js
  7. 88
      assets/styles/app.css
  8. 92
      assets/styles/article.css
  9. 204
      assets/styles/layout.css
  10. 6
      config/services.yaml
  11. 2
      config/unfold.yaml
  12. 2
      docker/cron/README.md
  13. 26
      migrations/Version20260423120000.php
  14. 151
      src/Command/PrewarmCommand.php
  15. 253
      src/Controller/ArticleController.php
  16. 15
      src/Controller/AuthorController.php
  17. 9
      src/Controller/CommentReplyController.php
  18. 11
      src/Controller/DefaultController.php
  19. 54
      src/Controller/FeaturedAuthorsController.php
  20. 103
      src/Controller/MagazineSyncController.php
  21. 64
      src/Controller/SeoController.php
  22. 89
      src/Entity/FeaturedAuthor.php
  23. 54
      src/Repository/FeaturedAuthorRepository.php
  24. 201
      src/Service/ArticleCommentThreadLoader.php
  25. 5
      src/Service/CacheService.php
  26. 120
      src/Service/FeaturedAuthorSync.php
  27. 163
      src/Service/MagazineContentService.php
  28. 2
      src/Service/MagazineIndexStore.php
  29. 13
      src/Service/MagazineRefresher.php
  30. 214
      src/Service/Nip05VerificationService.php
  31. 45
      src/Service/NostrClient.php
  32. 37
      src/Service/ProfileIdentityLinksBuilder.php
  33. 119
      src/Service/ProfilePaymentLinksBuilder.php
  34. 21
      src/Twig/Components/Footer.php
  35. 3
      src/Twig/Components/Header.php
  36. 28
      src/Twig/Components/Organisms/Comments.php
  37. 7
      templates/base.html.twig
  38. 50
      templates/components/Footer.html.twig
  39. 2
      templates/components/Header.html.twig
  40. 178
      templates/components/Organisms/Comments.html.twig
  41. 16
      templates/home.html.twig
  42. 13
      templates/pages/article.html.twig
  43. 70
      templates/pages/author.html.twig
  44. 4
      templates/pages/category.html.twig
  45. 43
      templates/pages/featured_authors.html.twig
  46. 67
      templates/partial/author_profile_header.html.twig
  47. 3
      templates/ux/magazine/category_body.html.twig
  48. 10
      templates/ux/magazine/header_ul.html.twig
  49. 5
      templates/ux/magazine/home_body.html.twig

6
assets/bootstrap.js vendored

@ -1,7 +1,6 @@ @@ -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 { @@ -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 {

69
assets/controllers/article_comments_controller.js

@ -6,11 +6,31 @@ import { Controller } from '@hotwired/stimulus'; @@ -6,11 +6,31 @@ import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
static values = {
url: String,
preloaded: { type: Boolean, default: false },
};
static targets = ['container'];
connect() {
this.boundOnAuth = this.onAuthChanged.bind(this);
window.addEventListener('unfold:auth-changed', this.boundOnAuth);
if (!this.hasContainerTarget || !this.urlValue) {
return;
}
if (this.preloadedValue) {
// 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();
}
disconnect() {
window.removeEventListener('unfold:auth-changed', this.boundOnAuth);
}
onAuthChanged() {
if (!this.hasContainerTarget || !this.urlValue) {
return;
}
@ -19,22 +39,41 @@ export default class extends Controller { @@ -19,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 =
'<p class="text-subtle">Comments could not be loaded.</p>';
}
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 =
'<p class="text-subtle">Comments could not be loaded.</p>';
}
}
}

44
assets/controllers/comment_reply_controller.js

@ -1,10 +1,10 @@ @@ -1,10 +1,10 @@
import { Controller } from '@hotwired/stimulus';
/**
* Builds a NIP-22 kind-1111 event (blurb + body), signs with NIP-07, POSTs to /comment/publish.
* NIP-22 kind-1111 reply: optional collapsed panel (Reply button), sign with NIP-07, POST, refresh thread.
*/
export default class extends Controller {
static targets = ['hint'];
static targets = ['hint', 'panel', 'toggleBtn'];
static values = {
publishUrl: String,
@ -32,6 +32,21 @@ export default class extends Controller { @@ -32,6 +32,21 @@ export default class extends Controller {
}
}
togglePanel() {
if (!this.hasPanelTarget) {
return;
}
const hidden = this.panelTarget.classList.toggle('comment-reply__panel--hidden');
const open = !hidden;
if (this.hasToggleBtnTarget) {
this.toggleBtnTarget.setAttribute('aria-expanded', open ? 'true' : 'false');
}
if (open) {
const ta = this.panelTarget.querySelector('textarea[name="body"]');
requestAnimationFrame(() => ta?.focus());
}
}
/**
* @param {Event} ev
*/
@ -41,7 +56,8 @@ export default class extends Controller { @@ -41,7 +56,8 @@ export default class extends Controller {
this.setHint('Install a Nostr extension (NIP-07) to sign comments.');
return;
}
const ta = this.element.querySelector('textarea[name="body"]');
const root = this.hasPanelTarget ? this.panelTarget : this.element;
const ta = root.querySelector('textarea[name="body"]');
const text = (ta?.value ?? '').trim();
if (!text) {
this.setHint('Write something first.');
@ -99,10 +115,16 @@ export default class extends Controller { @@ -99,10 +115,16 @@ export default class extends Controller {
this.setHint(data.error || `HTTP ${res.status}`);
return;
}
this.setHint('Published. It may take a short time to show on all relays.');
this.setHint('Published.');
if (ta) {
ta.value = '';
}
if (this.hasPanelTarget) {
this.panelTarget.classList.add('comment-reply__panel--hidden');
if (this.hasToggleBtnTarget) {
this.toggleBtnTarget.setAttribute('aria-expanded', 'false');
}
}
if (this.refreshAfterValue && this.fragmentUrlValue) {
this.refreshThread();
}
@ -133,13 +155,19 @@ export default class extends Controller { @@ -133,13 +155,19 @@ export default class extends Controller {
}
refreshThread() {
const el = document.querySelector('[data-article-comments-url-value]');
const u = el?.getAttribute('data-article-comments-url-value');
const container = document.querySelector('[data-article-comments-target="container"]');
if (!u || !container) {
const wrap = this.element.closest('[data-article-comments-wrapper]');
const url =
wrap?.getAttribute('data-article-comments-url-value') ||
document.querySelector('[data-article-comments-wrapper]')?.getAttribute('data-article-comments-url-value');
const container =
wrap?.querySelector('[data-article-comments-target="container"]') ||
document.querySelector('[data-article-comments-target="container"]');
if (!url || !container) {
window.location.reload();
return;
}
const bust = `cb=${Date.now()}`;
const u = url.includes('?') ? `${url}&${bust}` : `${url}?${bust}`;
void fetch(u, { headers: { Accept: 'text/html', 'X-Requested-With': 'XMLHttpRequest' } })
.then((r) => (r.ok ? r.text() : Promise.reject(new Error(String(r.status)))))
.then((html) => {

4
assets/controllers/login_controller.js

@ -31,6 +31,10 @@ export default class extends Controller { @@ -31,6 +31,10 @@ export default class extends Controller {
})
if (!!result) {
this.component.render();
window.dispatchEvent(
new CustomEvent('unfold:auth-changed', { detail: { loggedIn: true } })
);
}
}
}

49
assets/controllers/magazine_sync_controller.js

@ -1,49 +0,0 @@ @@ -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 */
}
}
}

132
assets/controllers/progress_bar_controller.js

@ -1,20 +1,86 @@ @@ -1,20 +1,86 @@
// 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');
this.barTarget.style.transition = 'none';
this.barTarget.style.width = '100%';
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 +99,47 @@ export default class extends Controller { @@ -33,19 +99,47 @@ 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.classList.add('pb-indeterminate');
/* Full-width track; motion is the ::before sweep in CSS (avoids keyframed width 20%↔55%). */
this.barTarget.style.width = '100%';
}
}

88
assets/styles/app.css

@ -536,7 +536,8 @@ footer a { @@ -536,7 +536,8 @@ footer a {
.author-profile__header-meta {
margin-top: 0.5rem;
max-width: 28rem;
max-width: min(100%, 40rem);
width: 100%;
margin-left: auto;
margin-right: auto;
text-align: left;
@ -548,18 +549,19 @@ footer a { @@ -548,18 +549,19 @@ footer a {
padding: 0;
}
.author-profile__identity-row {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.35rem 0.5rem;
.author-profile__meta-line,
.author-profile__identity-row,
.author-profile__payment {
display: grid;
grid-template-columns: 7.5rem minmax(0, 1fr);
column-gap: 0.5rem;
align-items: center;
margin: 0.35rem 0;
font-size: 0.9rem;
line-height: 1.35;
}
.author-profile__identity-type {
flex: 0 0 7.5rem;
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
@ -568,9 +570,36 @@ footer a { @@ -568,9 +570,36 @@ footer a {
opacity: 0.75;
}
.author-profile__identity-link {
word-break: break-all;
.author-profile__meta-value,
.author-profile__identity-link,
.author-profile__payment-link {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.author-profile__nip05-value {
display: flex;
align-items: center;
gap: 0.3rem;
min-width: 0;
overflow: hidden;
}
.author-profile__nip05-value .author-profile__identity-link {
flex: 1 1 0;
min-width: 0;
word-break: normal;
}
.author-profile__nip05-verified {
color: #2e7d32;
font-size: 0.88em;
font-weight: 600;
line-height: 1;
opacity: 0.85;
user-select: none;
}
.author-profile__payments {
@ -581,18 +610,7 @@ footer a { @@ -581,18 +610,7 @@ footer a {
text-align: left;
}
.author-profile__payment {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.35rem 0.5rem;
margin: 0.35rem 0;
font-size: 0.9rem;
line-height: 1.35;
}
.author-profile__payment-type {
flex: 0 0 7.5rem;
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
@ -601,11 +619,6 @@ footer a { @@ -601,11 +619,6 @@ footer a {
opacity: 0.75;
}
.author-profile__payment-link {
word-break: break-all;
min-width: 0;
}
.author-profile__jumble {
margin: 1rem 0 0;
text-align: center;
@ -848,31 +861,6 @@ a:focus-visible { @@ -848,31 +861,6 @@ a:focus-visible {
outline-offset: 2px;
}
.home-subscribe {
margin-bottom: 1.75rem;
padding: 1rem 0 0;
border-bottom: 1px solid var(--color-border, rgba(0, 0, 0, 0.08));
}
.home-subscribe__title {
font-size: 1.15rem;
margin: 0 0 0.35rem;
}
.home-subscribe__hint {
margin: 0 0 0.75rem;
font-size: 0.9rem;
color: var(--color-text);
opacity: 0.85;
}
.home-subscribe__actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 0.6rem;
margin-bottom: 1.25rem;
}
@media (max-width: 600px) {
.header__logo .brand {
font-size: clamp(0.95rem, 4.8vw, 1.25rem);

92
assets/styles/article.css

@ -128,6 +128,49 @@ blockquote p { @@ -128,6 +128,49 @@ blockquote p {
gap: 0.35rem;
}
/* Thread depth: light indent, max visual level 3 (deeper uses --depth-3) */
.comments .card.comment--depth-0 {
margin-left: 0;
}
.comments .card.comment--depth-1 {
margin-left: 0.28rem;
}
.comments .card.comment--depth-2 {
margin-left: 0.6rem;
}
.comments .card.comment--depth-3 {
margin-left: 0.95rem;
}
.comment__reply-blurb {
padding: 0.5rem 0.75rem 0.35rem;
margin: 0 0 0 0.25rem;
border-left: 3px solid var(--color-border, rgba(128, 128, 128, 0.45));
background: var(--color-bg-light, rgba(0, 0, 0, 0.12));
border-radius: 0 4px 4px 0;
font-size: 0.95em;
line-height: 1.45;
}
.comment__reply-blurb blockquote,
.comment__reply-blurb :where(blockquote) {
border-left: none;
margin: 0;
padding-left: 0;
}
.comment__reply-blurb blockquote p,
.comment__reply-blurb :where(blockquote) p {
font-size: inherit;
line-height: inherit;
font-style: normal;
margin: 0;
padding-left: 0;
}
.visually-hidden {
position: absolute;
width: 1px;
@ -153,13 +196,53 @@ blockquote p { @@ -153,13 +196,53 @@ blockquote p {
border-top: 1px solid var(--color-border);
}
.comment-reply--article__inner {
padding: 0.9rem 1rem 1rem;
}
.comment-reply__toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.5rem 0.75rem;
margin-bottom: 0.35rem;
}
.comment-reply__lede {
margin: 0;
font-size: 0.9rem;
line-height: 1.35;
flex: 1 1 12rem;
min-width: 0;
}
.comment-reply__toolbar--inline {
margin-bottom: 0.25rem;
margin-top: 0.5rem;
justify-content: flex-end;
}
.comment-reply__heading {
font-size: 1.05rem;
margin: 0 0 0.75rem;
margin: 0;
}
.comment-reply__panel {
margin-top: 0.6rem;
padding: 0.75rem 0.8rem 0.85rem;
border-radius: 6px;
background: var(--color-bg-light, rgba(0, 0, 0, 0.2));
border: 1px solid var(--color-border);
box-sizing: border-box;
}
.comment-reply__panel--hidden {
display: none;
}
.comment-reply--nested {
margin-top: 1rem;
margin-top: 0.5rem;
}
.comment-reply__head {
@ -171,13 +254,14 @@ blockquote p { @@ -171,13 +254,14 @@ blockquote p {
width: 100%;
max-width: 100%;
box-sizing: border-box;
padding: 0.5rem 0.65rem;
padding: 0.6rem 0.75rem;
margin: 0;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-bg);
color: var(--color-text);
font: inherit;
line-height: 1.45;
line-height: 1.5;
min-height: 4.5rem;
resize: vertical;
}

204
assets/styles/layout.css

@ -84,9 +84,46 @@ header { @@ -84,9 +84,46 @@ header {
bottom: 0;
height: 4px;
width: 0;
transform-origin: left center;
background: var(--color-primary);
transition: width 0.4s ease;
z-index: 1000;
overflow: hidden;
pointer-events: none;
}
/*
* In-flight navigation: a full-width track with a short segment that sweeps left right
* (do not keyframe the track width: 20% / 55% / 28% read as a half-screen rubber band).
*/
#progress-bar.pb-indeterminate {
transition: none;
/* Tinted track: solid fill comes from ::before while loading */
background: color-mix(in srgb, var(--color-primary) 20%, transparent);
animation: none;
}
#progress-bar.pb-indeterminate::before {
content: "";
position: absolute;
top: 0;
left: 0;
width: 35%;
height: 100%;
background: var(--color-primary);
border-radius: 0 2px 2px 0;
animation: pb-sweep 1.15s cubic-bezier(0.4, 0, 0.2, 1) infinite;
}
@keyframes pb-sweep {
0% {
transform: translateX(-100%);
}
100% {
/* Move one segment width past the right edge of the 100% track */
transform: translateX(calc(100% / 0.35 + 100%));
}
}
/* Mobile Styles */
@ -204,15 +241,176 @@ dt { @@ -204,15 +241,176 @@ dt {
footer {
background-color: var(--color-footer-bg);
color: var(--color-footer-text);
text-align: center;
padding: 1em 0;
padding: 1.25rem 1rem 1.5rem;
position: relative;
width: 100%;
border-top: 1px solid var(--color-border);
}
.site-footer {
max-width: 1200px;
margin: 0 auto;
display: flex;
flex-direction: column;
align-items: stretch;
gap: 1.75rem;
text-align: left;
}
.site-footer__syndication-title {
font-size: 1.1rem;
font-weight: 600;
margin: 0 0 0.35rem;
}
.site-footer__syndication-hint {
margin: 0 0 0.75rem;
font-size: 0.9rem;
color: var(--color-text);
opacity: 0.9;
max-width: 40rem;
}
.site-footer__nav {
max-width: 44rem;
}
.site-footer__syndication-list {
display: flex;
flex-wrap: wrap;
align-items: baseline;
row-gap: 0.4rem;
list-style: none;
margin: 0;
padding: 0;
font-size: 0.95rem;
line-height: 1.5;
}
.site-footer__syndication-list > li {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem 0.45rem;
}
.site-footer__syndication-list > li + li::before {
content: "·";
color: var(--color-text-mid, #666);
font-weight: 300;
align-self: center;
padding: 0 0.1rem 0 0;
}
.site-footer__link {
color: var(--color-footer-link);
text-decoration: underline;
text-underline-offset: 2px;
font-weight: 400;
}
.site-footer__link:hover {
color: var(--color-text);
}
.site-footer__link:focus-visible {
outline: 2px solid var(--color-focus-ring);
outline-offset: 2px;
}
/* RSS + category feed links in one cell */
.site-footer__syndication-list__feeds {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem 0.45rem;
max-width: 100%;
}
/* Dots between feed links (skip first <a> = "All articles"). */
.site-footer__syndication-list__feeds a:not(:first-of-type)::before {
content: "·";
color: var(--color-text-mid, #666);
font-weight: 300;
margin-right: 0.45rem;
text-decoration: none;
display: inline;
}
.site-footer__feeds-icon {
display: flex;
flex-shrink: 0;
line-height: 0;
color: var(--color-text-mid, #666);
opacity: 0.72;
}
.site-footer__main {
text-align: center;
}
.site-footer__legal {
margin: 1rem 0 0;
font-size: 0.95rem;
}
@media (min-width: 900px) {
.site-footer {
flex-direction: row;
align-items: flex-start;
justify-content: space-between;
gap: 2rem 3rem;
}
.site-footer__syndication {
flex: 0 1 50%;
}
.site-footer__main {
flex: 0 1 auto;
min-width: min(20rem, 100%);
text-align: right;
}
.site-footer__legal {
text-align: right;
}
}
footer .footer-links {
margin: 24px 0;
margin: 0 0 0.5rem;
}
.featured-authors {
max-width: 48rem;
margin: 0 auto;
padding: 0 0.5rem 2rem;
}
.featured-authors__intro {
margin-bottom: 2rem;
}
.featured-authors__intro h1 {
margin-top: 0;
}
.featured-authors__card {
margin-bottom: 2.5rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid var(--color-border);
}
.featured-authors__card:last-of-type {
border-bottom: none;
}
.author-profile--featured .author-profile__title {
font-size: 1.5rem;
}
.featured-authors__more {
margin: 0.75rem 0 0;
}
.footer-links a {

6
config/services.yaml

@ -37,6 +37,9 @@ services: @@ -37,6 +37,9 @@ services:
$articleRelayUrls: '%article_relays%'
$profileRelayUrls: '%profile_relays%'
$projectDir: '%kernel.project_dir%'
App\Service\ArticleCommentThreadLoader:
arguments:
$appCachePool: '@cache.app'
App\Twig\FooterLinksExtension:
arguments:
$footerLinksPath: '%footer_links%'
@ -51,3 +54,6 @@ services: @@ -51,3 +54,6 @@ services:
App\Service\CacheService:
arguments:
$appCache: '@cache.app'
App\Service\Nip05VerificationService:
arguments:
$appCache: '@cache.app'

2
config/unfold.yaml

@ -31,6 +31,8 @@ parameters: @@ -31,6 +31,8 @@ parameters:
npub: 'npub1m4ny6hjqzepn4rxknuq94c2gpqzr29ufkkw7ttcxyak7v43n6vvsajc2jl'
d_tag: 'newsroom-magazine-on-imwald-by-laeserin'
community_articles: true
# Domain for site-assigned NIP-05 for featured (magazine category) authors; must match the host serving /.well-known/nostr.json
nip05_domain: 'blog.imwald.eu'
# Base URL for "Open in Jumble" on author profile (trailing slash optional; npub is appended as /{npub}).
jumble_profile_users_base: 'https://jumble.imwald.eu/users'
external_links:

2
docker/cron/README.md

@ -1,6 +1,6 @@ @@ -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).

26
migrations/Version20260423120000.php

@ -0,0 +1,26 @@ @@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260423120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Featured authors for site NIP-05 (category authors)';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE featured_author (id INT AUTO_INCREMENT NOT NULL, pubkey_hex VARCHAR(64) NOT NULL, local_part VARCHAR(100) NOT NULL, is_listed TINYINT(1) NOT NULL DEFAULT 1, created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', UNIQUE INDEX UNIQ_8EED8C6CE479AD9 (pubkey_hex), UNIQUE INDEX UNIQ_8EED8C6CEEEB401 (local_part), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE featured_author');
}
}

151
src/Command/PrewarmCommand.php

@ -6,21 +6,27 @@ namespace App\Command; @@ -6,21 +6,27 @@ namespace App\Command;
use App\Entity\Article;
use App\Repository\ArticleRepository;
use App\Repository\FeaturedAuthorRepository;
use App\Service\ArticleCommentThreadLoader;
use App\Service\CacheService;
use App\Service\FeaturedAuthorSync;
use App\Service\MagazineContentService;
use App\Service\Nip05VerificationService;
use App\Service\MagazineRefresher;
use App\Service\Nip09DeletionApplier;
use App\Service\NostrClient;
use App\Service\ProfileIdentityLinksBuilder;
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;
/**
@ -29,7 +35,7 @@ use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; @@ -29,7 +35,7 @@ use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
*/
#[AsCommand(
name: 'app:prewarm',
description: 'Refresh magazine indices, NIP-09 deletions, profile metadata, and comment caches',
description: 'Refresh magazine indices, NIP-09 deletions, profile metadata, NIP-05 verification cache, and comment caches',
)]
final class PrewarmCommand extends Command
{
@ -43,6 +49,10 @@ final class PrewarmCommand extends Command @@ -43,6 +49,10 @@ final class PrewarmCommand extends Command
private readonly ArticleCommentThreadLoader $commentThreadLoader,
private readonly ParameterBagInterface $params,
private readonly LoggerInterface $logger,
private readonly FeaturedAuthorSync $featuredAuthorSync,
private readonly Nip05VerificationService $nip05Verification,
private readonly ProfileIdentityLinksBuilder $profileIdentityLinks,
private readonly FeaturedAuthorRepository $featuredAuthorRepository,
) {
parent::__construct();
}
@ -85,6 +95,23 @@ final class PrewarmCommand extends Command @@ -85,6 +95,23 @@ final class PrewarmCommand extends Command
} elseif ($phase === 'after_root') {
$hb->silent = true;
$this->cancelPcntlAlarm();
$planned = $p['slugs'] ?? null;
if (!\is_array($planned)) {
$planned = [];
}
if ($planned === []) {
$io->writeln(' <comment>Magazine root has no child <info>a</info> tag categories; only the root index was stored.</comment>');
} else {
$n = \count($planned);
$io->writeln(sprintf(' <comment>Magazine child categories in root</comment> <info>(%d)</info><comment>:</comment>', $n));
foreach ($planned as $slug) {
$s = (string) $slug;
if (strlen($s) > 120) {
$s = substr($s, 0, 117).'…';
}
$io->writeln(sprintf(' · <info>%s</info>', $s));
}
}
$bar = $this->createPrewarmProgressBar(
$io,
max(1, (int) ($p['total_steps'] ?? 1)),
@ -95,10 +122,25 @@ final class PrewarmCommand extends Command @@ -95,10 +122,25 @@ final class PrewarmCommand extends Command
} elseif ($phase === 'category_fetched' && $bar !== null) {
$bar->advance(1);
$slug = (string) ($p['slug'] ?? '');
if (strlen($slug) > 70) {
$slug = substr($slug, 0, 67).'…';
$tSlug = $slug;
if (strlen($tSlug) > 70) {
$tSlug = substr($tSlug, 0, 67).'…';
}
$bar->setMessage($tSlug !== '' ? 'Category: '.$tSlug : 'Category');
if ($tSlug !== '') {
$step = (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>',
$step,
$tot,
$tSlug
));
} else {
$io->writeln(sprintf(' <comment>Fetched category index</comment><info>%s</info>', $tSlug));
}
}
$bar->setMessage($slug !== '' ? 'Category: '.$slug : 'Category');
}
});
}, $hb);
@ -115,6 +157,28 @@ final class PrewarmCommand extends Command @@ -115,6 +157,28 @@ 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 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 <info>%d</info> 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.
@ -204,12 +268,25 @@ final class PrewarmCommand extends Command @@ -204,12 +268,25 @@ final class PrewarmCommand extends Command
if ($limit > 0) {
$pubkeys = \array_slice($pubkeys, 0, $limit);
}
$toWarm = [];
foreach ($pubkeys as $pubkey) {
if (strlen($pubkey) === 64) {
$toWarm[] = $pubkey;
$pubkeysSeen = [];
foreach ($pubkeys as $pk) {
if (!\is_string($pk) || 64 !== \strlen($pk)) {
continue;
}
$h = strtolower($pk);
if (ctype_xdigit($h) && !isset($pubkeysSeen[$h])) {
$pubkeysSeen[$h] = true;
}
}
$pubkeys = array_keys($pubkeysSeen);
foreach ($this->featuredAuthorRepository->findAllListedOrderByLocalPart() as $fa) {
$hx = strtolower($fa->getPubkeyHex());
if (64 === \strlen($hx) && ctype_xdigit($hx) && !isset($pubkeysSeen[$hx])) {
$pubkeys[] = $hx;
$pubkeysSeen[$hx] = true;
}
}
$toWarm = $pubkeys;
$total = \count($toWarm);
$n = 0;
if ($total === 0) {
@ -243,6 +320,43 @@ final class PrewarmCommand extends Command @@ -243,6 +320,43 @@ final class PrewarmCommand extends Command
$io->newLine(2);
}
$io->success(sprintf('Warmed metadata for %d of %d author(s).', $n, $total));
if ($toWarm !== []) {
$io->writeln('Verifying <comment>NIP-05</comment> (HTTPS <comment>/.well-known/nostr.json</comment>, per identifier)…');
$nt = 0;
$nv = 0;
$domain = trim((string) $this->params->get('nip05_domain'));
foreach ($toWarm as $hex) {
if (64 !== \strlen($hex) || !ctype_xdigit($hex)) {
continue;
}
$hex = strtolower($hex);
$npub = $keys->convertPublicKeyToBech32($hex);
$bundle = $this->cacheService->getMetadataBundle($npub);
$rows = $this->profileIdentityLinks->buildNip05($bundle['content'], $bundle['kind0_tags'] ?? []);
$fa = $this->featuredAuthorRepository->findOneByPubkeyHex($hex);
if ($fa !== null && $fa->isListed() && $domain !== '') {
$rows = $this->profileIdentityLinks->mergeSiteNip05IntoList(
$rows,
$fa->getLocalPart().'@'.$domain
);
}
foreach ($rows as $r) {
++$nt;
$label = (string) ($r['label'] ?? '');
if ($this->nip05Verification->verifyAndCache($hex, $label)) {
++$nv;
}
}
}
$failed = $nt - $nv;
$io->writeln(sprintf(
' <info>%d</info> identifier(s) checked: <info>%d</info> verified, <comment>%d</comment> not verified.',
$nt,
$nv,
$failed
));
}
} else {
$io->note('Skipping metadata (--no-metadata).');
}
@ -339,6 +453,27 @@ final class PrewarmCommand extends Command @@ -339,6 +453,27 @@ final class PrewarmCommand extends Command
$bar->setFormat(' %current%/%max% [%bar%] %percent:3s%%'."\n".' <comment>%message%</comment> <info>%elapsed:6s%</info> ');
$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;
}

253
src/Controller/ArticleController.php

@ -35,8 +35,8 @@ class ArticleController extends AbstractController @@ -35,8 +35,8 @@ class ArticleController extends AbstractController
{
// {@see NostrClient::getArticleDiscussion} runs per-relay work in parallel CLI workers; allow headroom
// for all processes + Symfony (45s was too low and caused an uncatchable max-execution fatal → HTTP 500).
@set_time_limit(120);
@ini_set('max_execution_time', '120');
@set_time_limit(300);
@ini_set('max_execution_time', '300');
$t0 = microtime(true);
$coordinate = $request->query->getString('coordinate');
@ -56,35 +56,6 @@ class ArticleController extends AbstractController @@ -56,35 +56,6 @@ class ArticleController extends AbstractController
if (strlen($articleTitle) > 200) {
$articleTitle = substr($articleTitle, 0, 200);
}
$coordparts = explode(':', $coordinate, 3);
$articleKind = isset($coordparts[0]) && ctype_digit($coordparts[0]) ? (int) $coordparts[0] : 30023;
$articleAuthorPubkey = $coordparts[1] ?? '';
$articleReplyTags = null;
if ($articleAuthorPubkey !== '' && 64 === \strlen($articleAuthorPubkey) && ctype_xdigit($articleAuthorPubkey)) {
$articleReplyTags = Nip22CommentTags::forReplyToArticle($coordinate, $articleAuthorPubkey);
}
$parentIdForNaddr = str_repeat('0', 64);
$articleParentId = $articleEventId ?? $parentIdForNaddr;
if ($articleEventId !== null && 64 === \strlen($articleEventId) && ctype_xdigit($articleEventId)) {
$articleParentId = $articleEventId;
} else {
$articleParentId = $parentIdForNaddr;
}
$threadReplyRows = [];
$userMayReply = $this->isGranted('ROLE_USER');
if ($userMayReply && $articleReplyTags !== null) {
$threadReplyRows[] = [
'mode' => 'article',
'blurbLabel' => $articleTitle !== '' ? $articleTitle : 'Article',
'parentKind' => $articleKind,
'parentId' => $articleParentId,
'authorPubkey' => $articleAuthorPubkey,
'expectedTags' => $articleReplyTags,
];
}
$logger->info('http.fragment.comments_start', [
'coordinate' => $coordinate,
@ -98,69 +69,17 @@ class ArticleController extends AbstractController @@ -98,69 +69,17 @@ class ArticleController extends AbstractController
try {
$data = $loader->load($coordinate, $articleEventId);
if ($userMayReply && $articleReplyTags !== null) {
/** @var array<int, object> $list */
$list = $data['list'] ?? [];
foreach ($list as $row) {
if (!\is_object($row)) {
continue;
}
$k = (int) ($row->kind ?? 0);
if ($k !== KindsEnum::COMMENTS->value) {
continue;
}
$cid = (string) ($row->id ?? '');
$cpk = (string) ($row->pubkey ?? '');
if ($cid === '' || 64 !== \strlen($cid) || !ctype_xdigit($cid)) {
continue;
}
if ($cpk === '' || 64 !== \strlen($cpk) || !ctype_xdigit($cpk)) {
continue;
}
$rawTags = json_decode(json_encode($row->tags ?? []), true);
if (!\is_array($rawTags)) {
$rawTags = [];
}
$snippet = trim((string) ($row->content ?? ''));
if (strlen($snippet) > 120) {
$snippet = substr($snippet, 0, 117).'…';
}
if ($snippet === '') {
$snippet = 'Comment';
}
try {
$expectedTags = Nip22CommentTags::forReplyToComment($cid, $cpk, $k, $rawTags);
} catch (\Throwable) {
continue;
}
$threadReplyRows[] = [
'mode' => 'comment',
'blurbLabel' => $snippet,
'parentKind' => $k,
'parentId' => $cid,
'authorPubkey' => $cpk,
'expectedTags' => $expectedTags,
];
}
}
$data = $this->enrichCommentDataWithReplyContext(
$data,
$coordinate,
$articleEventId,
$articleTitle
);
$logger->info('http.fragment.comments_after_load', [
'elapsed_ms' => (int) round((microtime(true) - $t0) * 1000),
]);
$tRender = microtime(true);
$fragmentQuery = ['coordinate' => $coordinate, 'title' => $articleTitle];
if ($articleEventId !== null) {
$fragmentQuery['e'] = $articleEventId;
}
$data['comment_reply_context'] = [
'can_publish' => $userMayReply,
'coordinate' => $coordinate,
'article_event_id' => $articleEventId,
'parent_kind' => $articleKind,
'rows' => $threadReplyRows,
'fragment_url' => $this->generateUrl('article_comments_fragment', $fragmentQuery),
];
$response = $this->render('components/Organisms/Comments.html.twig', $data, new Response(
'',
Response::HTTP_OK,
@ -183,6 +102,128 @@ class ArticleController extends AbstractController @@ -183,6 +102,128 @@ class ArticleController extends AbstractController
}
}
/**
* Adds `comment_reply_context` for the reply composer (same data as the HTML fragment, used for full-page SSR when cache hits).
*
* @param array{
* list: array<int, object>,
* quotes: array<int, object>,
* commentLinks: array<string, array<int, mixed>>,
* quoteLinks: array<string, array<int, mixed>>,
* processedContent: array<string, string>
* } $data
*
* @return array{
* list: array<int, object>,
* quotes: array<int, object>,
* commentLinks: array<string, array<int, mixed>>,
* quoteLinks: array<string, array<int, mixed>>,
* processedContent: array<string, string>,
* comment_reply_context: array{
* can_publish: bool,
* coordinate: string,
* article_event_id: ?string,
* parent_kind: int,
* rows: array<int, array<string, mixed>>,
* fragment_url: string
* }
* }
*/
private function enrichCommentDataWithReplyContext(
array $data,
string $coordinate,
?string $articleEventId,
string $articleTitle
): array {
$coordparts = explode(':', $coordinate, 3);
$articleKind = isset($coordparts[0]) && ctype_digit($coordparts[0]) ? (int) $coordparts[0] : 30023;
$articleAuthorPubkey = $coordparts[1] ?? '';
$articleReplyTags = null;
if ($articleAuthorPubkey !== '' && 64 === \strlen($articleAuthorPubkey) && ctype_xdigit($articleAuthorPubkey)) {
$articleReplyTags = Nip22CommentTags::forReplyToArticle($coordinate, $articleAuthorPubkey);
}
$parentIdForNaddr = str_repeat('0', 64);
if ($articleEventId !== null && 64 === \strlen($articleEventId) && ctype_xdigit($articleEventId)) {
$articleParentId = $articleEventId;
} else {
$articleParentId = $parentIdForNaddr;
}
$threadReplyRows = [];
$userMayReply = $this->isGranted('ROLE_USER');
if ($userMayReply && $articleReplyTags !== null) {
$threadReplyRows[] = [
'mode' => 'article',
'blurbLabel' => $articleTitle !== '' ? $articleTitle : 'Article',
'parentKind' => $articleKind,
'parentId' => $articleParentId,
'authorPubkey' => $articleAuthorPubkey,
'expectedTags' => $articleReplyTags,
];
/** @var array<int, object> $list */
$list = $data['list'] ?? [];
foreach ($list as $row) {
if (!\is_object($row)) {
continue;
}
$k = (int) ($row->kind ?? 0);
if ($k !== KindsEnum::COMMENTS->value) {
continue;
}
$cid = (string) ($row->id ?? '');
$cpk = (string) ($row->pubkey ?? '');
if ($cid === '' || 64 !== \strlen($cid) || !ctype_xdigit($cid)) {
continue;
}
if ($cpk === '' || 64 !== \strlen($cpk) || !ctype_xdigit($cpk)) {
continue;
}
$rawTags = json_decode(json_encode($row->tags ?? []), true);
if (!\is_array($rawTags)) {
$rawTags = [];
}
$forSnippet = (string) ($row->unfold_body ?? $row->content ?? '');
$snippet = trim($forSnippet);
if (strlen($snippet) > 120) {
$snippet = substr($snippet, 0, 117).'…';
}
if ($snippet === '') {
$snippet = 'Comment';
}
try {
$expectedTags = Nip22CommentTags::forReplyToComment($cid, $cpk, $k, $rawTags);
} catch (\Throwable) {
continue;
}
$threadReplyRows[] = [
'mode' => 'comment',
'blurbLabel' => $snippet,
'parentKind' => $k,
'parentId' => $cid,
'authorPubkey' => $cpk,
'expectedTags' => $expectedTags,
];
}
}
$fragmentQuery = ['coordinate' => $coordinate, 'title' => $articleTitle];
if ($articleEventId !== null) {
$fragmentQuery['e'] = $articleEventId;
}
$data['comment_reply_context'] = [
'can_publish' => $userMayReply,
'coordinate' => $coordinate,
'article_event_id' => $articleEventId,
'parent_kind' => $articleKind,
'rows' => $threadReplyRows,
'fragment_url' => $this->generateUrl('article_comments_fragment', $fragmentQuery),
];
return $data;
}
private static function isValidNostrCoordinate(string $coordinate): bool
{
$parts = explode(':', $coordinate, 3);
@ -237,13 +278,20 @@ class ArticleController extends AbstractController @@ -237,13 +278,20 @@ class ArticleController extends AbstractController
/**
* @throws InvalidArgumentException|CommonMarkException
*/
#[Route('/article/d/{slug}', name: 'article-slug')]
// Slug is the NIP-33 d-identifier and may contain "/"; default [^/]++ would break sitemap/URL generation.
#[Route(
path: '/article/d/{slug}',
name: 'article-slug',
requirements: ['slug' => '.+'],
options: ['utf8' => true],
)]
public function article(
$slug,
EntityManagerInterface $entityManager,
CacheService $cacheService,
CacheItemPoolInterface $articlesCache,
Converter $converter
Converter $converter,
ArticleCommentThreadLoader $commentThreadLoader
): Response
{
@ -282,12 +330,34 @@ class ArticleController extends AbstractController @@ -282,12 +330,34 @@ class ArticleController extends AbstractController
$npub = $key->convertPublicKeyToBech32($article->getPubkey());
$author = $cacheService->getMetadata($npub);
$kind = $article->getKind()?->value ?? 30023;
$pubkey = (string) $article->getPubkey();
$articleSlug = (string) ($article->getSlug() ?? $slug);
$coordinate = $kind.':'.$pubkey.':'.$articleSlug;
$eid = $article->getEventId();
$eid = ($eid !== null && $eid !== '' && self::isValidHexEventId($eid)) ? $eid : null;
$articleTitle = (string) ($article->getTitle() ?? '');
$commentsData = null;
$commentsPreloaded = false;
$cached = $commentThreadLoader->tryLoadFromCacheOnly($coordinate, $eid);
if (null !== $cached) {
$commentsData = $this->enrichCommentDataWithReplyContext(
$cached,
$coordinate,
$eid,
$articleTitle
);
$commentsPreloaded = true;
}
return $this->render('pages/article.html.twig', [
'article' => $article,
'author' => $author,
'npub' => $npub,
'content' => $cacheItem->get(),
'comments_data' => $commentsData,
'comments_preloaded' => $commentsPreloaded,
]);
}
@ -450,6 +520,7 @@ class ArticleController extends AbstractController @@ -450,6 +520,7 @@ class ArticleController extends AbstractController
'article' => $article,
'content' => $content,
'author' => $user->getMetadata(),
'comments_preloaded' => false,
]);
}

15
src/Controller/AuthorController.php

@ -5,7 +5,9 @@ declare(strict_types=1); @@ -5,7 +5,9 @@ declare(strict_types=1);
namespace App\Controller;
use App\Repository\ArticleRepository;
use App\Repository\FeaturedAuthorRepository;
use App\Service\CacheService;
use App\Service\Nip05VerificationService;
use App\Service\NostrClient;
use App\Service\ProfileIdentityLinksBuilder;
use App\Service\ProfilePaymentLinksBuilder;
@ -26,6 +28,8 @@ class AuthorController extends AbstractController @@ -26,6 +28,8 @@ class AuthorController extends AbstractController
NostrClient $nostrClient,
CacheService $cacheService,
ArticleRepository $articleRepository,
FeaturedAuthorRepository $featuredAuthorRepository,
Nip05VerificationService $nip05Verification,
ProfilePaymentLinksBuilder $profilePaymentLinks,
ProfileIdentityLinksBuilder $profileIdentityLinks,
): Response {
@ -70,13 +74,22 @@ class AuthorController extends AbstractController @@ -70,13 +74,22 @@ class AuthorController extends AbstractController
$jumbleBase = rtrim($jumbleBase, '/');
$jumbleProfileHref = $jumbleBase !== '' ? $jumbleBase.'/'.$npub : null;
$profileNip05 = $profileIdentityLinks->buildNip05($author, $kind0Tags);
$fa = $featuredAuthorRepository->findOneByPubkeyHex($pubkey);
if ($fa !== null && $fa->isListed()) {
$nipDomain = trim((string) $this->getParameter('nip05_domain'));
$siteNip = $fa->getLocalPart().($nipDomain !== '' ? '@'.$nipDomain : '');
$profileNip05 = $profileIdentityLinks->mergeSiteNip05IntoList($profileNip05, $siteNip);
}
$profileNip05 = $nip05Verification->enrichRowsWithCache($pubkey, $profileNip05);
return $this->render('pages/author.html.twig', [
'author' => $author,
'npub' => $npub,
'articles' => $articles,
'is_author_profile' => true,
'profile_websites' => $profileIdentityLinks->buildWebsites($author, $kind0Tags),
'profile_nip05' => $profileIdentityLinks->buildNip05($author, $kind0Tags),
'profile_nip05' => $profileNip05,
'profile_payment_links' => $profilePaymentLinks->buildPaymentRows($author, $kind0Tags, $extraPayto),
'jumble_profile_href' => $jumbleProfileHref,
]);

9
src/Controller/CommentReplyController.php

@ -5,6 +5,7 @@ declare(strict_types=1); @@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Controller;
use App\Entity\User;
use App\Service\ArticleCommentThreadLoader;
use App\Service\CommentReplyService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
@ -22,7 +23,7 @@ final class CommentReplyController extends AbstractController @@ -22,7 +23,7 @@ final class CommentReplyController extends AbstractController
*/
#[Route('/comment/publish', name: 'comment_reply_publish', methods: ['POST'])]
#[IsGranted('ROLE_USER')]
public function publish(Request $request, CommentReplyService $commentReply): JsonResponse
public function publish(Request $request, CommentReplyService $commentReply, ArticleCommentThreadLoader $commentThreadLoader): JsonResponse
{
$raw = $request->getContent();
if ($raw === '') {
@ -47,6 +48,12 @@ final class CommentReplyController extends AbstractController @@ -47,6 +48,12 @@ final class CommentReplyController extends AbstractController
$out = $commentReply->publishFromRequestPayload($user, $data);
if ($out['ok'] === true) {
$coord = $data['expected_coordinate'] ?? null;
if (\is_string($coord) && $coord !== '') {
$eid = isset($data['article_event_id']) && \is_string($data['article_event_id']) && $data['article_event_id'] !== '' ? $data['article_event_id'] : null;
$commentThreadLoader->invalidateThread($coord, 64 === \strlen((string) $eid) && ctype_xdigit((string) $eid) ? $eid : null);
}
return $this->json(['ok' => true, 'id' => $out['id']]);
}

11
src/Controller/DefaultController.php

@ -21,17 +21,8 @@ class DefaultController extends AbstractController @@ -21,17 +21,8 @@ class DefaultController extends AbstractController
#[Route('/', name: 'home')]
public function index(): Response
{
$categoriesForFeed = [];
foreach ($this->magazineContent->getCategorySlugsFromStore() as $slug) {
$categoriesForFeed[] = [
'slug' => $slug,
'title' => $this->magazineContent->getCategoryDisplayTitle($slug),
];
}
return $this->render('home.html.twig', [
'indices' => $this->magazineContent->getHomeCategoryIndexTags(),
'categories_for_feed' => $categoriesForFeed,
'indices' => $this->magazineContent->getHomeCategoryAIndexTagsFromStoreOnly(),
]);
}

54
src/Controller/FeaturedAuthorsController.php

@ -0,0 +1,54 @@ @@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Repository\FeaturedAuthorRepository;
use App\Service\CacheService;
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;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
/**
* Renders the site-managed NIP-05 list of magazine category authors.
*/
final class FeaturedAuthorsController extends AbstractController
{
#[Route('/featured-authors', name: 'featured_authors', methods: ['GET'])]
public function index(
FeaturedAuthorRepository $featuredAuthorRepository,
CacheService $cacheService,
ProfileIdentityLinksBuilder $profileIdentityLinks,
ProfilePaymentLinksBuilder $profilePaymentLinks,
ParameterBagInterface $params,
): Response {
$domain = trim((string) $params->get('nip05_domain'));
$jumbleBase = rtrim((string) $params->get('jumble_profile_users_base'), '/');
$keys = new Key();
$authors = [];
foreach ($featuredAuthorRepository->findAllListedOrderByLocalPart() as $fa) {
$npub = $keys->convertPublicKeyToBech32($fa->getPubkeyHex());
$bundle = $cacheService->getMetadataBundle($npub);
$author = $bundle['content'];
$kind0Tags = $bundle['kind0_tags'];
$jumbleProfileHref = $jumbleBase !== '' ? $jumbleBase.'/'.$npub : null;
$authors[] = [
'author' => $author,
'npub' => $npub,
'profile_websites' => $profileIdentityLinks->buildWebsites($author, $kind0Tags),
'profile_payment_links' => $profilePaymentLinks->buildPaymentRows($author, $kind0Tags, []),
'jumble_profile_href' => $jumbleProfileHref,
];
}
return $this->render('pages/featured_authors.html.twig', [
'authors' => $authors,
'nip05_domain' => $domain,
]);
}
}

103
src/Controller/MagazineSyncController.php

@ -1,103 +0,0 @@ @@ -1,103 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Service\MagazineContentService;
use App\Service\MagazineRefresher;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route;
use Twig\Environment;
/** Stale-first: the main request only reads {@see \App\Service\MagazineIndexStore}; this refetches Nostr, updates that store, and returns HTML fragments for Stimulus to patch the document. */
#[AsController]
final class MagazineSyncController
{
public function __construct(
private readonly Environment $twig,
private readonly MagazineRefresher $refresher,
private readonly MagazineContentService $magazineContent,
private readonly ParameterBagInterface $params,
private readonly LoggerInterface $logger,
) {
}
#[Route('/ux/magazine-sync', name: 'ux_magazine_sync', methods: ['GET'])]
public function __invoke(Request $request): JsonResponse
{
try {
$page = (string) $request->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
);
}
}
}

64
src/Controller/SeoController.php

@ -7,10 +7,12 @@ namespace App\Controller; @@ -7,10 +7,12 @@ namespace App\Controller;
use App\Entity\Article;
use App\Enum\EventStatusEnum;
use App\Repository\ArticleRepository;
use App\Repository\FeaturedAuthorRepository;
use App\Service\MagazineContentService;
use App\Service\MagazineIndexStore;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
@ -28,6 +30,7 @@ final class SeoController extends AbstractController @@ -28,6 +30,7 @@ final class SeoController extends AbstractController
private readonly MagazineContentService $magazineContent,
private readonly MagazineIndexStore $magazineIndexStore,
private readonly ParameterBagInterface $params,
private readonly FeaturedAuthorRepository $featuredAuthorRepository,
) {
}
@ -42,6 +45,8 @@ final class SeoController extends AbstractController @@ -42,6 +45,8 @@ final class SeoController extends AbstractController
$urls[] = ['loc' => $this->absoluteUrlForRoute('articles'), 'lastmod' => null];
}
$urls[] = ['loc' => $this->absoluteUrlForRoute('featured_authors'), 'lastmod' => null];
foreach ($this->magazineContent->getCategorySlugsFromStore() as $slug) {
$urls[] = [
'loc' => $this->absoluteUrlForRoute('magazine-category', ['slug' => $slug]),
@ -90,6 +95,65 @@ final class SeoController extends AbstractController @@ -90,6 +95,65 @@ final class SeoController extends AbstractController
);
}
/**
* NIP-05 well-known: maps site-assigned local-parts to hex pubkeys (featured magazine authors).
* Must not redirect. Includes recommended `relays` for clients when profile relay URLs are configured.
*/
#[Route(path: '/.well-known/nostr.json', name: 'nostr_well_known', methods: ['GET', 'HEAD'])]
public function nostrWellKnown(): JsonResponse
{
$rows = $this->featuredAuthorRepository->findAllListedOrderByLocalPart();
$names = [];
foreach ($rows as $r) {
$names[$r->getLocalPart()] = strtolower($r->getPubkeyHex());
}
$payload = ['names' => $names];
$relays = $this->buildRelaysByPubkey($names);
if ($relays !== []) {
$payload['relays'] = $relays;
}
$headers = [
'Content-Type' => 'application/json; charset=UTF-8',
'Access-Control-Allow-Origin' => '*',
'Cache-Control' => 'public, max-age=120',
];
return new JsonResponse(
$payload,
Response::HTTP_OK,
$headers
);
}
/**
* @param array<string, string> $names local-part => hex pubkey
*
* @return array<string, list<string>>
*/
private function buildRelaysByPubkey(array $names): array
{
$raw = $this->params->get('profile_relays');
if (!\is_array($raw) || $raw === []) {
return [];
}
$urls = [];
foreach ($raw as $u) {
if (\is_string($u) && (str_starts_with($u, 'wss://') || str_starts_with($u, 'ws://'))) {
$urls[] = $u;
}
}
if ($urls === []) {
return [];
}
$out = [];
foreach ($names as $hex) {
$out[strtolower($hex)] = $urls;
}
return $out;
}
#[Route('/feeds/magazine.xml', name: 'feed_magazine', methods: ['GET'])]
public function feedMagazine(Request $request): Response
{

89
src/Entity/FeaturedAuthor.php

@ -0,0 +1,89 @@ @@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Repository\FeaturedAuthorRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
/**
* Site-assigned NIP-05 for authors who appear in a magazine category index.
* Rows are only removed or deactivated manually (is_listed = false); sync only adds new pubkeys.
*/
#[ORM\Entity(repositoryClass: FeaturedAuthorRepository::class)]
#[ORM\Table(name: 'featured_author')]
class FeaturedAuthor
{
#[ORM\Id]
#[ORM\GeneratedValue(strategy: 'IDENTITY')]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 64, unique: true)]
private string $pubkeyHex = '';
/**
* NIP-05 local-part (a–z, 0–9, -, _, .) unique across all rows.
*/
#[ORM\Column(length: 100, unique: true)]
private string $localPart = '';
#[ORM\Column(type: Types::BOOLEAN, options: ['default' => true])]
private bool $isListed = true;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
private \DateTimeImmutable $createdAt;
public function __construct()
{
$this->createdAt = new \DateTimeImmutable();
}
public function getId(): ?int
{
return $this->id;
}
public function getPubkeyHex(): string
{
return $this->pubkeyHex;
}
public function setPubkeyHex(string $pubkeyHex): static
{
$this->pubkeyHex = $pubkeyHex;
return $this;
}
public function getLocalPart(): string
{
return $this->localPart;
}
public function setLocalPart(string $localPart): static
{
$this->localPart = $localPart;
return $this;
}
public function isListed(): bool
{
return $this->isListed;
}
public function setIsListed(bool $isListed): static
{
$this->isListed = $isListed;
return $this;
}
public function getCreatedAt(): \DateTimeImmutable
{
return $this->createdAt;
}
}

54
src/Repository/FeaturedAuthorRepository.php

@ -0,0 +1,54 @@ @@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\FeaturedAuthor;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<FeaturedAuthor>
*/
class FeaturedAuthorRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, FeaturedAuthor::class);
}
public function findOneByPubkeyHex(string $pubkeyHex): ?FeaturedAuthor
{
$h = strtolower($pubkeyHex);
return $this->findOneBy(['pubkeyHex' => $h]);
}
public function isLocalPartTaken(string $localPart, ?int $exceptId = null): bool
{
$qb = $this->createQueryBuilder('f')
->select('COUNT(f.id)')
->where('f.localPart = :lp')
->setParameter('lp', $localPart);
if ($exceptId !== null) {
$qb->andWhere('f.id != :eid')->setParameter('eid', $exceptId);
}
return (int) $qb->getQuery()->getSingleScalarResult() > 0;
}
/**
* @return list<FeaturedAuthor>
*/
public function findAllListedOrderByLocalPart(): array
{
return $this->createQueryBuilder('f')
->where('f.isListed = :t')
->setParameter('t', true)
->orderBy('f.localPart', 'ASC')
->getQuery()
->getResult();
}
}

201
src/Service/ArticleCommentThreadLoader.php

@ -4,6 +4,8 @@ declare(strict_types=1); @@ -4,6 +4,8 @@ declare(strict_types=1);
namespace App\Service;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
@ -13,14 +15,55 @@ use Symfony\Contracts\Cache\ItemInterface; @@ -13,14 +15,55 @@ use Symfony\Contracts\Cache\ItemInterface;
*/
final readonly class ArticleCommentThreadLoader
{
/** PSR-6 pool backing {@see $cache}; used for true cache-only reads (SSR) without invoking Nostr. */
public function __construct(
private NostrClient $nostrClient,
private NostrLinkParser $nostrLinkParser,
private CacheInterface $cache,
private CacheItemPoolInterface $appCachePool,
private LoggerInterface $logger,
) {
}
/**
* @return array{
* list: array<int, object>,
* quotes: array<int, object>,
* commentLinks: array<string, array<int, mixed>>,
* quoteLinks: array<string, array<int, mixed>>,
* processedContent: array<string, string>
* }|null
*
* Each object in `list` may be enriched with: unfold_reply_blurb, unfold_body, unfold_depth
* (0–3, for UI indentation).
*/
public function tryLoadFromCacheOnly(string $coordinate, ?string $articleEventHexId = null): ?array
{
$key = $this->cacheKeyForThread($coordinate, $articleEventHexId);
try {
$item = $this->appCachePool->getItem($key);
} catch (InvalidArgumentException) {
return null;
}
if (!$item->isHit()) {
return null;
}
$discussion = $item->get();
if (!\is_array($discussion)) {
return null;
}
if (($discussion['thread'] ?? []) === [] && ($discussion['quotes'] ?? []) === []) {
$this->logger->info('comments.loader.cache_hit_empty', ['coordinate' => $coordinate]);
} else {
$this->logger->info('comments.loader.cache_hit_only', [
'coordinate' => $coordinate,
'thread' => \count($discussion['thread'] ?? []),
]);
}
return $this->expandFromDiscussion($discussion, microtime(true), $articleEventHexId);
}
/**
* @return array{
* list: array<int, object>,
@ -29,12 +72,13 @@ final readonly class ArticleCommentThreadLoader @@ -29,12 +72,13 @@ final readonly class ArticleCommentThreadLoader
* quoteLinks: array<string, array<int, mixed>>,
* processedContent: array<string, string>
* }
*
* @see self::tryLoadFromCacheOnly() for list object enrichments
*/
public function load(string $coordinate, ?string $articleEventHexId = null): array
{
$t0 = microtime(true);
$aggrSuffix = $this->nostrClient->getNostrLandAggrReaderCacheSuffix();
$cacheKey = 'comments_v5_'.hash('sha256', $coordinate."\0".($articleEventHexId ?? '')."\0".$aggrSuffix);
$cacheKey = $this->cacheKeyForThread($coordinate, $articleEventHexId);
$this->logger->info('comments.loader.start', [
'cache_key_suffix' => substr($cacheKey, -16),
'coordinate' => $coordinate,
@ -43,7 +87,8 @@ final readonly class ArticleCommentThreadLoader @@ -43,7 +87,8 @@ final readonly class ArticleCommentThreadLoader
try {
$discussion = $this->cache->get($cacheKey, function (ItemInterface $item) use ($coordinate, $articleEventHexId, $t0): array {
$item->expiresAfter(120);
// Prewarm + HTTP should share the same key; 2m expiry caused cold misses during normal use.
$item->expiresAfter(86400);
$this->logger->info('comments.loader.cache_miss', [
'elapsed_since_load_start_ms' => (int) round((microtime(true) - $t0) * 1000),
]);
@ -66,6 +111,43 @@ final readonly class ArticleCommentThreadLoader @@ -66,6 +111,43 @@ final readonly class ArticleCommentThreadLoader
$discussion = ['thread' => [], 'quotes' => []];
}
return $this->expandFromDiscussion($discussion, $t0, $articleEventHexId);
}
/**
* Drop cached thread so the next load refetches from relays (e.g. after publishing a comment).
*/
public function invalidateThread(string $coordinate, ?string $articleEventHexId): void
{
$key = $this->cacheKeyForThread($coordinate, $articleEventHexId);
try {
$this->appCachePool->deleteItem($key);
} catch (InvalidArgumentException) {
}
}
/**
* Same key for CLI prewarm, anonymous, and logged-in readers so cached threads are shared.
* (Relay selection for misses may still add aggr for signed-in users in {@see NostrClient::getArticleDiscussion}.)
*/
private function cacheKeyForThread(string $coordinate, ?string $articleEventHexId): string
{
return 'comments_v5_'.hash('sha256', $coordinate."\0".($articleEventHexId ?? ''));
}
/**
* @param array{thread: array<int, object>, quotes: array<int, object>} $discussion
*
* @return array{
* list: array<int, object>,
* quotes: array<int, object>,
* commentLinks: array<string, array<int, mixed>>,
* quoteLinks: array<string, array<int, mixed>>,
* processedContent: array<string, string>
* }
*/
private function expandFromDiscussion(array $discussion, float $t0, ?string $articleEventHexId = null): array
{
$list = $discussion['thread'] ?? [];
$quotes = $discussion['quotes'] ?? [];
$this->logger->info('comments.loader.cache_resolved', [
@ -74,6 +156,8 @@ final readonly class ArticleCommentThreadLoader @@ -74,6 +156,8 @@ final readonly class ArticleCommentThreadLoader
'quote_events' => \count($quotes),
]);
$this->enrichThreadListForDisplay($list, $articleEventHexId);
$commentLinks = [];
$quoteLinks = [];
$processedContent = [];
@ -137,4 +221,115 @@ final readonly class ArticleCommentThreadLoader @@ -137,4 +221,115 @@ final readonly class ArticleCommentThreadLoader
$linkBucket[$idKey] = $links;
}
}
/**
* Adds reply blurb / body split and capped thread depth (0–3) on each thread event for Twig/CSS.
*
* @param array<int, object> $list
*/
private function enrichThreadListForDisplay(array $list, ?string $articleEventHexId): void
{
$threadIdSet = [];
foreach ($list as $ev) {
$hid = isset($ev->id) ? (string) $ev->id : '';
if ($hid !== '') {
$threadIdSet[$hid] = true;
}
}
$parentOf = [];
foreach ($list as $ev) {
$id = isset($ev->id) ? (string) $ev->id : '';
if ($id === '') {
continue;
}
$p = $this->resolveParentCommentId($ev, $threadIdSet, $articleEventHexId);
if ($p !== null) {
$parentOf[$id] = $p;
}
}
foreach ($list as $ev) {
$id = isset($ev->id) ? (string) $ev->id : '';
$raw = isset($ev->content) ? (string) $ev->content : '';
$split = $this->splitNip22ReplyBlurb($raw);
$ev->unfold_reply_blurb = $split['blurb'];
$ev->unfold_body = $split['body'];
$ev->unfold_depth = $id === '' ? 0 : $this->threadDepthCapped($id, $parentOf, 3);
}
}
/**
* @return array{blurb: string|null, body: string}
*/
private function splitNip22ReplyBlurb(string $content): array
{
if (!str_contains($content, "\n\n")) {
return ['blurb' => null, 'body' => $content];
}
$parts = explode("\n\n", $content, 2);
$first = trim((string) ($parts[0] ?? ''));
$rest = (string) ($parts[1] ?? '');
if ($first === '' || !str_starts_with($first, '>')) {
return ['blurb' => null, 'body' => $content];
}
if (!str_contains($first, 'nostr:')) {
return ['blurb' => null, 'body' => $content];
}
return ['blurb' => $first, 'body' => $rest];
}
/**
* 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.
*
* @param array<string, true> $threadIdSet
*/
private function resolveParentCommentId(object $event, array $threadIdSet, ?string $articleEventHexId): ?string
{
$selfId = isset($event->id) ? (string) $event->id : '';
$last = null;
foreach ($event->tags ?? [] as $tag) {
if (!\is_array($tag) || \count($tag) < 2) {
continue;
}
if ((string) ($tag[0] ?? '') !== 'e') {
continue;
}
$pid = (string) ($tag[1] ?? '');
if (64 !== \strlen($pid) || !ctype_xdigit($pid)) {
continue;
}
if ($selfId !== '' && hash_equals($pid, $selfId)) {
continue;
}
if ($articleEventHexId !== null && $articleEventHexId !== '' && hash_equals($pid, $articleEventHexId)) {
continue;
}
if (isset($threadIdSet[$pid])) {
$last = $pid;
}
}
return $last;
}
/**
* @param array<string, string> $parentOf child id => parent id
*/
private function threadDepthCapped(string $id, array $parentOf, int $max): int
{
$depth = 0;
$current = $id;
for ($i = 0; $i < 64; ++$i) {
if (!isset($parentOf[$current])) {
break;
}
$current = $parentOf[$current];
++$depth;
}
return $depth > $max ? $max : $depth;
}
}

5
src/Service/CacheService.php

@ -35,8 +35,9 @@ readonly class CacheService @@ -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

120
src/Service/FeaturedAuthorSync.php

@ -0,0 +1,120 @@ @@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\FeaturedAuthor;
use App\Repository\FeaturedAuthorRepository;
use Doctrine\ORM\EntityManagerInterface;
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.
*/
final class FeaturedAuthorSync
{
public function __construct(
private readonly MagazineContentService $magazineContent,
private readonly FeaturedAuthorRepository $featuredAuthorRepository,
private readonly CacheService $cacheService,
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
) {
}
/**
* @return int Number of newly persisted authors
*/
public function syncNewAuthorsFromMagazineCategories(): int
{
$pubkeys = $this->magazineContent->getAllDistinctCategoryAuthorPubkeyHexes();
if ($pubkeys === []) {
return 0;
}
$keys = new Key();
$n = 0;
foreach ($pubkeys as $hex) {
if ($this->featuredAuthorRepository->findOneByPubkeyHex($hex) !== null) {
continue;
}
$entity = new FeaturedAuthor();
$entity->setPubkeyHex($hex);
$base = $this->deriveBaseLocalPart($keys, $hex);
$entity->setLocalPart($this->allocateUniqueLocalPart($base));
$this->entityManager->persist($entity);
++$n;
}
if ($n > 0) {
$this->entityManager->flush();
$this->logger->info('featured_author.sync', ['new_count' => $n]);
}
return $n;
}
private function deriveBaseLocalPart(Key $keys, string $pubkeyHex): string
{
try {
$npub = $keys->convertPublicKeyToBech32($pubkeyHex);
} catch (\Throwable) {
$npub = null;
}
if (!\is_string($npub) || $npub === '') {
return 'author'.substr($pubkeyHex, 0, 8);
}
$name = '';
try {
$c = $this->cacheService->getMetadata($npub);
$name = (string) ($c->display_name ?? $c->name ?? '');
} catch (\Throwable) {
}
$base = $this->nip05LocalPartFromLabel($name);
if ($base === '') {
$base = 'author'.substr($pubkeyHex, 0, 8);
}
return $base;
}
/**
* NIP-05: local-part uses only a–z, 0–9, -, _, .
*/
private function nip05LocalPartFromLabel(string $raw): string
{
$s = strtolower(trim($raw));
$t = @iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $s);
if (\is_string($t) && $t !== '') {
$s = strtolower($t);
}
$s = preg_replace('/[^a-z0-9._-]+/', '', $s) ?? '';
$s = trim((string) $s, '._-');
if (\strlen($s) > 40) {
$s = substr($s, 0, 40);
}
$s = trim($s, '._-');
return $s;
}
private function allocateUniqueLocalPart(string $base): string
{
if ($base === '') {
$base = 'author';
}
if (!$this->featuredAuthorRepository->isLocalPartTaken($base)) {
return $base;
}
for ($i = 1; $i < 10_000; ++$i) {
$c = $base.$i;
if (!$this->featuredAuthorRepository->isLocalPartTaken($c)) {
return $c;
}
}
return $base.bin2hex(random_bytes(3));
}
}

163
src/Service/MagazineContentService.php

@ -11,18 +11,13 @@ use App\Repository\ArticleRepository; @@ -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 @@ -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<array<int, string>>
*/
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<array<int, string>>
*/
@ -86,16 +73,6 @@ final class MagazineContentService @@ -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).
*
@ -123,6 +100,43 @@ final class MagazineContentService @@ -123,6 +100,43 @@ final class MagazineContentService
return array_values(array_unique($out));
}
/**
* Distinct author pubkeys (hex) from every category index `a` tag (kind:pubkey:identifier).
*
* @return list<string>
*/
public function getAllDistinctCategoryAuthorPubkeyHexes(): array
{
$seen = [];
$out = [];
foreach ($this->getCategorySlugsFromStore() as $slug) {
$catIndex = $this->store->getCategory($slug);
if ($catIndex === null) {
continue;
}
foreach ($catIndex->getTags() as $tag) {
if (!\is_array($tag) || ($tag[0] ?? null) !== 'a' || !isset($tag[1])) {
continue;
}
$parts = explode(':', (string) $tag[1], 3);
if (\count($parts) < 2) {
continue;
}
$pk = strtolower((string) $parts[1]);
if (64 !== \strlen($pk) || !ctype_xdigit($pk)) {
continue;
}
if (isset($seen[$pk])) {
continue;
}
$seen[$pk] = true;
$out[] = $pk;
}
}
return $out;
}
/**
* Title from cached category index event tags, or the slug when missing.
*/
@ -145,15 +159,14 @@ final class MagazineContentService @@ -145,15 +159,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<Article>, 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 +201,6 @@ final class MagazineContentService @@ -188,21 +201,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 +222,77 @@ final class MagazineContentService @@ -224,6 +222,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<string> 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.

2
src/Service/MagazineIndexStore.php

@ -10,7 +10,7 @@ use Psr\Cache\InvalidArgumentException; @@ -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
{

13
src/Service/MagazineRefresher.php

@ -23,6 +23,7 @@ final class MagazineRefresher @@ -23,6 +23,7 @@ final class MagazineRefresher
private readonly ParameterBagInterface $params,
private readonly LoggerInterface $logger,
private readonly CacheItemPoolInterface $appCache,
private readonly FeaturedAuthorSync $featuredAuthorSync,
) {
}
@ -31,7 +32,8 @@ final class MagazineRefresher @@ -31,7 +32,8 @@ final class MagazineRefresher
* are requested first (e.g. current /cat route) so they are less likely to miss the budget.
*
* @param (callable(string, array<string, int|string|bool|null>): void)|null $onProgress
* Phases: `before_root`, `after_root` (total_steps, step, slug_count), `category_fetched` (step, total_steps, slug)
* Phases: `before_root`, `after_root` (total_steps, step, slug_count, slugs: list<string>),
* `category_fetched` (step, total_steps, slug)
*/
public function refreshFromRelays(int $budgetSeconds = 8, array $preferSlugs = [], ?callable $onProgress = null): void
{
@ -71,6 +73,7 @@ final class MagazineRefresher @@ -71,6 +73,7 @@ final class MagazineRefresher
'total_steps' => $totalSteps,
'step' => 1,
'slug_count' => \count($slugs),
'slugs' => $slugs,
]);
$step = 1;
foreach ($slugs as $slug) {
@ -105,6 +108,14 @@ final class MagazineRefresher @@ -105,6 +108,14 @@ final class MagazineRefresher
}
}
try {
$this->featuredAuthorSync->syncNewAuthorsFromMagazineCategories();
} catch (\Throwable $e) {
$this->logger->warning('MagazineRefresher: featured author sync failed', [
'message' => $e->getMessage(),
]);
}
$this->touchLastRelayTime();
}

214
src/Service/Nip05VerificationService.php

@ -0,0 +1,214 @@ @@ -0,0 +1,214 @@
<?php
declare(strict_types=1);
namespace App\Service;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException;
use Psr\Log\LoggerInterface;
use swentel\nostr\Key\Key;
/**
* Fetches <domain>/.well-known/nostr.json and checks the listed pubkey (NIP-05).
* Results are stored in the app cache for UI badges and to avoid re-fetching on every request.
*/
final readonly class Nip05VerificationService
{
private const CACHE_PREFIX = 'nip05v1_';
private const FETCH_TIMEOUT_SEC = 8;
public function __construct(
private CacheItemPoolInterface $appCache,
private LoggerInterface $logger,
) {
}
/**
* @param list<array{label: string, href: string, verified?: bool}> $rows
*
* @return list<array{label: string, href: string, verified: bool}>
*/
public function enrichRowsWithCache(string $authorPubkeyHex, array $rows): array
{
if ($rows === []) {
return [];
}
$h = strtolower($authorPubkeyHex);
if (64 !== \strlen($h) || !ctype_xdigit($h)) {
return array_map(static function (array $r): array {
return [...$r, 'verified' => false];
}, $rows);
}
$out = [];
foreach ($rows as $r) {
$label = (string) ($r['label'] ?? '');
$n = $this->normalizeNip05($label);
if ($n === null) {
$out[] = [...$r, 'verified' => false];
continue;
}
$k = $this->cacheKey($h, $n);
$verified = false;
try {
$item = $this->appCache->getItem($k);
if ($item->isHit() && is_bool($item->get())) {
$verified = (bool) $item->get();
}
} catch (InvalidArgumentException) {
}
$out[] = [...$r, 'verified' => $verified];
}
return $out;
}
/**
* Fetches the document and records success or failure in cache (24h).
*/
public function verifyAndCache(string $authorPubkeyHex, string $nip05Label): bool
{
$h = strtolower($authorPubkeyHex);
if (64 !== \strlen($h) || !ctype_xdigit($h)) {
return false;
}
$n = $this->normalizeNip05($nip05Label);
if ($n === null) {
return false;
}
$k = $this->cacheKey($h, $n);
$ok = $this->checkRemote($h, $n);
try {
$item = $this->appCache->getItem($k);
$item->set($ok);
$item->expiresAfter(86_400);
$this->appCache->save($item);
} catch (InvalidArgumentException $e) {
$this->logger->warning('nip05.verify_cache_write_failed', [
'message' => $e->getMessage(),
]);
}
return $ok;
}
private function cacheKey(string $hexLower, string $nip05Lower): string
{
return self::CACHE_PREFIX.hash('sha256', $hexLower."\0".$nip05Lower);
}
private function normalizeNip05(string $raw): ?string
{
$s = trim(strtolower($raw));
if ($s === '' || !str_contains($s, '@')) {
return null;
}
$p = explode('@', $s, 2);
if (($p[0] ?? '') === '' || ($p[1] ?? '') === '' || str_contains($p[1], ' ')) {
return null;
}
return $s;
}
private function checkRemote(string $expectedHex, string $nip05Lower): bool
{
$parts = explode('@', $nip05Lower, 2);
$local = (string) ($parts[0] ?? '');
$domain = (string) ($parts[1] ?? '');
if ($local === '' || $domain === '') {
return false;
}
$url = 'https://'.$domain.'/.well-known/nostr.json?name='.rawurlencode($local);
$http_response_header = [];
$ctx = stream_context_create([
'http' => [
'method' => 'GET',
'header' => "User-Agent: Unfold-NIP05-Verify/1.0\r\nAccept: application/json,\r\n",
'timeout' => self::FETCH_TIMEOUT_SEC,
'ignore_errors' => true,
],
'ssl' => [
'verify_peer' => true,
'verify_peer_name' => true,
],
]);
$raw = @file_get_contents($url, false, $ctx);
if ($raw === false) {
$this->logger->info('nip05.verify_fetch_failed', [
'nip05' => $nip05Lower,
]);
return false;
}
$statusLine = (isset($http_response_header) && \is_array($http_response_header))
? (string) ($http_response_header[0] ?? '')
: '';
if (!preg_match('#\b200\b#', $statusLine)) {
$this->logger->info('nip05.verify_not_200', [
'nip05' => $nip05Lower,
'status' => $statusLine,
]);
return false;
}
try {
$data = json_decode($raw, true, 512, \JSON_THROW_ON_ERROR);
} catch (\JsonException) {
return false;
}
if (!\is_array($data) || !isset($data['names']) || !\is_array($data['names'])) {
return false;
}
$val = $this->lookupNameInNames($data['names'], $local);
if (!\is_string($val) || $val === '') {
return false;
}
$rowHex = $this->toHex64($val);
if ($rowHex === null) {
return false;
}
return hash_equals($expectedHex, $rowHex);
}
/**
* @param array<array-key, mixed> $names
*/
private function lookupNameInNames(array $names, string $localWanted): mixed
{
if (isset($names[$localWanted])) {
return $names[$localWanted];
}
$lw = strtolower($localWanted);
foreach ($names as $k => $v) {
if (\is_string($k) && strtolower($k) === $lw) {
return $v;
}
}
return null;
}
private function toHex64(string $v): ?string
{
$v = trim($v);
if (64 === \strlen($v) && ctype_xdigit($v)) {
return strtolower($v);
}
if (str_starts_with($v, 'npub1')) {
try {
$k = new Key();
$hex = $k->convertToHex($v);
if (64 === \strlen($hex) && ctype_xdigit($hex)) {
return strtolower($hex);
}
} catch (\Throwable) {
}
}
return null;
}
}

45
src/Service/NostrClient.php

@ -33,6 +33,18 @@ class NostrClient @@ -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 @@ -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 @@ -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 @@ -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 @@ -1084,6 +1107,24 @@ class NostrClient
return ['thread' => $thread, 'quotes' => $quotes];
}
/**
* @param list<string> $relayUrls
*
* @return list<string>
*/
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).
*

37
src/Service/ProfileIdentityLinksBuilder.php

@ -94,6 +94,43 @@ final class ProfileIdentityLinksBuilder @@ -94,6 +94,43 @@ final class ProfileIdentityLinksBuilder
return $out;
}
/**
* Adds a site-assigned NIP-05 (e.g. under the blog domain) into the same list as profile NIP-05,
* with the same link shape as {@see buildNip05}, deduped by label.
*
* @param list<array{label: string, href: string}> $rows
*
* @return list<array{label: string, href: string}>
*/
public function mergeSiteNip05IntoList(array $rows, string $siteNip05): array
{
$siteNip05 = trim(strtolower($siteNip05));
if ($siteNip05 === '' || !str_contains($siteNip05, '@')) {
return $rows;
}
$seen = [];
foreach ($rows as $r) {
$seen[strtolower((string) ($r['label'] ?? ''))] = true;
}
if (isset($seen[$siteNip05])) {
return $rows;
}
$parts = explode('@', $siteNip05, 2);
$local = $parts[0] ?? '';
$domain = $parts[1] ?? '';
if ($local === '' || $domain === '' || str_contains($domain, ' ')) {
return $rows;
}
$href = 'https://'.$domain.'/.well-known/nostr.json?name='.rawurlencode($local);
$rows[] = [
'label' => $siteNip05,
'href' => $href,
];
usort($rows, static fn (array $a, array $b): int => strcasecmp($a['label'], $b['label']));
return $rows;
}
/**
* @return list<string>
*/

119
src/Service/ProfilePaymentLinksBuilder.php

@ -26,7 +26,15 @@ final class ProfilePaymentLinksBuilder @@ -26,7 +26,15 @@ final class ProfilePaymentLinksBuilder
* @param list<list<string>> $kind0Tags
* @param list<string> $extraPaytoUris from kind 10133
*
* @return list<array{type: string, type_label: string, label: string, href: string, sort: int}>
* @return list<array{
* type: string,
* type_label: string,
* label: string,
* href: string,
* sort: int,
* group_key: string,
* display_type_label: string
* }>
*/
public function buildPaymentRows(object $content, array $kind0Tags, array $extraPaytoUris = []): array
{
@ -44,7 +52,7 @@ final class ProfilePaymentLinksBuilder @@ -44,7 +52,7 @@ final class ProfilePaymentLinksBuilder
$seen[$norm] = true;
$rows[] = [
'type' => self::TYPE_LIGHTNING_ADDRESS,
'type_label' => 'Lightning address',
'type_label' => 'Lightning',
'label' => $addr,
'href' => 'lightning:'.$addr,
'sort' => 0,
@ -59,7 +67,7 @@ final class ProfilePaymentLinksBuilder @@ -59,7 +67,7 @@ final class ProfilePaymentLinksBuilder
$seen[$norm] = true;
$rows[] = [
'type' => self::TYPE_LNURL_PAY,
'type_label' => 'LNURL Pay',
'type_label' => 'Lightning',
'label' => $this->shortenLnurl($ln),
'href' => 'lightning:'.$ln,
'sort' => 1,
@ -67,6 +75,8 @@ final class ProfilePaymentLinksBuilder @@ -67,6 +75,8 @@ final class ProfilePaymentLinksBuilder
}
}
$lud16ForDedup = $resolved['lightning_address'];
$allPayto = array_merge(
$this->paytoUrisFromJsonObject($content),
$this->paytoUrisFromNipA3StyleTags($kind0Tags),
@ -80,6 +90,9 @@ final class ProfilePaymentLinksBuilder @@ -80,6 +90,9 @@ final class ProfilePaymentLinksBuilder
if (!self::isPaytoOrLegacyPaytoScheme($uri)) {
continue;
}
if ($lud16ForDedup !== null && self::paytoLightningUriMatchesLightningAddress($uri, $lud16ForDedup)) {
continue;
}
$canon = self::normalizePaytoUriForDedup($uri);
if (isset($seen[$canon])) {
continue;
@ -87,7 +100,7 @@ final class ProfilePaymentLinksBuilder @@ -87,7 +100,7 @@ final class ProfilePaymentLinksBuilder
$seen[$canon] = true;
$rows[] = [
'type' => self::TYPE_PAYTO,
'type_label' => 'Payto',
'type_label' => 'Pay to',
'label' => $this->labelForPaytoUri($uri),
'href' => $uri,
'sort' => 2,
@ -112,7 +125,77 @@ final class ProfilePaymentLinksBuilder @@ -112,7 +125,77 @@ final class ProfilePaymentLinksBuilder
}
);
return $rows;
return $this->collapseGroupLabels($rows);
}
/**
* Consecutive rows with the same {@see group_key} only show the first column label on the first row
* (e.g. multiple Lightning lines, then Monero).
*
* @param list<array<string, mixed>> $rows
*
* @return list<array<string, mixed>>
*/
private function collapseGroupLabels(array $rows): array
{
$prevKey = null;
$out = [];
foreach ($rows as $r) {
$gk = $this->rowGroupKey($r);
$col = $this->rowGroupColumnLabel($r, $gk);
$r['group_key'] = $gk;
$r['display_type_label'] = $gk === $prevKey ? '' : $col;
$prevKey = $gk;
$out[] = $r;
}
return $out;
}
/**
* @param array<string, mixed> $r
*/
private function rowGroupKey(array $r): string
{
$t = (string) ($r['type'] ?? '');
if ($t === self::TYPE_LIGHTNING_ADDRESS || $t === self::TYPE_LNURL_PAY) {
return 'lightning';
}
if ($t === self::TYPE_PAYTO) {
$h = strtolower((string) ($r['href'] ?? ''));
if (1 === preg_match('#^payto://([a-z0-9-]+)/#i', $h, $m)) {
$sc = strtolower($m[1]);
if ($sc === 'lightning') {
return 'lightning';
}
return 'payto:'.$sc;
}
return 'payto:other';
}
return 'other';
}
/**
* @param array<string, mixed> $r
*/
private function rowGroupColumnLabel(array $r, string $groupKey): string
{
if ($groupKey === 'lightning') {
return 'Lightning';
}
if (str_starts_with($groupKey, 'payto:')) {
$s = substr($groupKey, 6);
if ($s === 'other') {
return 'Pay to';
}
return $this->stylizePaytoTypeName($s);
}
return (string) ($r['type_label'] ?? 'Pay to');
}
/**
@ -365,6 +448,32 @@ final class ProfilePaymentLinksBuilder @@ -365,6 +448,32 @@ final class ProfilePaymentLinksBuilder
return substr($lnurl, 0, 10).'…'.substr($lnurl, -8);
}
/**
* Skips NIP-A3 / JSON {@see payto://lightning/…} rows that repeat the LUD16 lightning address
* (e.g. same as {@see TYPE_LIGHTNING_ADDRESS} with {@code lightning:user@host}).
*/
private static function paytoLightningUriMatchesLightningAddress(string $uri, string $lud16Email): bool
{
if (!str_starts_with(strtolower($uri), 'payto://lightning/')) {
return false;
}
$lud = strtolower(trim($lud16Email));
if ($lud === '' || !str_contains($lud, '@')) {
return false;
}
if (1 !== preg_match('#^payto://lightning/(.+)$#i', $uri, $m)) {
return false;
}
$tail = (string) $m[1];
$first = (string) (str_contains($tail, '/') ? strstr($tail, '/', true) : $tail);
if ($first === '') {
$first = $tail;
}
$first = strtolower(rawurldecode($first));
return $first === $lud;
}
/**
* JSON: strings (full `payto:` / `payto://` URI), or objects with `type`+`authority` (NIP-A3-style).
*

21
src/Twig/Components/Footer.php

@ -4,11 +4,28 @@ declare(strict_types=1); @@ -4,11 +4,28 @@ declare(strict_types=1);
namespace App\Twig\Components;
use App\Service\MagazineContentService;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[AsTwigComponent]
class Footer {
public function __construct()
class Footer
{
/** @var list<array{slug: string, title: string}> */
public array $categoriesForFeed = [];
public function __construct(
private readonly MagazineContentService $magazineContent,
) {
}
public function mount(): void
{
$this->categoriesForFeed = [];
foreach ($this->magazineContent->getCategorySlugsFromStore() as $slug) {
$this->categoriesForFeed[] = [
'slug' => $slug,
'title' => $this->magazineContent->getCategoryDisplayTitle($slug),
];
}
}
}

3
src/Twig/Components/Header.php

@ -15,6 +15,7 @@ class Header @@ -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();
}
}

28
src/Twig/Components/Organisms/Comments.php

@ -1,28 +0,0 @@ @@ -1,28 +0,0 @@
<?php
namespace App\Twig\Components\Organisms;
use App\Service\ArticleCommentThreadLoader;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[AsTwigComponent]
final class Comments
{
public array $list = [];
public array $commentLinks = [];
public array $processedContent = [];
public function __construct(private readonly ArticleCommentThreadLoader $commentThreadLoader)
{
}
public function mount($current): void
{
$data = $this->commentThreadLoader->load((string) $current);
$this->list = $data['list'];
$this->commentLinks = $data['commentLinks'];
$this->processedContent = $data['processedContent'];
}
}

7
templates/base.html.twig

@ -29,12 +29,7 @@ @@ -29,12 +29,7 @@
<link rel="stylesheet" href="{{ asset('theme.css') }}">
{% endblock %}
</head>
<body
data-controller="service-worker magazine-sync"
data-magazine-sync-page-value="{% block magazine_sync_page %}article{% endblock %}"
data-magazine-sync-slug-value="{% block magazine_sync_slug %}{% endblock %}"
data-magazine-sync-url-value="{{ path('ux_magazine_sync') }}"
>
<body data-controller="service-worker">
<twig:Header />

50
templates/components/Footer.html.twig

@ -1,13 +1,41 @@ @@ -1,13 +1,41 @@
<div class="footer-links">
{% for link in footer_links %}
<div class="footer-link">
<a href="{{ link.url }}" target="_blank" rel="noopener noreferrer" title="{{ link.description|default(link.title) }}">{{ link.title }}</a>
{% if link.description %}
&mdash; <small>{{ link.description }}</small>
{% endif %}
<div class="site-footer">
<div class="site-footer__syndication">
<h2 class="site-footer__syndication-title">Sitemap and feeds</h2>
<p class="site-footer__syndication-hint">For search engines and feed readers. Atom is supported by most clients.</p>
<nav class="site-footer__nav" aria-label="Sitemap, feeds, and index">
<ul class="site-footer__syndication-list">
<li><a class="site-footer__link" href="{{ path('featured_authors') }}">Featured authors</a></li>
<li><a class="site-footer__link" href="{{ path('sitemap') }}">Sitemap (XML)</a></li>
<li><a class="site-footer__link" href="{{ path('robots_txt') }}">Robots</a></li>
<li class="site-footer__syndication-list__feeds">
<span class="site-footer__feeds-icon" title="RSS/Atom" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8" width="16" height="16" focusable="false" fill="currentColor">
<circle cx="1.5" cy="6.5" r="1"/>
<path d="M0 3.5A4.5 4.5 0 0 1 4.5 8V6A2.5 2.5 0 0 0 2 3.5H0z"/>
<path d="M0 0A8 8 0 0 1 8 8H6.5A6.5 6.5 0 0 0 0 1.5V0z"/>
</svg>
</span>
<a class="site-footer__link" href="{{ path('feed_magazine') }}">All articles</a>
{% for c in categoriesForFeed %}
<a class="site-footer__link" href="{{ path('feed_category', {slug: c.slug}) }}">{{ c.title }}</a>
{% endfor %}
</li>
</ul>
</nav>
</div>
<div class="site-footer__main">
<div class="footer-links">
{% for link in footer_links %}
<div class="footer-link">
<a href="{{ link.url }}" target="_blank" rel="noopener noreferrer" title="{{ link.description|default(link.title) }}">{{ link.title }}</a>
{% if link.description %}
&mdash; <small>{{ link.description }}</small>
{% endif %}
</div>
{% endfor %}
</div>
{% endfor %}
<p class="site-footer__legal">
{{ "now"|date("Y") }} {{ website_name }} <span class="publisher">by <twig:Molecules:UserFromNpub :ident="publisher_npub" /></span>
</p>
</div>
</div>
<p>{{ "now"|date("Y") }} {{ website_name }} <span class="publisher">by <twig:Molecules:UserFromNpub :ident="publisher_npub" /></span></p>

2
templates/components/Header.html.twig

@ -11,7 +11,7 @@ @@ -11,7 +11,7 @@
<button class="hamburger btn btn-secondary" data-action="click->menu#toggle" aria-label="Menu">&#9776;</button>
</div>
<div class="header__categories" data-menu-target="menu">
<ul data-magazine-sync-target="headerNav">
<ul>
{% for category in cats %}
<li><twig:Molecules:CategoryLink :category="category" /></li>
{% endfor %}

178
templates/components/Organisms/Comments.html.twig

@ -2,42 +2,55 @@ @@ -2,42 +2,55 @@
{% if ctx and ctx.can_publish|default(false) and ctx.rows|default([])|length > 0 %}
{% for row in ctx.rows %}
{% if row.mode|default('') == 'article' %}
<div class="comment-reply comment-reply--article card">
<div class="card-body">
<h3 class="comment-reply__heading">Reply to this article</h3>
<form
class="comment-reply__form"
data-controller="comment-reply"
data-action="submit->comment-reply#publish"
data-comment-reply-publish-url-value="{{ path('comment_reply_publish')|e('html_attr') }}"
data-comment-reply-csrf-value="{{ csrf_token('comment_reply')|e('html_attr') }}"
data-comment-reply-expected-coordinate-value="{{ ctx.coordinate|e('html_attr') }}"
data-comment-reply-article-event-id-value="{{ ctx.article_event_id|default('')|e('html_attr') }}"
data-comment-reply-fragment-url-value="{{ ctx.fragment_url|default('')|e('html_attr') }}"
data-comment-reply-refresh-after-value="1"
data-comment-reply-blurb-label-value="{{ row.blurbLabel|default('Article')|e('html_attr') }}"
data-comment-reply-expected-tags-value='{{ row.expectedTags|default([])|json_encode|e('html_attr') }}'
data-comment-reply-parent-kind-value="{{ row.parentKind|default(0) }}"
data-comment-reply-parent-id-value="{{ row.parentId|default('')|e('html_attr') }}"
data-comment-reply-author-pubkey-value="{{ row.authorPubkey|default('')|e('html_attr') }}"
>
<div class="comment-reply__body">
<label class="visually-hidden" for="comment-reply-article-body">Your reply</label>
<textarea
class="form-control"
id="comment-reply-article-body"
name="body"
rows="4"
required
minlength="1"
placeholder="Write a Nostr comment (kind 1111). A quoted parent line is added automatically."
></textarea>
</div>
<div class="comment-reply__actions">
<button class="btn btn-primary" type="submit">Sign &amp; publish</button>
</div>
<p class="comment-reply__hint text-subtle" data-comment-reply-target="hint" aria-live="polite"></p>
</form>
<div
class="comment-reply comment-reply--article card"
data-controller="comment-reply"
data-comment-reply-publish-url-value="{{ path('comment_reply_publish')|e('html_attr') }}"
data-comment-reply-csrf-value="{{ csrf_token('comment_reply')|e('html_attr') }}"
data-comment-reply-expected-coordinate-value="{{ ctx.coordinate|e('html_attr') }}"
data-comment-reply-article-event-id-value="{{ ctx.article_event_id|default('')|e('html_attr') }}"
data-comment-reply-fragment-url-value="{{ ctx.fragment_url|default('')|e('html_attr') }}"
data-comment-reply-refresh-after-value="1"
data-comment-reply-blurb-label-value="{{ row.blurbLabel|default('Article')|e('html_attr') }}"
data-comment-reply-expected-tags-value='{{ row.expectedTags|default([])|json_encode|e('html_attr') }}'
data-comment-reply-parent-kind-value="{{ row.parentKind|default(0) }}"
data-comment-reply-parent-id-value="{{ row.parentId|default('')|e('html_attr') }}"
data-comment-reply-author-pubkey-value="{{ row.authorPubkey|default('')|e('html_attr') }}"
>
<div class="card-body comment-reply--article__inner">
<div class="comment-reply__toolbar">
<p class="comment-reply__lede text-subtle">Reply to this note on Nostr (kind 1111).</p>
<button
type="button"
class="btn btn-secondary btn-sm comment-reply__toggle"
data-comment-reply-target="toggleBtn"
data-action="click->comment-reply#togglePanel"
aria-expanded="false"
>Reply</button>
</div>
<div class="comment-reply__panel comment-reply__panel--hidden" data-comment-reply-target="panel">
<form
class="comment-reply__form"
data-action="submit->comment-reply#publish"
>
<div class="comment-reply__body">
<label class="visually-hidden" for="comment-reply-article-body">Your reply</label>
<textarea
class="form-control"
id="comment-reply-article-body"
name="body"
rows="4"
required
minlength="1"
placeholder="Write a NIP-22 comment (kind 1111). A quoted parent line is added when you publish."
></textarea>
</div>
<div class="comment-reply__actions">
<button class="btn btn-primary" type="submit">Sign &amp; publish</button>
</div>
<p class="comment-reply__hint text-subtle" data-comment-reply-target="hint" aria-live="polite"></p>
</form>
</div>
</div>
</div>
{% endif %}
@ -49,7 +62,8 @@ @@ -49,7 +62,8 @@
{% set cid = item.id|default('') %}
{% set cpk = item.pubkey|default('') %}
{% set cts = item.created_at|default(null) %}
<div class="card comment">
{% set cdepth = item.unfold_depth|default(0) %}
<div class="card comment comment--depth-{{ cdepth }}">
<div class="metadata">
<p>
{% if item.kind is defined and item.kind == 1 %}
@ -61,8 +75,13 @@ @@ -61,8 +75,13 @@
</p>
<small>{% if cts is not null and cts != '' %}{{ cts|date('F j Y') }}{% endif %}</small>
</div>
{% 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">
<twig:Atoms:Content content="{{ item.content|default('') }}" />
<twig:Atoms:Content content="{{ item.unfold_body|default(item.content|default('')) }}" />
</div>
{% if cid != '' and commentLinks[cid] is defined and commentLinks[cid]|length > 0 %}
<div class="card-footer nostr-previews mt-3">
@ -78,41 +97,54 @@ @@ -78,41 +97,54 @@
{% if ctx and ctx.can_publish|default(false) and item.kind|default(0) == 1111 %}
{% for row in ctx.rows|default([]) %}
{% if row.mode|default('') == 'comment' and row.parentId|default('') == cid %}
<div class="comment-reply comment-reply--nested">
<form
class="comment-reply__form"
data-controller="comment-reply"
data-action="submit->comment-reply#publish"
data-comment-reply-publish-url-value="{{ path('comment_reply_publish')|e('html_attr') }}"
data-comment-reply-csrf-value="{{ csrf_token('comment_reply')|e('html_attr') }}"
data-comment-reply-expected-coordinate-value="{{ ctx.coordinate|e('html_attr') }}"
data-comment-reply-article-event-id-value="{{ ctx.article_event_id|default('')|e('html_attr') }}"
data-comment-reply-fragment-url-value="{{ ctx.fragment_url|default('')|e('html_attr') }}"
data-comment-reply-refresh-after-value="1"
data-comment-reply-blurb-label-value="{{ row.blurbLabel|default('Comment')|e('html_attr') }}"
data-comment-reply-expected-tags-value='{{ row.expectedTags|default([])|json_encode|e('html_attr') }}'
data-comment-reply-parent-kind-value="{{ row.parentKind|default(0) }}"
data-comment-reply-parent-id-value="{{ row.parentId|default('')|e('html_attr') }}"
data-comment-reply-author-pubkey-value="{{ row.authorPubkey|default('')|e('html_attr') }}"
>
<div class="comment-reply__head text-subtle">Reply to this note</div>
<div class="comment-reply__body">
<label class="visually-hidden" for="comment-reply-{{ cid }}">Your reply</label>
<textarea
class="form-control"
id="comment-reply-{{ cid }}"
name="body"
rows="3"
required
minlength="1"
placeholder="Sign with your Nostr extension (kind 1111)…"
></textarea>
</div>
<div class="comment-reply__actions">
<button class="btn btn-primary" type="submit">Sign &amp; publish</button>
</div>
<p class="comment-reply__hint text-subtle" data-comment-reply-target="hint" aria-live="polite"></p>
</form>
<div
class="comment-reply comment-reply--nested"
data-controller="comment-reply"
data-comment-reply-publish-url-value="{{ path('comment_reply_publish')|e('html_attr') }}"
data-comment-reply-csrf-value="{{ csrf_token('comment_reply')|e('html_attr') }}"
data-comment-reply-expected-coordinate-value="{{ ctx.coordinate|e('html_attr') }}"
data-comment-reply-article-event-id-value="{{ ctx.article_event_id|default('')|e('html_attr') }}"
data-comment-reply-fragment-url-value="{{ ctx.fragment_url|default('')|e('html_attr') }}"
data-comment-reply-refresh-after-value="1"
data-comment-reply-blurb-label-value="{{ row.blurbLabel|default('Comment')|e('html_attr') }}"
data-comment-reply-expected-tags-value='{{ row.expectedTags|default([])|json_encode|e('html_attr') }}'
data-comment-reply-parent-kind-value="{{ row.parentKind|default(0) }}"
data-comment-reply-parent-id-value="{{ row.parentId|default('')|e('html_attr') }}"
data-comment-reply-author-pubkey-value="{{ row.authorPubkey|default('')|e('html_attr') }}"
>
<div class="comment-reply__toolbar comment-reply__toolbar--inline">
<button
type="button"
class="btn btn-secondary btn-sm comment-reply__toggle"
data-comment-reply-target="toggleBtn"
data-action="click->comment-reply#togglePanel"
aria-expanded="false"
>Reply</button>
</div>
<div class="comment-reply__panel comment-reply__panel--hidden" data-comment-reply-target="panel">
<form
class="comment-reply__form"
data-action="submit->comment-reply#publish"
>
<div class="comment-reply__head text-subtle">Reply to this note</div>
<div class="comment-reply__body">
<label class="visually-hidden" for="comment-reply-{{ cid }}">Your reply</label>
<textarea
class="form-control"
id="comment-reply-{{ cid }}"
name="body"
rows="3"
required
minlength="1"
placeholder="NIP-22 comment; parent quote line is added on publish…"
></textarea>
</div>
<div class="comment-reply__actions">
<button class="btn btn-primary" type="submit">Sign &amp; publish</button>
</div>
<p class="comment-reply__hint text-subtle" data-comment-reply-target="hint" aria-live="polite"></p>
</form>
</div>
</div>
{% endif %}
{% endfor %}

16
templates/home.html.twig

@ -6,8 +6,6 @@ @@ -6,8 +6,6 @@
<meta name="description" content="{{ website_description|e('html_attr') }}">
{% endblock %}
{% block magazine_sync_page %}home{% endblock %}
{% block ogtags %}
{% set _og_image = absolute_url(asset('og-image.jpg')) %}
<link rel="canonical" href="{{ url('home') }}">
@ -28,19 +26,7 @@ @@ -28,19 +26,7 @@
{% endblock %}
{% block body %}
<div class="home-subscribe" aria-label="Sitemap and syndication">
<h2 class="home-subscribe__title">Sitemap and feeds</h2>
<p class="home-subscribe__hint">For search engines and feed readers. Atom is supported by most clients.</p>
<div class="home-subscribe__actions">
<a class="btn btn-secondary" href="{{ path('sitemap') }}">Sitemap (XML)</a>
<a class="btn btn-secondary" href="{{ path('robots_txt') }}">Robots</a>
<a class="btn btn-secondary" href="{{ path('feed_magazine') }}">Atom — all articles</a>
{% for c in categories_for_feed %}
<a class="btn btn-secondary" href="{{ path('feed_category', {slug: c.slug}) }}">Atom — {{ c.title }}</a>
{% endfor %}
</div>
</div>
<div class="home-body" data-magazine-sync-target="pageBody">
<div class="home-body">
{% for item in indices %}
<twig:Organisms:FeaturedList :category="item" class="featured-list"/>
{% endfor %}

13
templates/pages/article.html.twig

@ -125,10 +125,19 @@ @@ -125,10 +125,19 @@
<section class="article-comments-async" id="article-comments" aria-label="Comments">
<div
data-controller="article-comments"
data-article-comments-wrapper
data-article-comments-url-value="{{ path('article_comments_fragment', comments_query)|e('html_attr') }}"
data-article-comments-preloaded-value="{{ (comments_preloaded|default(false)) ? 'true' : 'false' }}"
>
<div data-article-comments-target="container" class="comments comments--pending">
<p class="text-subtle">Loading comments…</p>
<div
data-article-comments-target="container"
class="comments {{ comments_preloaded|default(false) ? 'comments--from-cache' : 'comments--pending' }}"
>
{% if comments_preloaded|default(false) and comments_data is defined and comments_data is not null %}
{% include 'components/Organisms/Comments.html.twig' with comments_data %}
{% else %}
<p class="text-subtle">Loading comments…</p>
{% endif %}
</div>
</div>
</section>

70
templates/pages/author.html.twig

@ -1,68 +1,16 @@ @@ -1,68 +1,16 @@
{% extends 'base.html.twig' %}
{% block body %}
{% set author_pic = null %}
{% if author.picture is defined and author.picture %}
{% set author_pic = author.picture %}
{% elseif author.image is defined and author.image %}
{% set author_pic = author.image %}
{% endif %}
{% set author_label = author.display_name|default(author.name|default(npub|shortenNpub)) %}
<div class="author-profile">
{% if author_pic %}
<div class="author-profile__avatar">
<img src="{{ author_pic }}" alt="{{ author_label }}" loading="lazy" decoding="async" onerror="this.parentElement.remove()" />
</div>
{% endif %}
<h1 class="author-profile__title"><twig:Atoms:NameOrNpub :author="author" :npub="npub"></twig:Atoms:NameOrNpub></h1>
<div class="author-profile__header-meta">
{% if profile_websites is not empty %}
<ul class="author-profile__identity" aria-label="Websites">
{% for row in profile_websites %}
<li class="author-profile__identity-row">
<span class="author-profile__identity-type">Website</span>
<a class="author-profile__identity-link" href="{{ row.href|e('html_attr') }}" rel="nofollow noopener">{{ row.label|e }}</a>
</li>
{% endfor %}
</ul>
{% endif %}
{% if profile_nip05 is not empty %}
<ul class="author-profile__identity" aria-label="NIP-05">
{% for row in profile_nip05 %}
<li class="author-profile__identity-row">
<span class="author-profile__identity-type">NIP-05</span>
<a class="author-profile__identity-link" href="{{ row.href|e('html_attr') }}" rel="nofollow noopener" title="NIP-05 verification document">{{ row.label|e }}</a>
</li>
{% endfor %}
</ul>
{% endif %}
{% if profile_payment_links is not empty %}
<ul class="author-profile__payments" aria-label="Payment options">
{% for row in profile_payment_links %}
<li class="author-profile__payment">
<span class="author-profile__payment-type">{{ row.type_label }}</span>
<a class="author-profile__payment-link" href="{{ row.href|e('html_attr') }}" rel="nofollow noopener">{{ row.label|e }}</a>
</li>
{% endfor %}
</ul>
{% endif %}
</div>
<div class="author-profile__about">
{% if author.about is defined %}
{{ author.about|markdown_to_html|mentionify|linkify }}
{% endif %}
</div>
{% if jumble_profile_href is not null and jumble_profile_href != '' %}
<p class="author-profile__jumble">
<a class="btn btn-secondary" href="{{ jumble_profile_href|e('html_attr') }}" rel="nofollow noopener">View on Jumble</a>
</p>
{% endif %}
{% include 'partial/author_profile_header.html.twig' with {
author: author,
npub: npub,
show_nip05: true,
profile_websites: profile_websites,
profile_nip05: profile_nip05,
profile_payment_links: profile_payment_links,
jumble_profile_href: jumble_profile_href,
} only %}
<hr class="author-profile__divider" />
<twig:Organisms:CardList :list="articles" class="article-list"></twig:Organisms:CardList>

4
templates/pages/category.html.twig

@ -1,7 +1,5 @@ @@ -1,7 +1,5 @@
{% extends 'base.html.twig' %}
{% block magazine_sync_page %}{% if app.request.attributes.get('_route') == 'articles' %}articles{% else %}category{% endif %}{% endblock %}
{% block magazine_sync_slug %}{{ (sync_slug|default(''))|e('html_attr') }}{% endblock %}
{% block title %}{{ (category.title|default(''))|trim != '' ? category.title|trim ~ ' — ' ~ website_name : website_name }}{% endblock %}
@ -31,7 +29,7 @@ @@ -31,7 +29,7 @@
{% endblock %}
{% block body %}
<div class="category-body" data-magazine-sync-target="pageBody">
<div class="category-body">
<twig:Organisms:CardList :list="list" class="article-list" />
</div>
{% endblock %}

43
templates/pages/featured_authors.html.twig

@ -0,0 +1,43 @@ @@ -0,0 +1,43 @@
{% extends 'base.html.twig' %}
{% block body %}
<div class="featured-authors">
<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
<abbr title="NIP-05">NIP-05</abbr> identifier
{% if nip05_domain|default('')|trim != '' %}
under <strong>{{ nip05_domain|e }}</strong>
{% endif %}
for easier discovery. Verification uses <code>/.well-known/nostr.json</code>.
</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,
jumble_profile_href: row.jumble_profile_href,
} only %}
</div>
<p class="featured-authors__more">
<a class="btn btn-secondary" href="{{ path('author-profile', { npub: row.npub }) }}">Full profile</a>
</p>
</article>
{% 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 %}
</div>
{% endblock %}
{% block aside %}
{% endblock %}

67
templates/partial/author_profile_header.html.twig

@ -0,0 +1,67 @@ @@ -0,0 +1,67 @@
{# Shared author “header” + about (no article list). Expects: author, npub, profile_*, jumble_profile_href; show_nip05: true on full /p/ profile only #}
{% set author_pic = null %}
{% if author.picture is defined and author.picture %}
{% set author_pic = author.picture %}
{% elseif author.image is defined and author.image %}
{% set author_pic = author.image %}
{% endif %}
{% set author_label = author.display_name|default(author.name|default(npub|shortenNpub)) %}
{% if author_pic %}
<div class="author-profile__avatar">
<img src="{{ author_pic }}" alt="{{ author_label }}" loading="lazy" decoding="async" onerror="this.parentElement.remove()" />
</div>
{% endif %}
{% set header_tag = header_tag|default('h1') %}
<{{ header_tag }} class="author-profile__title"><twig:Atoms:NameOrNpub :author="author" :npub="npub"></twig:Atoms:NameOrNpub></{{ header_tag }}>
<div class="author-profile__header-meta">
{% if profile_websites is not empty %}
<ul class="author-profile__identity" aria-label="Websites">
{% 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') }}" rel="nofollow noopener">{{ row.label|e }}</a>
</li>
{% endfor %}
</ul>
{% endif %}
{% if show_nip05|default(false) and profile_nip05 is not empty %}
<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 %}
<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>
<div class="author-profile__about">
{% if author.about is defined %}
{{ author.about|markdown_to_html|mentionify|linkify }}
{% endif %}
</div>
{% if jumble_profile_href is not null and jumble_profile_href != '' %}
<p class="author-profile__jumble">
<a class="btn btn-secondary" href="{{ jumble_profile_href|e('html_attr') }}" target="_blank" rel="nofollow noopener noreferrer">View on Jumble</a>
</p>
{% endif %}

3
templates/ux/magazine/category_body.html.twig

@ -1,3 +0,0 @@ @@ -1,3 +0,0 @@
<div class="category-body" data-magazine-sync-target="pageBody">
<twig:Organisms:CardList :list="list" class="article-list" />
</div>

10
templates/ux/magazine/header_ul.html.twig

@ -1,10 +0,0 @@ @@ -1,10 +0,0 @@
<ul data-magazine-sync-target="headerNav">
{% for category in cats %}
<li><twig:Molecules:CategoryLink :category="category" /></li>
{% endfor %}
{% if magazine_community_articles %}
<li>
<a href="{{ path('articles') }}">Latest Articles</a>
</li>
{% endif %}
</ul>

5
templates/ux/magazine/home_body.html.twig

@ -1,5 +0,0 @@ @@ -1,5 +0,0 @@
<div class="home-body" data-magazine-sync-target="pageBody">
{% for item in indices %}
<twig:Organisms:FeaturedList :category="item" class="featured-list"/>
{% endfor %}
</div>
Loading…
Cancel
Save