Compare commits

..

No commits in common. '26eab2b3c357ec935c64c6005ad67e6fbfa79422' and 'cf8c3e8c2a74e0452d2b5891b66a02fe86ca2622' have entirely different histories.

  1. 7
      .env.dist
  2. 87
      assets/controllers/article_comments_controller.js
  3. BIN
      assets/laeserin_logo.png
  4. 53
      assets/styles/app.css
  5. 7
      assets/styles/article.css
  6. 3
      assets/theme/local/.gitignore
  7. BIN
      assets/theme/local/icons/apple-touch-icon.png
  8. BIN
      assets/theme/local/icons/favicon-96x96.png
  9. BIN
      assets/theme/local/icons/favicon.ico
  10. BIN
      assets/theme/local/icons/web-app-manifest-192x192.png
  11. BIN
      assets/theme/local/icons/web-app-manifest-512x512.png
  12. BIN
      assets/theme/local/og-image.jpg
  13. 75
      assets/theme/local/theme-dark.css
  14. 20
      assets/theme/local/theme.css
  15. 7
      compose.hub.yaml
  16. 48
      config/unfold.yaml
  17. 30
      src/Controller/ArticleController.php
  18. 8
      src/Service/MagazineContentService.php
  19. 7
      src/Service/NostrClient.php
  20. 10
      src/Service/NostrRelayFanoutTransport.php
  21. 6
      src/Service/NostrRelayRequestFactory.php
  22. 4
      src/Twig/ArticleCardCoverExtension.php
  23. 3
      templates/components/Molecules/Card.html.twig
  24. 3
      templates/components/Organisms/Comments.html.twig
  25. 3
      templates/components/Organisms/FeaturedList.html.twig
  26. 3
      templates/components/Organisms/FeaturedWall.html.twig
  27. 3
      templates/components/Organisms/HomeMagazineArticleStrip.html.twig

7
.env.dist

@ -22,7 +22,7 @@ APP_SECRET=9e287f1ad737386dde46d51e80487236 @@ -22,7 +22,7 @@ APP_SECRET=9e287f1ad737386dde46d51e80487236
###< symfony/framework-bundle ###
###> docker ###
# Dev URL: http://127.0.0.1:${HTTP_PORT}/ (override HTTP_PORT/HTTPS_PORT if busy).
HTTP_PORT=9085
HTTP_PORT=9080
HTTPS_PORT=9443
# SERVER_NAME=:80
# If MYSQL_* changed after the DB volume exists: docker compose down -v (wipes data), then up.
@ -46,8 +46,9 @@ MYSQL_ROOT_PASSWORD=root_password @@ -46,8 +46,9 @@ MYSQL_ROOT_PASSWORD=root_password
# PREWARM_FLAGS=
# Comma-separated magazine category #d slugs to refresh first when app:prewarm runs out of time before all categories (see MagazineRefresher).
# MAGAZINE_PREWARM_PREFER_SLUGS=
# compose.hub.yaml: Apache reverse-proxies to 127.0.0.1:9085 (gitcitadel.imwald.eu vhost).
HTTP_PUBLISH=127.0.0.1:9085
# compose.hub.yaml: default host port is 9080. Use 80 only if nothing else binds it. Loopback-only example:
# HTTP_PUBLISH=127.0.0.1:9080
# HTTP_PUBLISH=80
# Optional: silence verbose Symfony deprecation output in the CLI. See Symfony docs for values (max[direct]=N, etc.).
# SYMFONY_DEPRECATIONS_HELPER=weak
# Optional: Nostr per-relay WebSocket timeout in seconds. Default: `nostr_relay_request_timeout_sec` in config/unfold.yaml

87
assets/controllers/article_comments_controller.js

@ -1,16 +1,7 @@ @@ -1,16 +1,7 @@
import { Controller } from '@hotwired/stimulus';
/**
* Two-phase comment loading:
*
* Phase 1 fires ?cached=1 immediately, shows whatever is in the server-side cache
* without touching any Nostr relays (< 100 ms on a warm cache).
*
* Phase 2 fires the full URL in parallel, which does relay I/O. When it resolves
* it replaces the Phase-1 content. If Phase 2 finishes first (e.g. the
* cached response was held up by DNS), Phase 1 is silently discarded.
*
* Result: readers always see something quickly; fresh relay data appears when ready.
* Fetches the comment thread HTML after the article shell has rendered (no relay I/O on first paint).
*/
export default class extends Controller {
static values = {
@ -22,7 +13,8 @@ export default class extends Controller { @@ -22,7 +13,8 @@ export default class extends Controller {
connect() {
this.partialReloads = 0;
// Stable reference across reconnects: rebinding each connect() would strand old listeners.
// Stable reference across reconnects: rebinding each connect() would strand old listeners
// because removeEventListener must use the same function reference that was passed to add.
this.boundOnAuth ??= this.onAuthChanged.bind(this);
window.removeEventListener('unfold:auth-changed', this.boundOnAuth);
window.addEventListener('unfold:auth-changed', this.boundOnAuth);
@ -30,8 +22,9 @@ export default class extends Controller { @@ -30,8 +22,9 @@ export default class extends Controller {
return;
}
if (this.preloadedValue) {
// Article SSR already included comments (cache hit at render time). Do not re-fetch;
// a slow relay request would only replace working HTML. Auth changes may still reload.
// 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();
@ -50,30 +43,13 @@ export default class extends Controller { @@ -50,30 +43,13 @@ export default class extends Controller {
void this.load();
}
/** Append ?cb=<timestamp> (and optional extras) to bust HTTP caches. */
buildFetchUrl(extra = '') {
buildFetchUrl() {
const u = this.urlValue;
const parts = [`cb=${Date.now()}`, extra].filter(Boolean);
const qs = parts.join('&');
return u.includes('?') ? `${u}&${qs}` : `${u}?${qs}`;
const bust = `cb=${Date.now()}`;
return u.includes('?') ? `${u}&${bust}` : `${u}?${bust}`;
}
async load(isPartialRetry = false) {
// Track whether Phase 2 has already written to the DOM so Phase 1 never clobbers it.
this._fullFetchDone = false;
// Only reset the partial-retry counter on a fresh top-level load, not on retries triggered
// by a partial result — otherwise the counter resets every call and the retry loop never stops.
if (!isPartialRetry) {
this.partialReloads = 0;
}
// Phase 1: fire a cache-only request in the background — completes in < 100 ms.
// Skip on partial retries: the container already has content; Phase 1 would overwrite it.
if (!isPartialRetry) {
void this._showCachedVersion();
}
// Phase 2: full relay fetch — replaces Phase 1 output when it resolves.
async load() {
const t0 = performance.now();
const perAttemptMs = 45_000;
const maxAttempts = 3;
@ -81,6 +57,7 @@ export default class extends Controller { @@ -81,6 +57,7 @@ export default class extends Controller {
const controller = new AbortController();
const timer = window.setTimeout(() => controller.abort(), perAttemptMs);
try {
// Avoid a stale 60s-cached "guest" fragment right after login (see comments fragment headers).
const res = await fetch(this.buildFetchUrl(), {
signal: controller.signal,
cache: 'no-store',
@ -91,26 +68,27 @@ export default class extends Controller { @@ -91,26 +68,27 @@ export default class extends Controller {
throw new Error(`HTTP ${res.status}`);
}
const html = await res.text();
window.clearTimeout(timer);
if (!this.hasContainerTarget) {
window.clearTimeout(timer);
return;
}
this._fullFetchDone = true;
this.containerTarget.innerHTML = html;
const isPartial = /data-comments-partial="1"/.test(html);
if (isPartial && this.partialReloads < 2) {
this.partialReloads += 1;
window.setTimeout(() => {
if (this.hasContainerTarget) {
void this.load(true);
void this.load();
}
}, 1200);
}
const ms = Math.round(performance.now() - t0);
console.debug(
`[article-comments] relay fetch OK in ${ms}ms${attempt > 1 ? ` (attempt ${attempt})` : ''}`,
this.urlValue,
);
if (attempt > 1) {
console.debug(`[article-comments] fragment OK in ${ms}ms (after ${attempt} attempts)`, this.urlValue);
} else {
console.debug(`[article-comments] fragment OK in ${ms}ms`, this.urlValue);
}
window.clearTimeout(timer);
return;
} catch (err) {
window.clearTimeout(timer);
@ -123,35 +101,12 @@ export default class extends Controller { @@ -123,35 +101,12 @@ export default class extends Controller {
continue;
}
const ms = Math.round(performance.now() - t0);
console.warn(`[article-comments] relay fetch failed after ${ms}ms`, this.urlValue, err);
// Only show the error if Phase 1 hasn't already displayed something useful.
if (this.hasContainerTarget && !this._fullFetchDone) {
console.warn(`[article-comments] fragment failed after ${ms}ms`, this.urlValue, err);
if (this.hasContainerTarget) {
this.containerTarget.innerHTML =
'<p class="text-subtle">Comments could not be loaded.</p>';
}
}
}
}
/** Phase 1: return the server's cached copy immediately, without doing any relay I/O. */
async _showCachedVersion() {
try {
const res = await fetch(this.buildFetchUrl('cached=1'), {
cache: 'no-store',
credentials: 'same-origin',
headers: { Accept: 'text/html', 'X-Requested-With': 'XMLHttpRequest' },
});
if (!res.ok || !this.hasContainerTarget || this._fullFetchDone) {
return;
}
const html = await res.text();
// Re-check: Phase 2 may have landed while we were awaiting the body.
if (!this.hasContainerTarget || this._fullFetchDone) {
return;
}
this.containerTarget.innerHTML = html;
} catch {
// Ignore; Phase 2 will fill the container regardless.
}
}
}

BIN
assets/laeserin_logo.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 KiB

After

Width:  |  Height:  |  Size: 1.1 MiB

53
assets/styles/app.css

@ -451,17 +451,17 @@ svg.icon { @@ -451,17 +451,17 @@ svg.icon {
transform: scale(1.06);
}
/* Suppress hover zoom when using the branded fallback — the faded portrait shouldn't animate */
.featured-list--picture-grid .featured-tile--picture-block:has(.card-header--no-cover) .featured-tile__picture-img {
.featured-list--picture-grid .featured-tile__picture-img[src*="favicon-96x96"] {
object-fit: contain;
padding: 2rem;
box-sizing: border-box;
transform: none;
transition: none;
}
.featured-list--picture-grid .featured-tile__picture-scrim {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 2;
background: linear-gradient(
to top,
color-mix(in srgb, #0a0a0a 88%, transparent) 0%,
@ -476,7 +476,7 @@ svg.icon { @@ -476,7 +476,7 @@ svg.icon {
right: 0;
bottom: 0;
padding: 0.65rem 0.85rem 0.75rem;
z-index: 3;
z-index: 1;
display: flex;
flex-direction: column;
gap: 0.28rem;
@ -736,35 +736,12 @@ svg.icon { @@ -736,35 +736,12 @@ svg.icon {
text-underline-offset: 2px;
}
/* Fallback hero: landscape logo at low opacity with a diagonal stripe overlay.
Applied to any card container (.card-header, .featured-tile__media, etc.) when
the article has no cover image. Logo is shown in full (contain + center). */
.card-header--no-cover {
position: relative;
overflow: hidden;
background-color: var(--color-bg-light);
}
.card-header--no-cover img {
opacity: 0.18;
/* List cards: same site-logo treatment when the hero is the default mark */
.article-list .card-header img[src*="favicon-96x96"] {
object-fit: contain;
object-position: center;
}
.card-header--no-cover::after {
content: '';
position: absolute;
inset: 0;
background:
repeating-linear-gradient(
-45deg,
transparent 0,
transparent 5px,
rgba(0, 0, 0, 0.028) 5px,
rgba(0, 0, 0, 0.028) 6px
);
pointer-events: none;
z-index: 1;
padding: 1.25rem;
box-sizing: border-box;
background: var(--color-bg-light);
}
/* Optional category label above cover (see Molecules/Card) */
@ -1147,15 +1124,19 @@ svg.icon { @@ -1147,15 +1124,19 @@ svg.icon {
}
}
/* Horizontal banner logo: landscape logo image shown in full (contain + center). */
/* Horizontal banner logo: portrait painting cropped to a wide strip that shows the
subject's face/upper body. object-position 50% 14% targets the face at the top
of the portrait; adjust the Y value if the crop drifts on a different painting. */
.header__logo-banner {
display: block;
flex-shrink: 0;
/* Width drives the banner proportions; height clips into the portrait. */
width: 140px;
height: 48px;
object-fit: contain;
object-position: center;
object-fit: cover;
object-position: 50% 14%;
border-radius: 5px;
box-shadow: 0 0 0 1px var(--color-border);
/* Prevent global img { height: auto } from overriding the fixed height. */
max-width: none;
}

7
assets/styles/article.css

@ -268,13 +268,6 @@ @@ -268,13 +268,6 @@
gap: 0.55rem;
}
/* Empty-state label when the relay fetch returned zero comments */
.comments__empty {
font-size: 0.9rem;
color: var(--color-text-mid);
margin: 0.5rem 0;
}
.comments .card.comment,
.comments-quotes__list .card.comment {
margin-left: 0;

3
assets/theme/local/.gitignore vendored

@ -1 +1,2 @@ @@ -1 +1,2 @@
# All theme/local assets are tracked in this branch (gitcitadel brand assets)
icons
theme.css

BIN
assets/theme/local/icons/apple-touch-icon.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

BIN
assets/theme/local/icons/favicon-96x96.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

BIN
assets/theme/local/icons/favicon.ico

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

BIN
assets/theme/local/icons/web-app-manifest-192x192.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

BIN
assets/theme/local/icons/web-app-manifest-512x512.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

BIN
assets/theme/local/og-image.jpg

Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 KiB

After

Width:  |  Height:  |  Size: 91 KiB

75
assets/theme/local/theme-dark.css

@ -1,75 +0,0 @@ @@ -1,75 +0,0 @@
/*
* GitCitadel dark color scheme.
* Loaded only when data-color-scheme="dark" and config theme_stylesheet_dark is set.
* Palette mirrors gitcitadel-online/static/css/main.css exactly:
* bg-primary #2d2d2d, bg-secondary #1e1e1e, accent #7c9eff, hover #9bb3ff, visited #a58fff.
*/
html[data-color-scheme="dark"] {
/* Backgrounds */
--color-bg: #1e1e1e; /* --bg-secondary from gitcitadel-online */
--color-bg-light: #2d2d2d; /* --bg-primary */
--color-bg-primary: #3a3a3a; /* Slightly lifted surface */
/* Text */
--color-text: #f0f0f0; /* --text-primary */
--color-text-mid: #c0c0c0; /* --text-secondary */
--color-text-contrast: #1e1e1e;
/* Accents — periwinkle/lavender from gitcitadel-online */
--color-primary: #7c9eff; /* --accent-color / --link-color */
--color-secondary: #9bb3ff; /* --link-hover */
--color-primary-strong: #6b8bef;
--color-border: #404040; /* --border-color */
--color-border-soft: #333333;
--color-text-light: var(--color-text-mid);
--color-footer-bg: #1e1e1e;
--color-footer-text: var(--color-text);
--color-footer-link: var(--color-primary);
--color-highlight-mark-fg: #1e1e1e;
--color-link: #7c9eff;
--color-link-hover: #9bb3ff;
--color-link-visited: #a58fff; /* --link-visited: lavender-purple */
--color-focus-ring: #9bb3ff;
--color-shadow: color-mix(in srgb, #000 32%, transparent);
--brand-color: #f0f0f0;
--accent-color: var(--color-secondary);
--article-reading-pane-bg: color-mix(in srgb, var(--color-bg) 30%, var(--color-bg-light) 70%);
--article-reading-prose-color: color-mix(in srgb, var(--color-text-mid) 35%, var(--color-text) 65%);
}
html[data-color-scheme="dark"] a:visited {
color: var(--color-link-visited);
}
html[data-color-scheme="dark"] .article-main p,
html[data-color-scheme="dark"] .article-main ul,
html[data-color-scheme="dark"] .article-main ol,
html[data-color-scheme="dark"] .article-main li {
font-weight: 450;
}
html[data-color-scheme="dark"] .home-aside-highlights__item-inner {
border-left: 2px solid var(--color-primary);
padding-left: 0.5rem;
background: color-mix(in srgb, var(--color-bg-light) 55%, transparent);
}
html[data-color-scheme="dark"] .home-aside-highlights__quote {
color: #d8d4ee;
}
html[data-color-scheme="dark"] .home-aside-highlights__item-inner:hover,
html[data-color-scheme="dark"] .home-aside-highlights__item-inner:has(.home-aside-highlights__hit:focus-visible) {
background: #3a3a3a;
}
/* Reply toasts: override base article.css for dark. */
html[data-color-scheme="dark"] .reply-toast--success {
border-color: #3a8a55;
background: color-mix(in srgb, var(--color-bg) 70%, #2f7a4b 30%);
color: var(--color-text);
}
html[data-color-scheme="dark"] .reply-toast--error {
border-color: #b03030;
background: color-mix(in srgb, var(--color-bg) 70%, #a12b2b 30%);
color: var(--color-text);
}

20
assets/theme/local/theme.css

@ -1,20 +0,0 @@ @@ -1,20 +0,0 @@
/*
* GitCitadel light theme.
* Accent palette mirrors gitcitadel-online: periwinkle blue (#7c9eff) / lavender-purple (#a58fff).
* Light backgrounds use a cool lavender undertone; brand accent is a deep grape-purple.
*/
[data-theme="gitcitadel"] {
--color-bg: #f5f2fb; /* Very light lavender canvas */
--color-bg-light: #ede8f5; /* Soft lavender, slightly deeper */
--color-text: #1a1530; /* Deep purple-navy — strong contrast */
/* ≥4.5:1 on --color-bg and usable on --color-bg-light for secondary copy */
--color-text-mid: #3d3458;
--color-text-contrast: #f5f2fb;
--brand-color: #5c3d8f; /* Deep grape-purple */
--accent-color: #7c5cbf; /* Medium violet (between brand and link) */
--color-link: #5535a0; /* Periwinkle-to-violet (WCAG AA on light bg) */
--color-link-hover: #3d2278;
--color-focus-ring: #7c9eff; /* Matches gitcitadel-online --focus-color */
--color-shadow: color-mix(in srgb, var(--color-text) 10%, transparent);
}

7
compose.hub.yaml

@ -12,7 +12,8 @@ @@ -12,7 +12,8 @@
# Required in .env: APP_SECRET. Set MYSQL_* (or replace DATABASE_URL after editing this file) if you
# use the bundled database. For TLS in front, set TRUSTED_PROXIES to include your reverse proxy CIDR.
#
# Host port: Apache proxies gitcitadel.imwald.eu → 127.0.0.1:9085. HTTP_PUBLISH is set in .env.
# Host HTTP port defaults to 9080 (same idea as local dev) so Apache/nginx can keep :80. Override with
# HTTP_PUBLISH=80 or HTTP_PUBLISH=127.0.0.1:9080 in .env if needed.
#
# Build & push (on your machine or CI), e.g.:
# docker build --platform linux/amd64 --target frankenphp_prod -t silberengel/unfold:latest .
@ -20,7 +21,7 @@ @@ -20,7 +21,7 @@
#
# Override image: UNFOLD_DOCKER_IMAGE=myregistry/unfold:1.0.0 docker compose -f compose.hub.yaml up -d
name: gitcitadel
name: unfold
services:
php:
@ -37,7 +38,7 @@ services: @@ -37,7 +38,7 @@ services:
- caddy_data:/data
- caddy_config:/config
ports:
- "${HTTP_PUBLISH:-127.0.0.1:9085}:80/tcp"
- "${HTTP_PUBLISH:-9080}:80/tcp"
# Caddy/FrankenPHP only listen after the entrypoint finishes DB wait + migrations — allow a slow
# first MySQL + migrate on a small host (avoids "unhealthy" + failed `up` for dependents).
# Liveness: GET /health (see HealthController), not /.

48
config/unfold.yaml

@ -6,12 +6,12 @@ parameters: @@ -6,12 +6,12 @@ parameters:
# Per-relay WebSocket I/O (seconds) in NostrClient; also default_socket_timeout during app:prewarm.
nostr_relay_request_timeout_sec: 12
name: 'GitCitadel Homepage'
short_name: 'GitCitadel Homepage'
description: 'GitCitadel — Nostr-native open-source software development tools and infrastructure.'
name: 'Nostr, Curated Thoughtfully'
short_name: 'Imwald Blog'
description: 'A selection of my own Nostr long-form articles and articles from other authors, selected for the quality of their writing and the depth of their analysis.'
og_headline: 'GitCitadel Homepage'
og_subheading: 'Nostr-native publishing and development tools'
og_headline: 'Nostr, Curated Thoughtfully'
og_subheading: 'Imwald Blog by Laeserin'
default_relay: 'wss://theforest.nostr1.com'
# Extra wss:// URLs for article sync (articles:get), comment threads (NIP-22 / getArticleDiscussion),
@ -25,24 +25,29 @@ parameters: @@ -25,24 +25,29 @@ parameters:
profile_relays: [
'wss://profiles.nostr1.com'
]
# Example:
# article_relays:
# - 'wss://nos.lol'
# - 'wss://relay.ditto.pub'
# Magazine identity for data-theme=… (CSS hooks). Unrelated to light/dark color scheme.
theme: 'gitcitadel'
theme_color: '#5c3d8f'
theme_bg_color: '#f5f2fb'
theme: 'imwald'
theme_color: '#8c2f1c'
theme_bg_color: '#f1ebe4'
# Per–color-scheme stylesheets: logical asset names under assets/theme/{local,default}/ (see asset_mapper paths).
# Set theme_stylesheet_dark to '' to disable the footer scheme toggle and ship only one theme.
# imwald: light = editorial cream tokens; dark = second sheet (warm charcoal). Set theme_stylesheet_dark to ''
# to ship only one magazine theme and hide the footer sun/moon control.
theme_stylesheet_light: 'theme.css'
theme_stylesheet_dark: 'theme-dark.css'
npub: 'npub1s3ht77dq4zqnya8vjun5jp3p44pr794ru36d0ltxu65chljw8xjqd975wz'
npub: 'npub1m4ny6hjqzepn4rxknuq94c2gpqzr29ufkkw7ttcxyak7v43n6vvsajc2jl'
# Kind 30040 magazine root #d (NIP-33). Exposed as `d_tag` for backward compatibility.
d_tag_magazine: 'gitcitadel-homepage-f57299'
d_tag_magazine: 'newsroom-magazine-on-imwald-by-laeserin'
d_tag: '%d_tag_magazine%'
# Whether to show community articles on the home page
community_articles: false
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: 'gitcitadel.imwald.eu'
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'
# Base for event threads: {base}/{nevent1...} (NIP-19 nevent, not raw hex id).
@ -50,13 +55,16 @@ parameters: @@ -50,13 +55,16 @@ parameters:
# Comma-separated category #d slugs to fetch first in app:prewarm after the root (see MagazineRefresher).
magazine_prewarm_prefer_slugs_empty: ''
magazine_prewarm_prefer_slugs: '%env(default:magazine_prewarm_prefer_slugs_empty:MAGAZINE_PREWARM_PREFER_SLUGS)%'
# Extra category #d slugs to 30040-fetch in prewarm right after prefer (before the rest of root's a tags), so budget runs still hit new categories.
# Extra category #d slugs to 30040-fetch in prewarm right after prefer (before the rest of roots a tags), so budget runs still hit new categories.
magazine_prewarm_also_slugs_empty: ''
magazine_prewarm_also_slugs: '%env(default:magazine_prewarm_also_slugs_empty:MAGAZINE_PREWARM_ALSO_SLUGS)%'
external_links:
- title: "GitCitadel on GitHub"
url: "https://github.com/gitcitadel"
description: "GitCitadel open-source repositories."
- title: "Unfold source"
url: "https://git.imwald.eu/silberengel/unfold/src/branch/gitcitadel"
description: "This site's Unfold source (gitcitadel branch)."
- title: "Unfold"
url: "https://git.imwald.eu/silberengel/unfold/src/branch/imwald"
description: "This site’s Unfold source (imwald branch)."
- title: "Decent Newsroom"
url: "https://decentnewsroom.com/mag/newsroom-magazine-on-imwald-by-laeserin"
description: "Decentralized magazine platform. View the magazine on Decent Newsroom."
- title: "Alexandria"
url: "https://next-alexandria.gitcitadel.eu/publication/naddr/naddr1qvzqqqr4tqpzphtxf40yq9jr82xdd8cqtts5szqyx5tcndvaukhsvfmduetr85ceqqnkuethwdex7mmd94kkzemp0f5kuefddahz66tdwaskcepdvfuj6mrpv4ek2unfdcycjhwp"
description: "View the magazine on Alexandria."

30
src/Controller/ArticleController.php

@ -61,36 +61,16 @@ class ArticleController extends AbstractController @@ -61,36 +61,16 @@ class ArticleController extends AbstractController
$articleTitle = substr($articleTitle, 0, 200);
}
$headers = [
'Content-Type' => 'text/html; charset=UTF-8',
'Cache-Control' => 'private, no-store',
];
// Phase-1 fast path: return whatever is in the filesystem cache without touching relays.
// The JS fires this in parallel with the full relay request so readers see cached comments
// immediately (< 100 ms) while the relay fetch continues in the background.
if ($request->query->getBoolean('cached')) {
$cached = $loader->tryLoadFromCacheOnly($coordinate, $articleEventId);
if ($cached === null) {
// Cache miss — return an empty shell; the full relay fetch is already in flight.
// The article template already shows "Loading comments…" as the initial DOM state,
// so there is no need to repeat it here.
return new Response('<div class="comments" data-comments-partial="1"></div>', Response::HTTP_OK, $headers);
}
try {
$data = $this->enrichCommentDataWithReplyContext($cached, $coordinate, $articleEventId, $articleTitle);
return $this->render('components/Organisms/Comments.html.twig', $data, new Response('', Response::HTTP_OK, $headers));
} catch (\Throwable) {
return new Response('<div class="comments"></div>', Response::HTTP_OK, $headers);
}
}
$logger->info('http.fragment.comments_start', [
'coordinate' => $coordinate,
'article_event_hex' => $articleEventId,
]);
$headers = [
'Content-Type' => 'text/html; charset=UTF-8',
'Cache-Control' => 'private, max-age=60',
];
try {
$data = $loader->load($coordinate, $articleEventId);
$data = $this->enrichCommentDataWithReplyContext(

8
src/Service/MagazineContentService.php

@ -200,11 +200,7 @@ final class MagazineContentService @@ -200,11 +200,7 @@ final class MagazineContentService
continue;
}
$parts = explode(':', (string) $seq[1], 3);
if (\count($parts) < 3) {
continue;
}
// Only longform article authors are featured authors; skip sub-index (30040) references.
if (!\in_array((int) $parts[0], KindsEnum::longformKindValues(), true)) {
if (\count($parts) < 2) {
continue;
}
$pk = strtolower((string) $parts[1]);
@ -374,7 +370,7 @@ final class MagazineContentService @@ -374,7 +370,7 @@ final class MagazineContentService
* missing_total: int,
* entries: list<array{
* coordinate: string,
* status: 'resolved'|'missing'|'skipped',
* status: 'resolved'|'missing',
* reason: string,
* article_title?: string,
* article_slug?: string

7
src/Service/NostrClient.php

@ -833,14 +833,9 @@ class NostrClient @@ -833,14 +833,9 @@ class NostrClient
$this->logger->warning('nostr.article_discussion.sequential_fallback', [
'relays' => $forSeq,
]);
// Use a shorter per-relay timeout for the web sequential fallback so one slow
// relay does not hold up the HTTP response for 3 × 12 s = 36 s.
// CLI prewarm still uses the full configured timeout via the normal path.
$seqTimeoutSec = min(6, $this->relayFanout->getRelayRequestTimeoutSec());
$response = $this->relayFanout->sendSequential(
$this->relayListFactory->relaySetFromDistinctUrlList($forSeq),
$requestMessage,
$seqTimeoutSec
$requestMessage
);
}
}

10
src/Service/NostrRelayFanoutTransport.php

@ -35,11 +35,6 @@ final readonly class NostrRelayFanoutTransport @@ -35,11 +35,6 @@ final readonly class NostrRelayFanoutTransport
) {
}
public function getRelayRequestTimeoutSec(): int
{
return $this->relayRequestFactory->getRelayRequestTimeoutSec();
}
/**
* @param list<string> $relayUrls
*
@ -63,9 +58,9 @@ final readonly class NostrRelayFanoutTransport @@ -63,9 +58,9 @@ final readonly class NostrRelayFanoutTransport
*
* @return array<string, mixed> Same shape as {@see Request::send()}
*/
public function sendSequential(RelaySet $relaySet, RequestMessage $requestMessage, ?int $overrideTimeoutSec = null): array
public function sendSequential(RelaySet $relaySet, RequestMessage $requestMessage): array
{
$request = $this->relayRequestFactory->createTimedRequest($relaySet, $requestMessage, $overrideTimeoutSec);
$request = $this->relayRequestFactory->createTimedRequest($relaySet, $requestMessage);
return $request->send();
}
@ -172,7 +167,6 @@ final readonly class NostrRelayFanoutTransport @@ -172,7 +167,6 @@ final readonly class NostrRelayFanoutTransport
* One line per relay after {@see Request::send()}: errors vs message-type counts (EVENT, EOSE, …).
*
* @param array<string, mixed> $response
* @param int|null $overrideTimeoutSec when set, overrides the configured per-relay WebSocket timeout
*/
public function logWireResponseSummary(string $context, array $response): void
{

6
src/Service/NostrRelayRequestFactory.php

@ -26,14 +26,12 @@ final readonly class NostrRelayRequestFactory @@ -26,14 +26,12 @@ final readonly class NostrRelayRequestFactory
/**
* {@see Request::setTimeout()} drives per-relay WebSocket I/O for {@see Request::send()}.
*
* @param int|null $overrideTimeoutSec when set, uses this instead of the configured default
*/
public function createTimedRequest(RelaySet $relaySet, RequestMessage $requestMessage, ?int $overrideTimeoutSec = null): Request
public function createTimedRequest(RelaySet $relaySet, RequestMessage $requestMessage): Request
{
$request = new Request($relaySet, $requestMessage);
return $request->setTimeout($overrideTimeoutSec ?? $this->relayRequestTimeoutSec);
return $request->setTimeout($this->relayRequestTimeoutSec);
}
/**

4
src/Twig/ArticleCardCoverExtension.php

@ -19,9 +19,9 @@ final class ArticleCardCoverExtension extends AbstractExtension @@ -19,9 +19,9 @@ final class ArticleCardCoverExtension extends AbstractExtension
{
/**
* Used when the article has no image and the author has no (or no usable) NIP-01 {@see picture} URL.
* The portrait painting is shown at low opacity with a CSS pattern overlay (see `.card-header--no-cover`).
* Same asset as the header mark so empty hero slots read as the site, not a blank gray field.
*/
private const DEFAULT_PACKAGE_IMAGE = 'laeserin_logo.png';
private const DEFAULT_PACKAGE_IMAGE = 'icons/favicon-96x96.png';
private const OG_FALLBACK_PACKAGE_IMAGE = 'og-image.jpg';

3
templates/components/Molecules/Card.html.twig

@ -12,8 +12,7 @@ @@ -12,8 +12,7 @@
{% endif %}
</div>
<a href="{{ (article.pubkey and npub_from_hex(article.pubkey) != '') ? path('article', { npub: npub_from_hex(article.pubkey), slug: article.slug }) : path('article-legacy-redirect', { slug: article.slug }) }}">
{% set _no_cover = article.image|default('')|trim == '' %}
<div class="card-header{{ _no_cover ? ' card-header--no-cover' : '' }}">
<div class="card-header">
{% if category %}<small class="text-uppercase">{{ category }}</small>{% endif %}
<img
src="{{ article_card_cover(article.image, article.pubkey) }}"

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

@ -48,9 +48,6 @@ @@ -48,9 +48,6 @@
{% endif %}
<div class="comments" data-comments-partial="{{ comments_partial|default(false) ? '1' : '0' }}">
{% if list|default([])|length == 0 and not comments_partial|default(false) %}
<p class="text-subtle comments__empty">No comments yet.</p>
{% endif %}
{% for item in list %}
{% set cid = item.id|default('')|lower %}
{% set cpk = item.pubkey|default('') %}

3
templates/components/Organisms/FeaturedList.html.twig

@ -16,8 +16,7 @@ @@ -16,8 +16,7 @@
<div class="featured-tile__head">
<span class="featured-tile__cat">{{ title }}</span>
</div>
{% set _no_cover = item.image|default('')|trim == '' %}
<div class="featured-tile__media featured-tile__media--ar{{ loop.index0 % 4 }}{{ _no_cover ? ' card-header--no-cover' : '' }}">
<div class="featured-tile__media featured-tile__media--ar{{ loop.index0 % 4 }}">
<img
src="{{ article_card_cover(item.image, item.pubkey) }}"
alt="{{ ('Illustration for ' ~ item.title)|e('html_attr') }}"

3
templates/components/Organisms/FeaturedWall.html.twig

@ -18,8 +18,7 @@ @@ -18,8 +18,7 @@
href="{{ article_href }}"
aria-label="{{ (item.title ~ ' — ' ~ tile.categoryTitle)|e('html_attr') }}"
>
{% set _no_cover = item.image|default('')|trim == '' %}
<div class="featured-tile__picture{{ _no_cover ? ' card-header--no-cover' : '' }}">
<div class="featured-tile__picture">
<img
class="featured-tile__picture-img"
src="{{ article_card_cover(item.image, item.pubkey) }}"

3
templates/components/Organisms/HomeMagazineArticleStrip.html.twig

@ -14,8 +14,7 @@ @@ -14,8 +14,7 @@
{% set article_href = (item.pubkey and npub_from_hex(item.pubkey) != '') ? path('article', { npub: npub_from_hex(item.pubkey), slug: item.slug }) : path('article-legacy-redirect', { slug: item.slug }) %}
<article class="curation-article-display">
<div class="curation-article-display__pane">
{% set _no_cover = item.image|default('')|trim == '' %}
<div class="curation-article-display__media{{ _no_cover ? ' card-header--no-cover' : '' }}">
<div class="curation-article-display__media">
<a href="{{ article_href }}" tabindex="-1" aria-hidden="true">
<img
src="{{ article_card_cover(item.image, item.pubkey) }}"

Loading…
Cancel
Save