Compare commits

..

No commits in common. '111cb0df9cca4da4729e8e89c5b4a97ce05f3c3d' and 'f969bc4a57a765016344f6a337763e6b3afc8157' have entirely different histories.

  1. 7
      .env.dist
  2. 5
      Dockerfile
  3. 47
      README.md
  4. 3
      assets/app.js
  5. 10
      assets/bootstrap.js
  6. 14
      assets/controllers/article_comments_controller.js
  7. 73
      assets/controllers/comment_reply_controller.js
  8. 158
      assets/controllers/nostr_preview_controller.js
  9. 4
      assets/controllers/service-worker_controller.js
  10. 279
      assets/controllers/user_highlight_tooltip_controller.js
  11. 438
      assets/styles/app.css
  12. 265
      assets/styles/article.css
  13. 2
      assets/styles/card.css
  14. 28
      assets/styles/components/_nostr_previews.scss
  15. 5
      assets/styles/event.css
  16. 528
      assets/styles/layout.css
  17. 32
      assets/styles/nostr-previews.css
  18. 11
      assets/styles/theme.css
  19. 6
      bin/nostr_relay_request_worker.php
  20. 72
      composer.json
  21. 3046
      composer.lock
  22. 11
      config/packages/csrf.yaml
  23. 6
      config/packages/doctrine.yaml
  24. 2
      config/packages/monolog.yaml
  25. 11
      config/packages/nyholm_psr7.yaml
  26. 3
      config/packages/property_info.yaml
  27. 25
      config/services.yaml
  28. 31
      config/unfold.yaml
  29. 31
      migrations/Version20260425200000.php
  30. 22
      patches/swentel-nostr-php-symfony-debugclassloader-docblocks.patch
  31. 673
      phpstan-baseline.neon
  32. 17
      phpstan.neon.dist
  33. 5
      scripts/docker-prewarm.sh
  34. 160
      src/Command/ArticleHighlightsAuditCommand.php
  35. 386
      src/Command/PrewarmCommand.php
  36. 130
      src/Controller/ArticleController.php
  37. 49
      src/Controller/AuthorController.php
  38. 7
      src/Controller/CommentReplyController.php
  39. 14
      src/Controller/DefaultController.php
  40. 18
      src/Controller/EventController.php
  41. 46
      src/Controller/FeaturedAuthorsController.php
  42. 34
      src/Controller/SearchController.php
  43. 2
      src/Controller/SeoController.php
  44. 49
      src/Controller/TopicController.php
  45. 11
      src/Dto/FeaturedArticleCard.php
  46. 8
      src/Entity/Article.php
  47. 163
      src/Entity/ArticleHighlight.php
  48. 42
      src/Factory/ArticleFactory.php
  49. 4
      src/Form/RoleType.php
  50. 4
      src/Nostr/MagazineEventKeys.php
  51. 8
      src/Nostr/Nip19Addressable.php
  52. 120
      src/Nostr/Nip19Codec.php
  53. 88
      src/Repository/ArticleHighlightRepository.php
  54. 165
      src/Repository/ArticleRepository.php
  55. 44
      src/Repository/FeaturedAuthorRepository.php
  56. 44
      src/Security/NostrAuthenticator.php
  57. 712
      src/Service/ArticleBodyHighlightInjector.php
  58. 51
      src/Service/ArticleCommentThreadLoader.php
  59. 9
      src/Service/CacheService.php
  60. 106
      src/Service/CommentReplyService.php
  61. 86
      src/Service/FeaturedAuthorListedRows.php
  62. 86
      src/Service/FeaturedAuthorSync.php
  63. 13
      src/Service/HighlightAuthorMetadataProvider.php
  64. 106
      src/Service/HighlightSyncService.php
  65. 391
      src/Service/MagazineContentService.php
  66. 20
      src/Service/MagazineRefresher.php
  67. 73
      src/Service/Nip05VerificationService.php
  68. 4
      src/Service/Nip09DeletionApplier.php
  69. 169
      src/Service/NostrArticleDiscussionSupport.php
  70. 89
      src/Service/NostrAuthorRelayCache.php
  71. 2124
      src/Service/NostrClient.php
  72. 41
      src/Service/NostrKeyHelper.php
  73. 54
      src/Service/NostrKind5DeletionFilter.php
  74. 11
      src/Service/NostrLinkParser.php
  75. 116
      src/Service/NostrLongformArticleStore.php
  76. 36
      src/Service/NostrNip65RelayUrls.php
  77. 4
      src/Service/NostrPathHelper.php
  78. 230
      src/Service/NostrRelayFanoutTransport.php
  79. 317
      src/Service/NostrRelayListFactory.php
  80. 165
      src/Service/NostrRelayQuery.php
  81. 49
      src/Service/NostrRelayRequestFactory.php
  82. 65
      src/Service/NostrShareMenuBuilder.php
  83. 450
      src/Service/NostrWireEventMerge.php
  84. 105
      src/Service/TopicIndexService.php
  85. 96
      src/Twig/ArticleCardCoverExtension.php
  86. 1
      src/Twig/Components/IndexTabs.php
  87. 13
      src/Twig/Components/Molecules/UserFromNpub.php
  88. 4
      src/Twig/Components/Organisms/FeaturedList.php
  89. 127
      src/Twig/Components/SearchComponent.php
  90. 6
      src/Twig/MagazineJumbleExtension.php
  91. 27
      src/Twig/SidebarFeaturedAuthorsExtension.php
  92. 26
      src/Twig/TopTopicsExtension.php
  93. 9
      src/Util/CommonMark/Converter.php
  94. 15
      src/Util/CommonMark/NostrSchemeExtension/NostrBareBech32Parser.php
  95. 12
      src/Util/CommonMark/NostrSchemeExtension/NostrEventRenderer.php
  96. 13
      src/Util/CommonMark/NostrSchemeExtension/NostrSchemeExtension.php
  97. 29
      src/Util/CommonMark/NostrSchemeExtension/NostrSchemeParser.php
  98. 546
      src/Util/HighlightEventTags.php
  99. 45
      symfony.lock
  100. 8
      templates/base.html.twig
  101. Some files were not shown because too many files have changed in this diff Show More

7
.env.dist

@ -49,15 +49,10 @@ MYSQL_ROOT_PASSWORD=root_password @@ -49,15 +49,10 @@ MYSQL_ROOT_PASSWORD=root_password
# 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
# (also passed to bin/nostr_relay_request_worker.php as NOSTR_RELAY_REQUEST_TIMEOUT when the parent sets it).
# NOSTR_RELAY_REQUEST_TIMEOUT=12
###< docker ###
###> doctrine/doctrine-bundle ###
# Format described at https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url
# `serverVersion` is also set in `config/packages/doctrine.yaml` to avoid DBAL 4 "MySQL earlier than 8" deprecation noise.
# IMPORTANT: You MUST configure your server version, either here or in config/packages/doctrine.yaml
DATABASE_URL="mysql://${MYSQL_USER}:${MYSQL_PASSWORD}@database:3306/${MYSQL_DATABASE}?serverVersion=${MYSQL_VERSION}&charset=${MYSQL_CHARSET}"
###< doctrine/doctrine-bundle ###

5
Dockerfile

@ -28,12 +28,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ @@ -28,12 +28,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
cron \
&& rm -rf /var/lib/apt/lists/*
# Composer: copy from the official image instead of @composer on install-php-extensions, which
# curl's getcomposer.org and fails when build DNS is broken (e.g. curl: (6) Could not resolve host).
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
RUN set -eux; \
install-php-extensions \
@composer \
apcu \
intl \
opcache \

47
README.md

@ -4,19 +4,7 @@ @@ -4,19 +4,7 @@
<img src="assets/laeserin_logo.png" alt="Imwald" width="150">
</p>
A Symfony + FrankenPHP site that **reads Nostr long-form articles (kind 30023)** and related data from relays, and serves pages with Twig.
### Where data lives
| Data | Storage |
|------|---------|
| Published articles (30023/24) | **MySQL** `article` table (from `articles:get` / relay sync) |
| Magazine index (30040), kind-0 **profiles**, NIP-65 **relay lists** (10002) | **MySQL** `event` table with stable `core_row_key` (filled by `app:prewarm` and on-demand fetches) |
| Comment / reply / thread **UI** (fetched thread HTML, etc.) | **Filesystem cache** pool `cache.replies` (not the DB) |
| Unpublished **editor preview** payloads | **Filesystem cache** pool `cache.drafts` |
| Generic Symfony `cache.app` | Other app caches; **not** used for long-term profile or magazine index storage |
NIP-09 kind-5 deletions that target stored kinds are applied to **MySQL** (articles + `event` rows). Relays are expected to handle ephemeral thread data.
A Symfony + FrankenPHP site that **reads Nostr long-form articles (kind 30023)** and related data from relays, stores articles in **MySQL**, and serves pages with Twig. **Comments and profile metadata** are **cache-backed** (not the full source of truth in the DB).
---
@ -52,9 +40,9 @@ NIP-09 kind-5 deletions that target stored kinds are applied to **MySQL** (artic @@ -52,9 +40,9 @@ NIP-09 kind-5 deletions that target stored kinds are applied to **MySQL** (artic
---
## Backfill articles + prewarm (recommended)
## Backfill articles + warm caches (recommended)
To **migrate**, **import articles from Nostr** for a time window, then run **prewarm** (magazine + profiles + deletions + comment cache):
To **migrate**, **import articles from Nostr** for a time window, then **prewarm** magazine indices, author metadata, and comment caches:
```bash
make prewarm
@ -63,22 +51,20 @@ make prewarm @@ -63,22 +51,20 @@ make prewarm
| Step (script order) | Command / effect |
|---------------------|------------------|
| 1 | `docker compose up -d --wait` — starts **php**, **database**, and **cron** (the `cron` image runs a full `app:prewarm` on a 10 min schedule) |
| 2 | `doctrine:migrations:migrate` — applies schema (including `event` columns for core Nostr rows) |
| 3 | `articles:get -- '-2 month' 'now'` — sync long-form into the `article` table |
| 4 | `app:prewarm`NIP-09 kind-5 sync (for stored kinds), magazine **30040**`event`, kind-0 **profiles** (and relay lists on demand) → `event`, **comment** thread cache → `cache.replies` (default **`--comments-max=10`**, newest by `createdAt`) |
| 2 | `doctrine:migrations:migrate` |
| 3 | `articles:get -- '-2 month' 'now'` — sync long-form into MySQL for that window |
| 4 | `app:prewarm`magazine **30040**, **kind-0** profiles, **comment** cache (default **`--comments-max=10`**, newest by `createdAt`) |
`make prewarm` brings the stack (including `cron`) up so scheduled prewarm is active. **Optional** extra arguments for the **cron**-scheduled `app:prewarm` go in **`.env`** as **`PREWARM_FLAGS`** (same as you might pass to `php bin/console app:prewarm …`); Compose passes them into the `cron` container. Example: `PREWARM_FLAGS="--metadata-limit=50 --no-magazine"`. **Restart** the `cron` service after changing `PREWARM_FLAGS` so the container reloads the env. On the **hub** stack, the `prewarm` service reads the same `PREWARM_FLAGS`; use `docker compose -f compose.hub.yaml up -d --force-recreate prewarm` after changing it.
**Fresh database or major upgrade:** after schema changes, run **`articles:get`** + **`app:prewarm`** (or `make prewarm`) so `article` and `event` are repopulated from relays. There is no automatic migration of old PSR **profile** cache into MySQL.
---
## Console commands (overview)
| Command | Purpose |
|---------|---------|
| `articles:get <from> <to>` | Pull long-form articles from Nostr for the time range, persist to `article` |
| `app:prewarm` | Magazine 30040 + kind-0 profile prewarm (→ `event`), NIP-09 deletions, comment thread warm (→ `cache.replies`) |
| `articles:get <from> <to>` | Pull long-form articles from Nostr for the time range, persist to DB |
| `app:prewarm` | Magazine relay refresh + metadata cache + comment cache warm |
| `doctrine:migrations:migrate` | Apply SQL migrations |
| `user:elevate` | (If used) user elevation helper |
@ -88,16 +74,14 @@ make prewarm @@ -88,16 +74,14 @@ make prewarm
| Option | Default | Meaning |
|--------|---------|--------|
| `--no-magazine` | off | Skip magazine 30040 index fetch / `event` update |
| `--no-metadata` | off | Skip batched kind-0 profile prewarm (writes to `event`) |
| `--no-deletions` | off | Skip NIP-09 kind-5 fetch and application (articles + `event` index/profile rows) |
| `--deletion-since` | `-2 month` | `strtotime()` lower bound for kind-5 author-scoped fetch |
| `--no-comments` | off | Skip comment thread prewarm (`cache.replies`) |
| `--metadata-limit` | `0` (all authors) | Max distinct author pubkeys for the metadata phase |
| `--metadata-batch` | `50` | Pubkeys per batched kind-0 Nostr `REQ` |
| `--no-magazine` | off | Skip magazine 30040 index |
| `--no-metadata` | off | Skip Nostr kind-0 / profile cache |
| `--no-comments` | off | Skip comment thread cache |
| `--metadata-limit` | `0` (all authors) | Cap distinct author pubkeys |
| `--metadata-batch` | `50` | Pubkeys per batched Nostr `REQ` |
| `--comments-max` | `10` | Newest **N** articles (by `createdAt` **DESC**); `0` = all (still bounded by budget) |
| `--comments-budget` | `600` | Max wall seconds for the whole comments phase (Nostr is slow; raise e.g. `1200` if you need more articles in one run) |
| `--magazine-budget` | `90` | Max wall seconds for magazine **per-category** 30040 fetches (root is separate; cap 600s in code). If you have many categories, a **low** budget can stop before the last slug is refreshed. Set `MAGAZINE_PREWARM_PREFER_SLUGS` (comma-separated category `#d` slugs) to fetch those first after the root. |
| `--magazine-budget` | `90` | Max wall seconds for magazine root + per-category 30040 fetches (hard-capped at 600s in code). If you have many categories, a **low** budget can stop before the last slug is refreshed—**stale home/category pages** until the next run. Set `MAGAZINE_PREWARM_PREFER_SLUGS` (comma-separated category `#d` slugs) to fetch those first after the root. |
Prewarm clears the PHP **CLI** execution time limit for that run; relay work can be slow.
@ -118,8 +102,7 @@ For a full **Nostr backfill** + one-shot prewarm, use **`make prewarm`** (or a h @@ -118,8 +102,7 @@ For a full **Nostr backfill** + one-shot prewarm, use **`make prewarm`** (or a h
| Site title, `npub`, `d_tag`, **relays** (`default_relay`, `article_relays`, `profile_relays`), theme | `config/unfold.yaml` (imported as Symfony parameters) |
| `MAGAZINE_PREWARM_PREFER_SLUGS` | `.env` / `.env.local` — optional comma-separated category slugs to prioritize in `app:prewarm` magazine phase (after the root). Use when the relay time budget would otherwise skip your updated category. |
| `DATABASE_URL`, `APP_SECRET`, `HTTP_PORT`, `MYSQL_*`, optional **`PREWARM_FLAGS`** (for the Docker `cron` service) | `.env` / `.env.local` (see `.env.dist`) |
| Cache pool definitions (`cache.replies`, `cache.drafts`, `cache.app`) | `config/packages/cache.yaml` |
| Service wiring (e.g. which pool comment loaders use) | `config/services.yaml` |
| Service wiring (e.g. cache, `NostrClient` args) | `config/services.yaml` |
**Relays (short):** `default_relay` and `article_relays` drive article sync and many queries; `profile_relays` are used **first** for kind-0 / profile fetches, then the merged default + article set (see `NostrClient`).

3
assets/app.js

@ -19,3 +19,6 @@ import './styles/form.css'; @@ -19,3 +19,6 @@ import './styles/form.css';
import './styles/notice.css';
import './styles/spinner.css';
import './styles/a2hs.css';
console.log('This log comes from assets/app.js - welcome to AssetMapper! 🎉');

10
assets/bootstrap.js vendored

@ -2,11 +2,8 @@ import { startStimulusApp } from '@symfony/stimulus-bundle'; @@ -2,11 +2,8 @@ import { startStimulusApp } from '@symfony/stimulus-bundle';
import ArticleCommentsController from './controllers/article_comments_controller.js';
import CommentReplyController from './controllers/comment_reply_controller.js';
import CopyTextController from './controllers/copy_text_controller.js';
import UserHighlightTooltipController from './controllers/user_highlight_tooltip_controller.js';
const app = startStimulusApp();
if (typeof app.debug === 'boolean') {
app.debug = false;
}
// Ensure lazy comment loader is registered (Asset Mapper discovery can miss new files until rebuild).
try {
@ -24,8 +21,3 @@ try { @@ -24,8 +21,3 @@ try {
} catch {
/* already registered by the bundle */
}
try {
app.register('user-highlight-tooltip', UserHighlightTooltipController);
} catch {
/* already registered by the bundle */
}

14
assets/controllers/article_comments_controller.js

@ -12,7 +12,6 @@ export default class extends Controller { @@ -12,7 +12,6 @@ export default class extends Controller {
static targets = ['container'];
connect() {
this.partialReloads = 0;
this.boundOnAuth = this.onAuthChanged.bind(this);
window.addEventListener('unfold:auth-changed', this.boundOnAuth);
if (!this.hasContainerTarget || !this.urlValue) {
@ -68,20 +67,11 @@ export default class extends Controller { @@ -68,20 +67,11 @@ export default class extends Controller {
return;
}
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();
}
}, 1200);
}
const ms = Math.round(performance.now() - t0);
if (attempt > 1) {
console.debug(`[article-comments] fragment OK in ${ms}ms (after ${attempt} attempts)`, this.urlValue);
console.info(`[article-comments] fragment OK in ${ms}ms (after ${attempt} attempts)`, this.urlValue);
} else {
console.debug(`[article-comments] fragment OK in ${ms}ms`, this.urlValue);
console.info(`[article-comments] fragment OK in ${ms}ms`, this.urlValue);
}
window.clearTimeout(timer);
return;

73
assets/controllers/comment_reply_controller.js

@ -13,6 +13,7 @@ export default class extends Controller { @@ -13,6 +13,7 @@ export default class extends Controller {
articleEventId: String,
fragmentUrl: String,
refreshAfter: { type: Boolean, default: true },
blurbLabel: String,
expectedTags: Array,
parentKind: Number,
parentId: String,
@ -67,12 +68,16 @@ export default class extends Controller { @@ -67,12 +68,16 @@ export default class extends Controller {
return;
}
this.setHint('Preparing event…');
// `nostr-tools` entry pulls @noble/curves (bare spec → breaks in AssetMapper). NIP-19 only needs bech32 helpers.
const { naddrEncode, neventEncode } = await import('nostr-tools/nip19');
const link = this.buildParentBech32(naddrEncode, neventEncode);
// NIP-22 quote line: must still mention nostr:… for server validation; UI strips this (see formatReplyBlurbForDisplay).
const blurb = `> Replying to **${this.blurbLabelValue}** (nostr:${link})\n\n`;
const unsigned = {
kind: 1111,
created_at: Math.floor(Date.now() / 1000),
tags: this._tags,
// Keep user-authored content clean; reply context is encoded in NIP-22 tags.
content: text,
content: blurb + text,
};
let signed;
try {
@ -109,23 +114,10 @@ export default class extends Controller { @@ -109,23 +114,10 @@ export default class extends Controller {
}
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const msg = data.error || `HTTP ${res.status}`;
this.setHint(msg);
this.showToast(msg, 'error');
this.setHint(data.error || `HTTP ${res.status}`);
return;
}
const okRelaysRaw = Number(data.ok_relays);
const totalRelaysRaw = Number(data.total_relays);
const okRelays = Number.isFinite(okRelaysRaw) ? okRelaysRaw : null;
const totalRelays = Number.isFinite(totalRelaysRaw) ? totalRelaysRaw : null;
const successMsg =
okRelays !== null && totalRelays !== null
? `Published to ${okRelays}/${totalRelays} relays.`
: 'Published.';
this.setHint(successMsg);
this.showToast(successMsg, 'success');
// Keep form content until the success toast is visible.
await new Promise((r) => window.setTimeout(r, 180));
this.setHint('Published.');
if (ta) {
ta.value = '';
}
@ -146,14 +138,31 @@ export default class extends Controller { @@ -146,14 +138,31 @@ export default class extends Controller {
return typeof window.nostr !== 'undefined' && typeof window.nostr.signEvent === 'function';
}
/**
* @param {function(object): string} naddrEncode
* @param {function(object): string} neventEncode
*/
buildParentBech32(naddrEncode, neventEncode) {
const allZero = /^0{64}$/.test(this.parentIdValue);
const parts = (this.expectedCoordinateValue || '').split(':');
const k = parts[0] ? parseInt(parts[0], 10) : 30023;
const pub = parts[1] || this.authorPubkeyValue;
const d = parts[2] || '';
if (allZero && d !== '') {
return naddrEncode({ kind: k, pubkey: pub, identifier: d, relays: [] });
}
return neventEncode({
id: this.parentIdValue,
kind: this.parentKindValue,
pubkey: this.authorPubkeyValue,
relays: [],
});
}
/**
* Reload the section HTML from the article comments fragment. After publishing, relays can lag;
* if `expectedEventIdHex` is set, re-fetch with backoff until the new note appears (or a cap is hit).
*
* Only sets `innerHTML` once the response actually contains the new `data-event-id`. Assigning on
* every poll replaced the whole subtree each time and re-instantiated every Stimulus `comment-reply`
* (connect/disconnect storms) while relays were still behind.
*
* @param {string} [expectedEventIdHex] lowercase 64-char hex
*/
async refreshThread(expectedEventIdHex = '') {
@ -188,13 +197,11 @@ export default class extends Controller { @@ -188,13 +197,11 @@ export default class extends Controller {
throw new Error(String(res.status));
}
const html = await res.text();
container.innerHTML = html;
if (!wantId) {
container.innerHTML = html;
return;
}
const parsed = new DOMParser().parseFromString(html, 'text/html');
if (parsed.querySelector(`[data-event-id="${wantId}"]`)) {
container.innerHTML = html;
if (container.querySelector(`[data-event-id="${wantId}"]`)) {
return;
}
} catch {
@ -216,20 +223,4 @@ export default class extends Controller { @@ -216,20 +223,4 @@ export default class extends Controller {
this.hintTarget.textContent = msg;
}
}
showToast(message, tone = 'success') {
const el = document.createElement('div');
el.className = `reply-toast reply-toast--${tone === 'error' ? 'error' : 'success'}`;
el.setAttribute('role', 'status');
el.setAttribute('aria-live', 'polite');
el.textContent = message;
document.body.appendChild(el);
window.setTimeout(() => {
el.classList.add('reply-toast--visible');
}, 10);
window.setTimeout(() => {
el.classList.remove('reply-toast--visible');
window.setTimeout(() => el.remove(), 220);
}, 2600);
}
}

158
assets/controllers/nostr_preview_controller.js

@ -1,122 +1,74 @@ @@ -1,122 +1,74 @@
import { Controller } from '@hotwired/stimulus';
const LOADING_HTML = `<div class="nostr-preview__loading text-center my-2"><span class="nostr-preview__spinner" role="status" aria-label="Loading"></span><span class="nostr-preview__loading-text ms-2">Loading preview…</span></div>`;
const UNAVAILABLE_HTML = `<div class="alert alert-warning my-2" role="status">Preview unavailable.</div>`;
/**
* @param {HTMLElement} el
* @param {string} type
* @param {string} decodedStr
* @returns {boolean}
*/
function isPreviewForSameArticleOnPage(el, type, decodedStr) {
const root = el.closest('[data-nostr-page-article-coordinate]');
if (!root) {
return false;
}
const pageCoord = root.getAttribute('data-nostr-page-article-coordinate') || '';
const pageEid = (root.getAttribute('data-nostr-page-article-event-id') || '').toLowerCase();
const pagePubHex = (root.getAttribute('data-nostr-page-article-pubkey-hex') || '').toLowerCase();
const pageNpub = root.getAttribute('data-nostr-page-article-npub') || '';
if (!pageCoord) {
return false;
}
let d;
try {
d = JSON.parse(decodedStr);
} catch {
return false;
}
if (type === 'naddr' && d && d.pubkey != null) {
const identRaw = d.identifier != null ? d.identifier : (d.specifier != null ? d.specifier : null);
if (identRaw == null) {
return false;
}
const k = d.kind != null ? parseInt(String(d.kind), 10) : 30023;
const ident = String(identRaw);
let pk = String(d.pubkey);
if (/^[0-9a-fA-F]{64}$/.test(pk)) {
pk = pk.toLowerCase();
} else if (pk.startsWith('npub1') && pageNpub) {
if (pk !== pageNpub) {
return false;
}
pk = pagePubHex;
} else {
return false;
}
if (!pk || pk.length !== 64) {
return false;
}
const candidate = `${k}:${pk}:${ident}`;
return candidate === pageCoord;
}
if (type === 'nevent' && d && d.id && pageEid) {
return String(d.id).toLowerCase() === pageEid;
}
return false;
}
export default class extends Controller {
static values = {
identifier: String,
type: String,
decoded: String,
fullMatch: String,
};
fullMatch: String
}
static targets = ['container'];
static targets = ['container']
connect() {
if (this.typeValue === 'naddr' || this.typeValue === 'nevent') {
if (isPreviewForSameArticleOnPage(this.element, this.typeValue, this.decodedValue)) {
this.element.setAttribute('hidden', '');
this.element.setAttribute('data-nostr-preview-suppressed', 'same-page-article');
return;
}
}
this.fetchPreview();
async connect() {
await this.fetchPreview();
}
async fetchPreview() {
if (!this.hasContainerTarget) {
return;
}
this.containerTarget.innerHTML = LOADING_HTML;
try {
this.containerTarget.innerHTML = '<div class="nostr-preview__loading text-center my-2"><span class="nostr-preview__spinner" role="status" aria-label="Loading"></span><span class="nostr-preview__loading-text ms-2">Loading preview…</span></div>';
if (this.typeValue === 'url' && this.fullMatchValue) {
const res = await fetch('/og-preview/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: this.fullMatchValue }),
// Fetch OG preview for plain URLs
fetch("/og-preview/", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ url: this.fullMatchValue })
})
.then(res => {
if (!res.ok) {
throw new Error(`HTTP error! status: ${res.status}`);
}
return res.text();
})
.then(data => {
this.containerTarget.innerHTML = data;
})
.catch(error => {
console.error("Error:", error);
this.containerTarget.innerHTML = `<div class="alert alert-warning">Unable to load OG preview for ${this.fullMatchValue}</div>`;
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
this.containerTarget.innerHTML = await res.text();
return;
}
const res = await fetch('/preview/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
identifier: this.identifierValue,
type: this.typeValue,
decoded: this.decodedValue,
}),
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
} else {
// Fallback to Nostr preview
const data = {
identifier: this.identifierValue,
type: this.typeValue,
decoded: this.decodedValue
};
fetch("/preview/", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(data)
})
.then(res => {
if (!res.ok) {
throw new Error(`HTTP error! status: ${res.status}`);
}
return res.text();
})
.then(data => {
this.containerTarget.innerHTML = data;
})
.catch(error => {
console.error("Error:", error);
});
}
this.containerTarget.innerHTML = await res.text();
} catch (e) {
// NetworkError / offline: avoid console.error noise; one inline fallback per block
console.debug('nostr_preview: fetch failed', e);
this.containerTarget.innerHTML = this.typeValue === 'url' && this.fullMatchValue
? `<div class="alert alert-warning my-2" role="status">Unable to load link preview for ${this.fullMatchValue}.</div>`
: UNAVAILABLE_HTML;
} catch (error) {
console.error('Error fetching Nostr preview:', error);
this.containerTarget.innerHTML = `<div class="alert alert-warning">Unable to load preview for ${this.fullMatchValue}</div>`;
}
}
}

4
assets/controllers/service-worker_controller.js

@ -4,9 +4,7 @@ export default class extends Controller { @@ -4,9 +4,7 @@ export default class extends Controller {
connect() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/service-worker.js')
.then(() => {
/* optional: console.debug('SW registered') */
})
.then(reg => console.log('SW registered:', reg))
.catch(err => console.error('SW failed:', err));
}
}

279
assets/controllers/user_highlight_tooltip_controller.js

@ -1,279 +0,0 @@ @@ -1,279 +0,0 @@
import { Controller } from '@hotwired/stimulus';
const HIDE_MS = 180;
function el(tag, cls, parent) {
const e = document.createElement(tag);
if (cls) {
e.className = cls;
}
if (parent) {
parent.appendChild(e);
}
return e;
}
function shortNpub(n) {
if (n == null || n.length < 16) {
return n || '';
}
return `${n.slice(0, 12)}${n.slice(-6)}`;
}
/**
* In-article highlight marks: hover/focus to show a tooltip of user-badges for everyone
* who highlighted the same passage (data-hl JSON from {@see \App\Service\ArticleBodyHighlightInjector}).
*/
export default class extends Controller {
connect() {
this.tip = el('div', 'user-highlight__tip-popover', document.body);
this.tip.setAttribute('role', 'tooltip');
this.tip.setAttribute('hidden', '');
this.activeMark = null;
this._hideT = 0;
this._inTip = false;
this._onOver = (e) => {
if (!(e instanceof MouseEvent)) {
return;
}
const t = e.target;
if (!(t instanceof Node)) {
return;
}
const m =
t.nodeType === 1
? /** @type {Element} */ (t).closest('mark.user-highlight__marker[data-hl]')
: t.parentElement?.closest('mark.user-highlight__marker[data-hl]') ?? null;
if (m) {
this._cancelHide();
this._show(/** @type {HTMLElement} */ (m), e);
}
};
this._onOut = (e) => {
if (!(e instanceof MouseEvent)) {
return;
}
const t = e.target;
if (!(t instanceof Node)) {
return;
}
const m =
t.nodeType === 1
? /** @type {Element} */ (t).closest('mark.user-highlight__marker[data-hl]')
: null;
if (m) {
const to = e.relatedTarget;
if (to && (m.contains(to) || (to instanceof Node && this.tip.contains(/** @type {Node} */(to))))) {
return;
}
}
this._scheduleHide();
};
this.tip.addEventListener('mouseenter', () => {
this._inTip = true;
this._cancelHide();
});
this.tip.addEventListener('mouseleave', () => {
this._inTip = false;
this._scheduleHide();
});
this._onFocus = (e) => {
const t = e.target;
if (!(t instanceof Element)) {
return;
}
const m = t.closest('mark.user-highlight__marker[data-hl]');
if (m) {
this._cancelHide();
this._show(/** @type {HTMLElement} */ (m), e);
}
};
this._onBlur = (e) => {
const t = e.target;
if (!(t instanceof Node)) {
return;
}
const m = t.nodeType === 1 ? t.closest('mark.user-highlight__marker[data-hl]') : null;
if (m) {
const to = e.relatedTarget;
if (to && (m.contains(to) || (to instanceof Node && this.tip.contains(/** @type {Node} */ (to))))) {
return;
}
}
this._scheduleHide();
};
this.element.addEventListener('mouseover', this._onOver);
this.element.addEventListener('mouseout', this._onOut);
this.element.addEventListener('focusin', this._onFocus);
this.element.addEventListener('focusout', this._onBlur);
this._onResize = () => {
if (this.activeMark) {
this._place(this.activeMark);
}
};
window.addEventListener('resize', this._onResize);
this._onHashChange = () => {
this._scrollToHashHighlight();
};
window.addEventListener('hashchange', this._onHashChange);
this._scrollToHashHighlight();
}
/**
* Browsers are inconsistent about scrolling to #highlight-<event id> (inline marks, alias spans,
* late layout). Mirror native intent after paint.
*/
_scrollToHashHighlight() {
const hash = window.location.hash;
if (!hash?.startsWith('#highlight-')) {
return;
}
const id = decodeURIComponent(hash.slice(1));
if (!/^highlight-[a-f0-9]{64}$/i.test(id)) {
return;
}
const run = () => {
const node = document.getElementById(id);
if (!(node instanceof HTMLElement)) {
return;
}
const next = node.nextElementSibling;
const target =
node.classList.contains('user-highlight__fragment-target') &&
next?.classList?.contains('user-highlight__marker')
? next
: node;
target.scrollIntoView({ block: 'start', inline: 'nearest' });
};
requestAnimationFrame(() => {
requestAnimationFrame(run);
});
}
disconnect() {
this.element.removeEventListener('mouseover', this._onOver);
this.element.removeEventListener('mouseout', this._onOut);
this.element.removeEventListener('focusin', this._onFocus);
this.element.removeEventListener('focusout', this._onBlur);
window.removeEventListener('resize', this._onResize);
if (this._onHashChange) {
window.removeEventListener('hashchange', this._onHashChange);
}
this._cancelHide();
this.tip.remove();
}
_cancelHide() {
if (this._hideT) {
clearTimeout(this._hideT);
this._hideT = 0;
}
}
_scheduleHide() {
this._cancelHide();
this._hideT = window.setTimeout(() => {
this._hideT = 0;
if (this._inTip) {
return;
}
this._doHide();
}, HIDE_MS);
}
_doHide() {
this.tip.setAttribute('hidden', '');
this.tip.replaceChildren();
this.activeMark = null;
}
/**
* @param {HTMLElement} mark
* @param {UIEvent} _e
*/
_show(mark, _e) {
this.activeMark = mark;
const raw = mark.getAttribute('data-hl');
if (raw == null || raw === '') {
this._doHide();
return;
}
/** @type {Array<{e?: string, n: string, a?: string, p?: string}>} */
let rows;
try {
rows = JSON.parse(raw);
} catch {
this._doHide();
return;
}
if (!Array.isArray(rows) || rows.length === 0) {
this._doHide();
return;
}
this.tip.removeAttribute('hidden');
this.tip.replaceChildren();
const head = el('div', 'user-highlight__tip-head', this.tip);
head.textContent = 'Highlighted by';
const list = el('ul', 'user-highlight__tip-list', this.tip);
for (const row of rows) {
if (!row || typeof row.n !== 'string' || !row.n.startsWith('npub1')) {
continue;
}
const li = el('li', 'user-highlight__tip-item', list);
const a = el('a', 'user-badge user-badge--in-tip', li);
a.setAttribute('href', `/p/${encodeURIComponent(row.n)}`);
const label = (row.a && String(row.a).trim() !== '' ? String(row.a) : shortNpub(row.n)) || shortNpub(row.n);
const av = el('span', 'user-badge__avatar user-badge__avatar--in-tip', a);
if (row.p && typeof row.p === 'string' && row.p.length > 0) {
const im = el('img', 'user-badge__avatar-img', av);
im.setAttribute('src', row.p);
im.setAttribute('alt', '');
im.setAttribute('loading', 'lazy');
im.addEventListener('error', () => {
im.remove();
av.setAttribute('aria-hidden', 'true');
const dot = el('span', 'user-badge__avatar-fallback--dot', av);
dot.textContent = label.charAt(0).toUpperCase() || '…';
});
} else {
av.setAttribute('aria-hidden', 'true');
const dot = el('span', 'user-badge__avatar-fallback--dot', av);
dot.textContent = label.charAt(0).toUpperCase() || '…';
}
const nm = el('span', 'user-badge__name', a);
nm.appendChild(document.createTextNode(label));
}
requestAnimationFrame(() => {
this._place(mark);
});
}
/**
* @param {HTMLElement} mark
*/
_place(mark) {
const r = mark.getBoundingClientRect();
const pad = 8;
this.tip.style.position = 'fixed';
this.tip.style.zIndex = '2000';
this.tip.style.top = `${Math.round(r.bottom + pad)}px`;
let left = Math.round(r.left);
const w = this.tip.getBoundingClientRect().width || 300;
if (left + w + 12 > window.innerWidth) {
left = Math.max(8, window.innerWidth - w - 8);
}
this.tip.style.left = `${left}px`;
}
}

438
assets/styles/app.css

@ -139,240 +139,83 @@ svg.icon { @@ -139,240 +139,83 @@ svg.icon {
border-radius: 0; /* Sharp edges */
}
/* Landing (home): clear hierarchy — section label / title / excerpt / spacing */
.home-body {
display: flex;
flex-direction: column;
gap: 3.5rem;
.featured-cat {
border-bottom: 2px solid var(--color-border);
padding-left: 10px;
}
/* List pages: space header / form from content (same intent as .home-body gap) */
.search-page {
.featured-list {
display: flex;
flex-direction: column;
gap: 2.5rem;
}
.category-page__header-card {
margin-bottom: 2.5rem;
flex-direction: row;
flex-wrap: nowrap;
align-items: flex-start;
}
/* Author profile: space articles block below header+divider (header is multiple nodes; do not use flex+gap on .author-profile) */
.author-profile > .author-profile__divider {
margin: 2.5rem 0;
border: 0;
border-top: 1px solid var(--color-border);
}
/* Home featured sections: column masonry (Pinterest-style wall) + per-tile category label */
.featured-list--wall {
column-count: 1;
column-gap: 1.15rem;
column-fill: balance;
.featured-list > * {
box-sizing: border-box; /* so padding/border don't break the layout */
margin-bottom: 10px;
padding: 10px;
}
@media (min-width: 640px) {
.featured-list--wall {
column-count: 2;
@media (max-width: 1024px) {
.featured-list {
flex-direction: column !important;
}
}
@media (min-width: 1100px) {
.featured-list--wall {
column-count: 3;
.featured-list > div:first-child,
.featured-list > div:last-child {
flex: 1 1 auto;
width: 100%;
}
}
.featured-tile {
--tile-hue: 140;
break-inside: avoid;
display: block;
width: 100%;
margin: 0 0 1.15rem;
box-sizing: border-box;
min-width: 0;
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: 0.45rem;
box-shadow: 0 1px 0 color-mix(in srgb, var(--color-text) 6%, transparent);
border-top: 3px solid hsl(var(--tile-hue) 38% 40%);
overflow: hidden;
transition: box-shadow 0.2s ease, border-color 0.2s ease, transform 0.2s ease;
}
/* Home masonry tiles: global `a:hover { text-decoration: underline }` would apply; cancel + lift card on hover / focus. */
.featured-tile:has(.featured-tile__link:hover),
.featured-tile:has(.featured-tile__link:focus-visible) {
box-shadow:
0 1px 0 color-mix(in srgb, var(--color-text) 6%, transparent),
0 10px 28px color-mix(in srgb, var(--color-text) 8%, transparent);
border-color: color-mix(in srgb, var(--color-text-mid) 14%, var(--color-border) 86%);
transform: translateY(-2px);
}
.featured-tile__link {
display: block;
color: inherit;
text-decoration: none;
}
.featured-tile__link:hover,
.featured-tile__link:hover .card-title,
.featured-tile__link:hover .lede,
.featured-tile__link:hover h2,
.featured-tile__link:hover p {
text-decoration: none;
}
.featured-tile__link:focus-visible {
outline: 2px solid var(--color-focus-ring);
outline-offset: 2px;
border-radius: 0.35rem;
}
.featured-tile__head {
padding: 0.4rem 0.75rem 0.45rem;
background: color-mix(in srgb, hsl(var(--tile-hue) 34% 46%) 14%, var(--color-bg) 86%);
border-bottom: 1px solid color-mix(in srgb, hsl(var(--tile-hue) 32% 38%) 24%, var(--color-border) 76%);
}
.featured-tile__cat {
display: block;
font-family: var(--font-family), sans-serif;
font-size: 0.66rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
/* Blend hue toward body mid-gray so every tile hue stays ≥4.5:1 on dark head strip */
color: color-mix(in srgb, hsl(var(--tile-hue) 26% 50%) 38%, var(--color-text-mid) 62%);
line-height: 1.35;
}
.featured-tile__media {
position: relative;
margin: 0;
width: 100%;
overflow: hidden;
background-color: var(--color-bg-light);
}
/* Vary aspect ratios for a more irregular “wall of blocks” rhythm */
.featured-tile__media--ar0 {
aspect-ratio: 16 / 9;
}
.featured-tile__media--ar1 {
aspect-ratio: 1 / 1;
}
.featured-tile__media--ar2 {
aspect-ratio: 3 / 2;
}
.featured-list .card-header {
margin-top: 20px;
}
.featured-tile__media--ar3 {
aspect-ratio: 3 / 4;
}
.featured-list .card {
border-bottom: 1px solid var(--color-border) !important;
}
.featured-tile__media img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
display: block;
.featured-list > * {
margin-bottom: 10px;
padding: 0;
}
}
/* Site logo fallback in wide frames */
.featured-tile__media img[src*="favicon-96x96"] {
object-fit: contain;
padding: 2.25rem;
box-sizing: border-box;
div:nth-child(odd) .featured-list {
flex-direction: row-reverse;
}
/* Uniform text padding (home tiles + shared with list cards elsewhere) */
.featured-list .card-body {
box-sizing: border-box;
padding: 1rem 1.125rem 1.2rem;
/* Only the two column wrappers — not every .card that happens to be :first-child/:last-child of its parent */
.featured-list > div:first-child {
flex: 0 0 66%;
min-width: 0;
}
/* List card titles + excerpts: home (featured), category, search, author */
.featured-list h2.card-title,
.article-list h2.card-title {
font-family: var(--heading-font), serif;
font-size: 1.95rem;
font-weight: 700;
line-height: 1.18;
color: var(--color-primary);
margin: 0.15rem 0 0.55rem;
.featured-list > div:last-child {
flex: 0 0 34%;
min-width: 0;
}
/* Home featured grid only: two-line title + two-line deck for even rhythm. */
.featured-list h2.card-title {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
overflow: hidden;
min-height: 2.36em;
margin-top: 0;
margin-bottom: 0.4rem;
}
.featured-list p.lede,
.article-list p.lede {
font-family: var(--main-body-font), serif;
font-size: 1.1rem;
font-weight: 400;
line-height: 1.55;
color: var(--color-text-mid);
margin-top: 0.15rem;
}
.featured-list p.lede.truncate {
-webkit-line-clamp: 2;
line-clamp: 2;
}
/* Masonry wall (home): smaller titles, more title + excerpt lines (must follow generic .featured-list rules) */
.featured-list.featured-list--wall h2.card-title {
font-size: 1.35rem;
font-weight: 600;
line-height: 1.3;
-webkit-line-clamp: 4;
line-clamp: 4;
min-height: 0;
margin-bottom: 0.35rem;
}
.featured-list.featured-list--wall p.lede.truncate {
-webkit-line-clamp: 10;
line-clamp: 10;
font-size: 1.02rem;
line-height: 1.5;
margin-top: 0.35rem;
font-size: 1.5rem;
}
.featured-list__meta {
font-family: var(--font-family), sans-serif;
font-size: 0.78rem;
font-weight: 400;
line-height: 1.35;
color: var(--color-text-mid);
margin: 0.15rem 0 0.45rem;
.featured-list p.lede {
font-size: 1.4rem;
}
.featured-list__meta time {
color: inherit;
.featured-list .card {
margin-bottom: 10px;
}
/* Whole-card link: keep excerpt + date subdued on hover. */
.featured-tile__link:hover p.lede,
.article-list .card a:hover p.lede {
color: color-mix(in srgb, var(--color-text-mid) 88%, var(--color-text) 12%);
text-decoration: none;
.featured-list .card:not(:last-child) {
border-bottom: 1px solid var(--color-border);
}
.featured-tile__link:hover .featured-list__meta {
color: color-mix(in srgb, var(--color-text-mid) 58%, var(--color-text) 42%);
.featured-list .card-header img {
max-height: 500px;
aspect-ratio: 1;
}
.article-list .metadata {
@ -382,48 +225,11 @@ svg.icon { @@ -382,48 +225,11 @@ svg.icon {
align-items: center;
gap: 0.75rem;
min-width: 0;
font-family: var(--font-family), sans-serif;
font-size: 0.78rem;
font-weight: 400;
line-height: 1.35;
color: var(--color-text-mid);
}
.article-list .metadata p {
margin: 0;
min-width: 0;
color: inherit;
}
.article-list .metadata a {
color: color-mix(in srgb, var(--color-text-mid) 72%, var(--color-text) 28%);
text-decoration: none;
}
.article-list .metadata a:hover,
.article-list .metadata a:focus-visible {
color: var(--color-link-hover);
text-decoration: underline;
text-underline-offset: 2px;
}
/* List cards: same site-logo treatment when the hero is the default mark */
.article-list .card-header img[src*="favicon-96x96"] {
object-fit: contain;
padding: 1.25rem;
box-sizing: border-box;
background: var(--color-bg-light);
}
/* Optional category label above cover (see Molecules/Card) */
.article-list .card-header small {
display: block;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
color: color-mix(in srgb, var(--color-text-mid) 55%, var(--color-bg) 45%);
margin-bottom: 0.35rem;
}
.truncate {
@ -488,79 +294,21 @@ svg.icon { @@ -488,79 +294,21 @@ svg.icon {
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: center;
gap: 0.4rem 0.6rem;
padding: 0.35rem 0.5rem 0.5rem;
margin: 0;
gap: 20px;
padding: 0;
}
.header__categories li {
list-style: none;
}
/* Top category row: current section + clear hover affordance (passive “clean” list → scannable) */
.header__cat-link {
display: inline-flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
padding: 0.4rem 0.75rem;
font-family: var(--font-family), sans-serif;
font-size: 0.82rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
text-decoration: none;
color: var(--color-text-mid);
background: transparent;
border: 1px solid transparent;
border-radius: 5px;
transition:
color 0.16s ease,
background-color 0.16s ease,
border-color 0.16s ease,
box-shadow 0.16s ease;
}
.header__cat-link:hover {
.header__categories li a:hover {
text-decoration: none;
color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 8%, var(--color-bg) 92%);
box-shadow: 0 2px 0 0 var(--color-secondary);
}
.header__cat-link:focus-visible {
text-decoration: none;
outline: 2px solid var(--color-focus-ring);
outline-offset: 2px;
}
.header__cat-link--active {
color: var(--color-primary);
font-weight: 700;
background: color-mix(in srgb, var(--color-primary) 14%, var(--color-bg) 86%);
box-shadow: inset 0 -2px 0 0 var(--color-primary);
}
/* Strong CTA: “Latest Articles” (outline + fill when you’re on that list) */
.header__cat-link--cta {
border-color: color-mix(in srgb, var(--color-secondary) 55%, var(--color-border) 45%);
color: var(--color-secondary);
background: color-mix(in srgb, var(--color-secondary) 7%, var(--color-bg) 93%);
}
.header__cat-link--cta:hover {
color: var(--color-link-hover);
background: color-mix(in srgb, var(--color-secondary) 14%, var(--color-bg) 86%);
box-shadow: 0 2px 0 0 var(--color-secondary);
font-weight: bold;
}
.header__cat-link--cta.header__cat-link--active {
color: var(--color-text-contrast);
background: var(--color-primary);
border-color: var(--color-primary);
font-weight: 700;
box-shadow: none;
.header__categories a.active {
font-weight: bold;
}
.header__logo h1 {
@ -703,8 +451,7 @@ footer a { @@ -703,8 +451,7 @@ footer a {
gap: 10px; /* Adds spacing between individual tags */
}
/* Individual tag (spans on non-article pages, links on article body) */
a.tag,
/* Individual tag */
.tag {
background-color: var(--color-bg-light);
color: var(--color-text-mid);
@ -714,15 +461,13 @@ a.tag, @@ -714,15 +461,13 @@ a.tag,
cursor: pointer; /* Cursor turns to pointer for clickable tags */
text-decoration: none; /* Removes any text decoration (e.g., underline) */
display: inline-block; /* Makes sure each tag behaves like a block with padding */
transition: background-color 0.2s ease, color 0.2s ease;
transition: background-color 0.3s ease; /* Smooth hover effect */
}
a.tag:hover,
a.tag:focus-visible {
background-color: color-mix(in srgb, var(--color-primary) 12%, var(--color-bg-light));
color: var(--color-primary);
text-decoration: none;
}
/*!* Hover effect for tags *!*/
/*.tag:hover {*/
/* color: var(--color-text-contrast);*/
/*}*/
/* Optional: Responsive adjustments for smaller screens */
@media (max-width: 768px) {
@ -731,19 +476,6 @@ a.tag:focus-visible { @@ -731,19 +476,6 @@ a.tag:focus-visible {
}
}
.topic-page__title {
font-size: 1.85rem;
font-weight: 700;
color: var(--color-primary);
margin: 0 0 0.35rem;
font-family: var(--heading-font), serif;
}
.topic-page__lede {
margin: 0 0 1.5rem;
font-size: 0.95rem;
}
.card.card__horizontal {
@ -809,11 +541,6 @@ a.tag:focus-visible { @@ -809,11 +541,6 @@ a.tag:focus-visible {
.author-profile__title {
margin-top: 0.25em;
font-family: var(--heading-font), serif;
font-size: clamp(1.65rem, 3.2vw, 2.35rem);
font-weight: 700;
line-height: 1.12;
color: var(--color-primary);
}
.author-profile__header-meta {
@ -1227,50 +954,3 @@ a:focus-visible { @@ -1227,50 +954,3 @@ a:focus-visible {
height: 40px;
}
}
.pager {
margin-top: 1.25rem;
width: 100%;
box-sizing: border-box;
}
.pager__inner {
width: 100%;
max-width: 100%;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border);
background: var(--color-bg-light);
}
.pager__status {
flex: 1 1 auto;
min-width: 0;
text-align: center;
}
.pager__btn {
min-width: 6.5rem;
text-align: center;
}
.pager__btn.is-disabled {
opacity: 0.55;
pointer-events: none;
}
@media (max-width: 640px) {
.pager__inner {
gap: 0.5rem;
}
.pager__btn {
min-width: 5.25rem;
}
.pager__status {
font-size: 0.92rem;
}
}

265
assets/styles/article.css

@ -50,26 +50,6 @@ @@ -50,26 +50,6 @@
flex: 1 1 12rem;
min-width: 0;
margin: 0;
font-family: var(--heading-font), serif;
font-weight: 700;
line-height: 1.12;
color: var(--color-primary);
}
/* Article + category page headers: global h1 is 300 weight; titles should read as the clear focal point. */
.card-header--article h1.card-title {
font-size: clamp(1.85rem, 2.8vw, 2.75rem);
}
/* Hero summary: same “excerpt” level as list cards, not .lede’s 1.6rem body scale */
.card > .card-body > .lede {
font-family: var(--main-body-font), serif;
font-size: 1.1rem;
line-height: 1.55;
font-weight: 400;
color: var(--color-text-mid);
margin: 0 0 1.25rem;
max-width: none;
}
/* Sibling .category-body would paint over the ⋯ popover; lift the title card above the list. */
@ -96,10 +76,7 @@ @@ -96,10 +76,7 @@
margin: 2rem 0;
padding-top: 0.5rem;
border-top: 1px solid var(--color-border);
font-size: 0.88rem;
font-weight: 400;
color: color-mix(in srgb, var(--color-text-mid) 58%, var(--color-bg) 42%);
font-family: var(--font-family), sans-serif;
font-size: 1rem;
}
.byline__author {
@ -109,14 +86,13 @@ @@ -109,14 +86,13 @@
gap: 0.35em;
}
/* Article body only — avoid 50px vertical margins on comment / quote cards (Atoms:Content markdown). */
.article-main blockquote {
blockquote {
border-left: 6px solid var(--color-bg-light);
padding-left: 3px;
margin: 50px 0 50px 3px;
}
.article-main blockquote p {
blockquote p {
font-size: 1.6rem;
font-style: italic;
color: var(--color-text-mid);
@ -164,7 +140,16 @@ @@ -164,7 +140,16 @@
.comments-quotes__title {
font-size: 1.25rem;
margin: 0 0 0.9rem;
margin: 0 0 0.35rem;
}
.comments-quotes__lede {
font-size: 0.95rem;
margin: 0 0 1.25rem;
}
.comments-quotes__lede code {
font-size: 0.9em;
}
.comments-quotes__sep {
@ -195,22 +180,20 @@ @@ -195,22 +180,20 @@
margin-top: 0.75rem;
}
/* Thread: no depth indent; one accent color for all replies */
/* Thread: no depth indent; one accent color for all replies; compact vertical rhythm */
.comments {
display: flex;
flex-direction: column;
gap: 0.55rem;
gap: 0.4rem;
}
.comments .card.comment,
.comments-quotes__list .card.comment {
.comments .card.comment {
margin-left: 0;
margin-bottom: 0;
padding: 0.75rem 0.9rem 0.85rem;
padding: 0.5rem 0.65rem 0.5rem 0.7rem;
border-radius: 6px;
border: 1px solid var(--color-border);
border-left: 3px solid var(--color-primary);
gap: 0.5rem;
}
.comments .card.comment--depth-0,
@ -221,53 +204,19 @@ @@ -221,53 +204,19 @@
border-left-color: var(--color-primary);
}
.comments .card.comment .metadata,
.comments-quotes__list .card.comment .metadata {
margin-bottom: 0;
.comments .card.comment .metadata {
margin-bottom: 0.4rem;
}
.comment__reply-blurb {
padding: 0.4rem 0.55rem 0.45rem 0.55rem;
padding: 0.2rem 0.45rem 0.2rem 0.5rem;
margin: 0 0 0 0.2rem;
border-left: 2px solid color-mix(in srgb, var(--color-border) 50%, transparent);
background: color-mix(in srgb, var(--color-bg-light) 55%, transparent);
border-radius: 0 3px 3px 0;
font-size: 0.82em;
line-height: 1.45;
color: var(--color-text-mid);
}
/* Markdown blockquotes inside note bodies: tight rhythm (not .article-main blockquote). */
.comments .card.comment .card-body blockquote,
.comments-quotes__list .card.comment .card-body blockquote {
margin: 0.35rem 0 0.5rem;
padding: 0.3rem 0 0.35rem 0.65rem;
border-left: 3px solid color-mix(in srgb, var(--color-primary) 40%, var(--color-border));
}
.comments .card.comment .card-body blockquote p,
.comments-quotes__list .card.comment .card-body blockquote p {
font-size: 1em;
line-height: 1.45;
font-style: italic;
font-size: 0.78em;
line-height: 1.35;
color: var(--color-text-mid);
margin: 0;
padding-left: 0;
}
.comments .card.comment .card-body > :first-child,
.comments-quotes__list .card.comment .card-body > :first-child {
margin-top: 0;
}
.comments .card.comment .card-body > :last-child,
.comments-quotes__list .card.comment .card-body > :last-child {
margin-bottom: 0;
}
.comments .card.comment .card-body,
.comments-quotes__list .card.comment .card-body {
line-height: 1.52;
}
.comment__reply-blurb blockquote,
@ -389,173 +338,3 @@ @@ -389,173 +338,3 @@
font-size: 0.9rem;
margin: 0.5rem 0 0;
}
.reply-toast {
position: fixed;
left: 50%;
bottom: 1.2rem;
transform: translateX(-50%) translateY(8px);
z-index: 1200;
min-width: 16rem;
max-width: min(92vw, 32rem);
padding: 0.55rem 0.85rem;
border: 1px solid var(--color-border);
color: var(--color-text);
background: var(--color-bg);
opacity: 0;
transition: opacity 0.2s ease, transform 0.2s ease;
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.18);
}
.reply-toast--visible {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
.reply-toast--success {
border-color: #2f7a4b;
background: #e7f5eb;
}
.reply-toast--error {
border-color: #a12b2b;
background: #fdecec;
}
/* NIP-84: kind-9802 marks in .article-main (fragment id highlight-<event id> for deep links) */
/* Full `context` quote + optional <mark> on the `content` substring (body copy, not a box) */
.user-highlight__body {
margin: 0.35rem 0 0;
font-size: 0.95rem;
line-height: 1.65;
color: var(--color-text);
font-family: var(--main-body-font), serif;
}
/* In-flow + aside: same NIP-84 mark treatment; scroll-margin in article for #highlight-… links */
.article-main mark.user-highlight__marker,
.home-aside-highlights__quote--html mark.user-highlight__marker {
margin: 0;
padding: 0.08em 0.1em 0.12em;
border-radius: 0.12em;
font: inherit;
line-height: inherit;
color: var(--color-highlight-mark-fg);
background: color-mix(in srgb, #7ad67a 30%, #f0e8a0 70%);
box-shadow: none;
box-decoration-break: clone;
-webkit-box-decoration-break: clone;
}
.article-main mark.user-highlight__marker,
.article-main .user-highlight__fragment-target {
scroll-margin-top: calc(var(--site-fixed-header-offset, 140px) + 0.75rem);
}
/* Invisible #highlight-{eid} anchors (same group as an older mark) — zero visual footprint. */
.article-main .user-highlight__fragment-target {
display: inline;
font-size: 0;
line-height: 0;
width: 0.01em;
height: 0;
overflow: hidden;
margin: 0;
padding: 0;
border: 0;
vertical-align: baseline;
}
/* When `content` is not a substring of `context` (rare) */
.user-highlight__marker-orphan {
margin: 0.5rem 0 0;
font-size: 0.9rem;
line-height: 1.5;
color: var(--color-text-mid);
}
/* Hover tooltip: all highlighters for this passage (from data-hl) */
.user-highlight__tip-popover {
min-width: 10rem;
max-width: min(22rem, 92vw);
padding: 0.5rem 0.65rem 0.6rem;
border-radius: 0.35rem;
background: var(--color-bg, #fff);
border: 1px solid color-mix(in srgb, var(--color-text-mid) 18%, var(--color-bg) 82%);
box-shadow: 0 0.15rem 0.75rem color-mix(in srgb, #000 12%, transparent);
font-size: 0.88rem;
line-height: 1.35;
pointer-events: auto;
}
.user-highlight__tip-popover[hidden] {
display: none !important;
}
.user-highlight__tip-head {
font-size: 0.72rem;
font-style: normal;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-mid, #6b6b6b);
margin-bottom: 0.4rem;
}
.user-highlight__tip-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.user-highlight__tip-item {
margin: 0;
}
.user-highlight__tip-popover .user-badge--in-tip {
display: flex;
align-items: center;
gap: 0.4rem;
text-decoration: none;
color: var(--color-text, #111);
max-width: 100%;
}
.user-highlight__tip-popover .user-badge--in-tip:hover {
text-decoration: underline;
}
.user-highlight__tip-popover .user-badge__avatar--in-tip {
flex-shrink: 0;
width: 1.5rem;
height: 1.5rem;
border-radius: 50%;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background: color-mix(in srgb, var(--color-text-mid) 12%, var(--color-bg) 88%);
font-size: 0.65rem;
font-weight: 600;
}
.user-highlight__tip-popover .user-badge__avatar-fallback--dot {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
.user-highlight__tip-popover .user-badge__name {
font-size: 0.85rem;
font-weight: 500;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

2
assets/styles/card.css

@ -36,7 +36,7 @@ h2.card-title { @@ -36,7 +36,7 @@ h2.card-title {
}
.article-list .card {
margin-bottom: 1.75rem;
margin-bottom: 1rem;
min-width: 0; /* column flex: do not let cover images set unshrinkable row width */
}

28
assets/styles/components/_nostr_previews.scss

@ -67,34 +67,6 @@ @@ -67,34 +67,6 @@
flex-direction: column;
gap: 0.35rem;
}
.nostr-address-preview {
position: relative;
}
.nostr-address-preview--with-menu .nostr-address-preview__body {
padding-right: 2.25rem;
}
.nostr-address-preview__body {
display: flex;
flex-direction: column;
gap: 0.35rem;
padding: 0.45rem 0.75rem 0.55rem;
.card-title,
.card-text {
margin: 0;
}
}
.nostr-address-preview .nostr-preview-card__menu {
position: absolute;
top: 0.35rem;
right: 0.4rem;
margin: 0;
z-index: 1;
}
}
.nostr-previews {

5
assets/styles/event.css

@ -94,11 +94,8 @@ @@ -94,11 +94,8 @@
flex-wrap: wrap;
gap: 0.5rem;
width: 100%;
font-family: var(--font-family), sans-serif;
font-size: 0.78rem;
font-weight: 400;
line-height: 1.35;
color: var(--color-text-mid);
font-size: 0.95rem;
}
.event-page a:focus-visible {

528
assets/styles/layout.css

@ -22,8 +22,7 @@ @@ -22,8 +22,7 @@
flex-grow: 1;
}
/* Only the app chrome sidebar — not <nav> in main (pagination) or footer. */
.layout > nav {
nav {
width: 21vw;
min-width: 150px;
max-width: 280px;
@ -32,180 +31,25 @@ @@ -32,180 +31,25 @@
overflow-y: auto; /* Ensure the menu is scrollable if content is too long */
}
.layout > nav ul {
nav ul {
list-style-type: none;
padding: 0;
}
.layout > nav li {
nav li {
margin: 0.5em 0;
}
.layout > nav a {
nav a {
color: var(--color-primary);
text-decoration: none;
}
.layout > nav a:hover {
nav a:hover {
color: var(--color-text-mid);
text-decoration: none;
}
/* Left nav: featured authors (desktop only; same site logo as header when no Nostr picture) */
.sidebar-featured-authors {
display: none;
}
/* Top topics list (same visibility pattern as featured authors) */
.sidebar-top-topics {
display: none;
}
@media (min-width: 1025px) {
.sidebar-featured-authors {
display: block;
margin-top: 1.1rem;
padding-top: 0.9rem;
border-top: 1px solid var(--color-border);
}
.layout > nav .sidebar-featured-authors a,
.layout > nav .sidebar-featured-authors a:hover {
color: inherit;
text-decoration: none;
}
.sidebar-featured-authors__title {
margin: 0 0 0.55rem;
font-family: var(--font-family), sans-serif;
font-size: 0.68rem;
font-weight: 600;
letter-spacing: 0.07em;
text-transform: uppercase;
color: color-mix(in srgb, var(--color-text-mid) 72%, var(--color-bg) 28%);
line-height: 1.3;
}
.sidebar-featured-authors__grid {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 0.45rem;
align-content: flex-start;
list-style: none;
margin: 0;
padding: 0;
}
.layout > nav .sidebar-featured-authors__item,
.sidebar-featured-authors__item {
margin: 0;
padding: 0;
list-style: none;
}
.sidebar-featured-authors__link {
display: block;
border-radius: 50%;
line-height: 0;
}
.sidebar-featured-authors__link:hover {
text-decoration: none;
}
.sidebar-featured-authors__link:hover .sidebar-featured-authors__avatar {
box-shadow: 0 0 0 2px var(--color-secondary);
}
.sidebar-featured-authors__link:focus-visible .sidebar-featured-authors__avatar,
.sidebar-featured-authors__link:focus-visible {
outline: 2px solid var(--color-focus-ring);
outline-offset: 2px;
}
.sidebar-featured-authors__avatar {
display: block;
width: 40px;
height: 40px;
border-radius: 50%;
overflow: hidden;
background: var(--color-bg-light);
box-shadow: 0 0 0 1px var(--color-border);
}
.sidebar-featured-authors__avatar img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.sidebar-featured-authors__avatar img[src*="favicon-96x96"] {
object-fit: contain;
object-position: center;
padding: 0.2rem;
box-sizing: border-box;
}
.sidebar-top-topics {
display: block;
margin-top: 1.1rem;
padding-top: 0.9rem;
border-top: 1px solid var(--color-border);
}
.sidebar-top-topics__title {
margin: 0 0 0.5rem;
font-family: var(--font-family), sans-serif;
font-size: 0.68rem;
font-weight: 600;
letter-spacing: 0.07em;
text-transform: uppercase;
color: color-mix(in srgb, var(--color-text-mid) 72%, var(--color-bg) 28%);
line-height: 1.3;
}
.sidebar-top-topics__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 0.4rem 0.35rem;
align-items: center;
}
.layout > nav .sidebar-top-topics__list li {
margin: 0;
}
/* Pill badges: borderless, low-contrast chips (softer than article `a.tag`) */
.layout > nav a.topic-badge.sidebar-top-topics__link,
.layout > nav a.topic-badge {
display: inline-block;
max-width: 100%;
background-color: color-mix(in srgb, var(--color-text-mid) 7%, var(--color-bg));
color: color-mix(in srgb, var(--color-text-mid) 78%, var(--color-bg) 22%);
padding: 0.2rem 0.5rem;
border-radius: 999px;
font-size: 0.7rem;
line-height: 1.35;
font-weight: 400;
text-decoration: none;
border: none;
box-sizing: border-box;
word-break: break-word;
transition: background-color 0.2s ease, color 0.2s ease;
}
.layout > nav a.topic-badge:hover,
.layout > nav a.topic-badge:focus-visible {
background-color: color-mix(in srgb, var(--color-text-mid) 12%, var(--color-bg));
color: color-mix(in srgb, var(--color-primary) 42%, var(--color-text-mid));
text-decoration: none;
}
}
/* Only the app chrome in Header.html.twig (#site-header). A bare `header` rule also
matched <header class="featured-authors__intro"> and fixed it under the real bar, hiding it. */
#site-header {
@ -299,11 +143,7 @@ @@ -299,11 +143,7 @@
width: 100%;
text-align: left;
padding: 0.45rem 0.75rem;
font-family: var(--font-family), sans-serif;
font-size: 0.9rem;
font-weight: 400;
line-height: 1.3;
text-transform: none;
font: inherit;
color: var(--color-text, inherit);
text-decoration: none;
background: none;
@ -417,13 +257,7 @@ a.nostr-share-menu__action { @@ -417,13 +257,7 @@ a.nostr-share-menu__action {
.header__categories ul {
flex-direction: column;
gap: 0.35rem;
align-items: stretch;
}
.header__cat-link {
width: 100%;
min-height: 2.6rem;
gap: 10px;
}
/* Log in / account block below category links in the hamburger */
@ -463,18 +297,8 @@ a.nostr-share-menu__action { @@ -463,18 +297,8 @@ a.nostr-share-menu__action {
}
/* Main content */
:root {
/* Clears fixed #site-header; keep in sync with main margin-top per breakpoint below. */
--site-fixed-header-offset: 140px;
}
/* #:target / in-page links: scroll position leaves room under the fixed bar (scroll-margin on inline <mark> is unreliable alone). */
html {
scroll-padding-top: calc(var(--site-fixed-header-offset) + 0.75rem);
}
main {
margin-top: var(--site-fixed-header-offset);
margin-top: 140px;
flex-grow: 1;
min-width: 0; /* flex item: allow shrinking below wide images / intrinsic min-content */
padding: 1em;
@ -490,65 +314,13 @@ main { @@ -490,65 +314,13 @@ main {
}
@media (min-width: 1025px) {
:root {
--site-fixed-header-offset: 152px;
}
/* Match extra header padding-top so content and menu clear the fixed bar */
/* In-flow left column: <nav> clears the fixed #site-header. */
.layout > nav {
margin-top: var(--site-fixed-header-offset);
}
/* Right column: same clearance as <main> so the highlights pane is not under #site-header. */
.layout > aside {
margin-top: var(--site-fixed-header-offset);
/* Default: do not stretch — avoids a full-height empty column on pages with blank <aside> (e.g. article). */
align-self: flex-start;
}
/* Home: stretch the aside to the same row height as <main> so the highlights column isn’t a short box; list flows with the page. */
.layout:has(.home-body--wall) > aside {
align-self: stretch;
}
/*
* Left column account block: keep it in document flow (not position:fixed) so order is
* badge logout / search featured authors. Fixed positioning removed the menu from the flow
* so the featured block sat under the header or overlapped.
*/
.layout > nav .user-menu:not(.user-menu--inline) {
position: static;
width: 100%;
min-width: 0;
max-width: 100%;
top: auto;
left: auto;
}
.layout > nav .user-menu .notice {
margin-top: 0;
margin-bottom: 0.3rem;
}
/* Tight stack: badge, then logout, then search */
.layout > nav .user-menu .user-menu__account-nav {
margin: 0.4rem 0 0.35rem;
padding: 0;
}
.layout > nav .user-menu .user-menu__account-nav li {
margin: 0.2rem 0 0;
}
.layout > nav .user-menu .user-menu__account-nav li:first-child {
margin-top: 0;
main {
margin-top: 152px;
}
/* More separation before the featured block (sibling in <nav>, after this menu). */
.layout > nav .sidebar-featured-authors {
margin-top: 1.35rem;
padding-top: 1.15rem;
.user-menu {
top: 162px;
}
}
@ -569,186 +341,13 @@ main { @@ -569,186 +341,13 @@ main {
/* Right sidebar */
aside {
width: min(22vw, 260px);
min-width: 170px;
width: 190px;
min-width: 150px;
flex-shrink: 0;
flex-grow: 0;
padding: 1em;
}
/* Home: full list height — no max-height; window scroll. */
.home-aside-highlights {
display: flex;
flex-direction: column;
gap: 0.5rem;
min-width: 0;
width: 100%;
}
/* Wrapper around the list (keeps end padding; no max-height so highlights aren’t trapped in a short box). */
.home-aside-highlights__scroller {
overflow: visible;
padding-right: max(0.6rem, calc(0.35rem + env(safe-area-inset-right, 0px)));
box-sizing: border-box;
}
.home-aside-highlights__title {
margin: 0 0 0.55rem;
font-family: var(--font-family), sans-serif;
font-size: 0.68rem;
font-weight: 600;
letter-spacing: 0.07em;
text-transform: uppercase;
color: color-mix(in srgb, var(--color-text-mid) 72%, var(--color-bg) 28%);
line-height: 1.3;
}
.home-aside-highlights__list {
list-style: none;
margin: 0;
padding: 0 0.2rem 0 0; /* keep inset from scrollbar */
display: flex;
flex-direction: column;
gap: 0.9rem;
}
/* Block body HTML (full quote + <mark> like the article) must not sit inside <a> — use overlay “hit” link. */
.home-aside-highlights__item-inner {
position: relative;
color: color-mix(in srgb, var(--color-text-mid) 90%, var(--color-primary) 10%);
padding: 0.1rem 0 0.15rem 0.55rem;
border: none;
border-left: 1px solid color-mix(in srgb, var(--color-text-mid) 7%, var(--color-border) 93%);
border-radius: 0;
background: transparent;
line-height: 1.45;
font-size: 0.78rem;
transition: color 0.18s ease, border-left-color 0.18s ease, background 0.18s ease;
}
.home-aside-highlights__item-inner:hover {
color: var(--color-primary);
border-left-color: color-mix(in srgb, var(--color-primary) 32%, var(--color-border) 68%);
background: color-mix(in srgb, var(--color-primary) 4%, var(--color-bg) 96%);
}
.home-aside-highlights__item-inner:has(.home-aside-highlights__hit:focus-visible) {
color: var(--color-primary);
border-left-color: color-mix(in srgb, var(--color-primary) 38%, var(--color-border) 62%);
background: color-mix(in srgb, var(--color-primary) 4%, var(--color-bg) 96%);
outline: 2px solid var(--color-focus-ring, var(--color-primary));
outline-offset: 3px;
}
.home-aside-highlights__hit {
position: absolute;
inset: 0;
z-index: 2;
text-decoration: none;
}
/* Highlight author (small badge link) + date above quote; badge is clickable, rest of row opens article. */
.home-aside-highlights__byline {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.3rem 0.5rem;
margin: 0 0 0.35rem;
position: relative;
z-index: 3;
font-family: var(--font-family), system-ui, sans-serif;
font-size: 0.68rem;
line-height: 1.2;
pointer-events: none;
}
.home-aside-highlights__who {
display: inline-flex;
max-width: 100%;
pointer-events: auto;
}
.home-aside-highlights__byline .user-badge {
gap: 0.28rem;
}
.home-aside-highlights__byline .user-badge__avatar {
width: 1.125rem;
height: 1.125rem;
}
.home-aside-highlights__byline .user-badge__name {
font-size: 0.68rem;
max-width: 7.5rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.home-aside-highlights__time {
font-size: 0.65rem;
color: color-mix(in srgb, var(--color-text-mid) 88%, var(--color-bg) 12%);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
/* Let clicks go to the overlay; quote/meta stay visible above background only visually (no pointer on text). */
.home-aside-highlights__item-inner .home-aside-highlights__quote,
.home-aside-highlights__item-inner .home-aside-highlights__meta {
position: relative;
z-index: 0;
pointer-events: none;
}
.home-aside-highlights__quote {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 5;
line-clamp: 5;
overflow: hidden;
font-style: italic;
font-weight: 400;
font-family: var(--main-body-font, Georgia), ui-serif, serif;
margin-bottom: 0.3rem;
word-break: break-word;
color: inherit;
}
/* Match article `bodyHtml` semantics; keep aside scale (`.user-highlight__body` is larger in-article). */
.home-aside-highlights__quote--html.user-highlight__body {
margin: 0 0 0.3rem;
font-size: 0.78rem;
line-height: 1.45;
color: inherit;
}
/* Lead-in to the (truncated) highlight was removed so line-clamp shows the mark, not only context. */
.home-aside-highlights__quote--html .user-highlight__elide {
font-style: normal;
color: var(--color-text-mid, #6b6b6b);
user-select: none;
}
.home-aside-highlights__meta {
display: block;
font-size: 0.7rem;
font-style: normal;
font-family: var(--font-family), system-ui, sans-serif;
color: color-mix(in srgb, var(--color-text-mid) 80%, var(--color-bg) 20%);
letter-spacing: 0.02em;
transition: color 0.18s ease;
}
.home-aside-highlights__item-inner:hover .home-aside-highlights__meta,
.home-aside-highlights__item-inner:has(.home-aside-highlights__hit:focus-visible) .home-aside-highlights__meta {
color: color-mix(in srgb, var(--color-primary) 45%, var(--color-text-mid) 55%);
}
.home-aside-highlights__item-inner:hover .home-aside-highlights__time,
.home-aside-highlights__item-inner:has(.home-aside-highlights__hit:focus-visible) .home-aside-highlights__time {
color: color-mix(in srgb, var(--color-primary) 32%, var(--color-text-mid) 68%);
}
table {
width: 100%;
margin: 20px 0;
@ -776,30 +375,10 @@ dt { @@ -776,30 +375,10 @@ dt {
aside {
display: none; /* Hide the sidebars on small screens */
}
/* Home: keep highlights — stack <aside> under the featured wall (same DOM order; row layout would squeeze it beside main). */
.layout:has(.home-body--wall) {
flex-direction: column;
align-items: stretch;
}
.layout:has(.home-body--wall) > aside {
display: block;
width: 100%;
max-width: none;
min-width: 0;
flex-shrink: 0;
margin-top: 0.5rem;
padding: 0 1em 1.75rem;
box-sizing: border-box;
}
/* Fixed header is taller than 90px (safe-area + logo row + title padding). Match it or the first
main content (e.g. featured authors intro) sits under the bar and looks cut off at the top. */
:root {
--site-fixed-header-offset: max(7.25rem, calc(4.8rem + env(safe-area-inset-top, 0px)));
}
main {
margin-top: var(--site-fixed-header-offset);
margin-top: max(7.25rem, calc(4.8rem + env(safe-area-inset-top, 0px)));
width: 100%;
}
}
@ -1061,78 +640,10 @@ footer .footer-links { @@ -1061,78 +640,10 @@ footer .footer-links {
max-width: 48rem;
margin: 0 auto;
padding: 0 0.5rem 2rem;
display: flex;
flex-direction: column;
gap: 2.5rem;
}
.featured-authors-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
gap: 0.85rem;
}
.featured-authors-grid__card {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.35rem;
padding: 0.75rem 0.55rem;
border: 1px solid var(--color-border);
background: var(--color-bg);
text-decoration: none;
color: inherit;
}
.featured-authors-grid__card:hover {
text-decoration: none;
background: var(--color-bg-light);
}
.featured-authors-grid__avatar {
width: 64px;
height: 64px;
border-radius: 50%;
overflow: hidden;
box-shadow: 0 0 0 1px var(--color-border);
display: flex;
align-items: center;
justify-content: center;
background: var(--color-bg-light);
}
.featured-authors-grid__avatar > img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.featured-authors-grid__avatar-fallback {
font-size: 1.25rem;
color: var(--color-text-mid);
}
.featured-authors-grid__name {
width: 100%;
text-align: center;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.featured-authors-grid__handle {
width: 100%;
text-align: center;
font-size: 0.82rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.featured-authors__intro {
margin-bottom: 0;
margin-bottom: 2rem;
overflow: visible; /* do not clip heading ascenders */
}
@ -1141,7 +652,7 @@ footer .footer-links { @@ -1141,7 +652,7 @@ footer .footer-links {
margin: 0 0 0.5rem;
font-size: clamp(1.35rem, 2.6vw, 2.05rem);
line-height: 1.28;
font-weight: 700;
font-weight: 500;
font-family: var(--heading-font), serif;
color: var(--color-primary);
padding: 0.2em 0 0.05em;
@ -1230,14 +741,17 @@ footer .footer-links { @@ -1230,14 +741,17 @@ footer .footer-links {
text-align: center;
}
/* Narrow: smaller page title + intro; flex gap avoids margin collapse with first author block. */
/* Narrow: smaller page title + intro; flex gap avoids margin collapse with first author card. */
@media (max-width: 1024px) {
.featured-authors {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 1.75rem;
}
.featured-authors__intro {
margin-bottom: 0;
/* Contain the intro <p> margin so it doesn’t collapse with the first author block */
display: flow-root;
}

32
assets/styles/nostr-previews.css

@ -85,44 +85,12 @@ @@ -85,44 +85,12 @@
gap: 0.35rem;
}
/* naddr previews: avoid global h1–h6 margins + relay tag order quirks; menu out of flow */
.nostr-preview .nostr-address-preview {
position: relative;
}
.nostr-preview .nostr-address-preview--with-menu .nostr-address-preview__body {
padding-right: 2.25rem;
}
.nostr-preview .nostr-address-preview__body {
display: flex;
flex-direction: column;
gap: 0.35rem;
padding: 0.45rem 0.75rem 0.55rem;
}
.nostr-preview .nostr-address-preview__body .card-title {
margin: 0;
}
.nostr-preview .nostr-address-preview__body .card-text {
margin: 0;
}
.nostr-preview-card__menu {
display: flex;
justify-content: flex-end;
margin-bottom: 0.35rem;
}
.nostr-preview .nostr-address-preview .nostr-preview-card__menu {
position: absolute;
top: 0.35rem;
right: 0.4rem;
margin: 0;
z-index: 1;
}
.nostr-previews h6 {
font-size: 0.9rem;
margin-bottom: 1rem;

11
assets/styles/theme.css

@ -9,19 +9,16 @@ @@ -9,19 +9,16 @@
--color-bg-light: #2a2a2a; /* Slightly lighter charcoal */
--color-bg-primary: #2e1f2e; /* Muted aubergine for a rich, elegant feel */
--color-text: #f5f5f5; /* Soft white for readability */
--color-text-mid: #d8d8d8; /* Warm light gray — ≥4.5:1 vs --color-bg for body copy */
--color-text-mid: #d8d8d8; /* Warm light gray — use on dark bg for ≥4.5:1 vs --color-bg */
--color-text-contrast: #000; /* Black text for contrast */
/* Green accents: tuned for ≥4.5:1 as *text* on --color-bg / --color-bg-light; black (#000) on primary fill ≥4.5:1 */
--color-primary: #6d8a62;
--color-secondary: #7a9e82;
--color-primary: #5F7355; /* Plum primary color */
--color-secondary: #495544; /* secondary color */
--color-border: #3a3a3a; /* Subtle gray border */
/* Aliases / derived (WCAG AA on typical surfaces when paired as documented) */
--color-text-light: var(--color-text-mid); /* deprecated name: use --color-text-mid */
--color-footer-bg: var(--color-bg-light);
--color-footer-text: var(--color-text);
--color-footer-link: var(--color-secondary); /* primary on footer bg was below 4.5:1 */
/* NIP-84 highlight mark: light yellow-green fill needs dark ink (not inherited light body text) */
--color-highlight-mark-fg: #1a1a1a;
--color-footer-link: var(--color-primary);
--color-link: var(--color-secondary);
--color-link-hover: var(--color-text);
--color-focus-ring: var(--color-secondary);

6
bin/nostr_relay_request_worker.php

@ -46,12 +46,8 @@ if (!\is_object($msg) || !($msg instanceof \swentel\nostr\Message\RequestMessage @@ -46,12 +46,8 @@ if (!\is_object($msg) || !($msg instanceof \swentel\nostr\Message\RequestMessage
$relaySet = new \swentel\nostr\Relay\RelaySet();
$relaySet->addRelay(new \swentel\nostr\Relay\Relay($relayUrl));
$request = new \swentel\nostr\Request\Request($relaySet, $msg);
$relayTimeout = (int) (getenv('NOSTR_RELAY_REQUEST_TIMEOUT') ?: 12);
if ($relayTimeout < 1) {
$relayTimeout = 12;
}
if (method_exists($request, 'setTimeout')) {
$request->setTimeout($relayTimeout);
$request->setTimeout(15);
}
try {

72
composer.json

@ -6,45 +6,48 @@ @@ -6,45 +6,48 @@
"minimum-stability": "stable",
"prefer-stable": true,
"require": {
"php": "^8.3",
"php": ">=8.3.13",
"ext-ctype": "*",
"ext-iconv": "*",
"ext-openssl": "*",
"cweagans/composer-patches": "^1.7",
"doctrine/dbal": "^4.2",
"doctrine/doctrine-bundle": "^2.13",
"doctrine/doctrine-migrations-bundle": "^3.3",
"doctrine/orm": "^3.3",
"embed/embed": "^4.4",
"laminas/laminas-diactoros": "^3.6",
"league/commonmark": "^2.7",
"league/html-to-markdown": "^5.1",
"league/html-to-markdown": "*",
"nostriphant/nip-19": "^2.0",
"phpdocumentor/reflection-docblock": "^5.6",
"phpstan/phpdoc-parser": "^2.0",
"runtime/frankenphp-symfony": "^0.2.0",
"swentel/nostr-php": "^1.9.4",
"symfony/asset": "7.3.*",
"symfony/asset-mapper": "7.3.*",
"symfony/console": "7.3.*",
"symfony/dotenv": "7.3.*",
"symfony/asset": "7.1.*",
"symfony/asset-mapper": "7.1.*",
"symfony/console": "7.1.*",
"symfony/dotenv": "7.1.*",
"symfony/flex": "^2",
"symfony/form": "7.3.*",
"symfony/framework-bundle": "7.3.*",
"symfony/html-sanitizer": "7.3.*",
"symfony/http-foundation": "7.3.*",
"symfony/intl": "7.3.*",
"symfony/monolog-bridge": "7.3.*",
"symfony/form": "7.1.*",
"symfony/framework-bundle": "7.1.*",
"symfony/html-sanitizer": "7.1.*",
"symfony/http-foundation": "7.1.*",
"symfony/intl": "7.1.*",
"symfony/monolog-bridge": "7.1.*",
"symfony/monolog-bundle": "^3.11",
"symfony/process": "7.3.*",
"symfony/property-access": "7.3.*",
"symfony/property-info": "7.3.*",
"symfony/runtime": "7.3.*",
"symfony/security-bundle": "7.3.*",
"symfony/serializer": "7.3.*",
"symfony/process": "7.1.*",
"symfony/property-access": "7.1.*",
"symfony/property-info": "7.1.*",
"symfony/runtime": "7.1.*",
"symfony/security-bundle": "7.1.*",
"symfony/serializer": "7.1.*",
"symfony/stimulus-bundle": "^2.22",
"symfony/translation": "7.3.*",
"symfony/twig-bundle": "7.3.*",
"symfony/translation": "7.1.*",
"symfony/twig-bundle": "7.1.*",
"symfony/ux-icons": "^2.22",
"symfony/ux-live-component": "^2.21",
"symfony/workflow": "7.3.*",
"symfony/yaml": "7.3.*",
"symfony/workflow": "7.1.*",
"symfony/yaml": "7.1.*",
"twig/extra-bundle": "^2.12|^3.0",
"twig/markdown-extra": "^3.21",
"twig/string-extra": "^3.21",
@ -55,8 +58,7 @@ @@ -55,8 +58,7 @@
"php-http/discovery": true,
"symfony/flex": true,
"symfony/runtime": true,
"endroid/installer": true,
"cweagans/composer-patches": true
"endroid/installer": true
},
"sort-packages": true
},
@ -91,10 +93,6 @@ @@ -91,10 +93,6 @@
],
"post-update-cmd": [
"@auto-scripts"
],
"phpstan": [
"@php bin/console cache:warmup --env=dev --no-interaction",
"phpstan analyse -c phpstan.neon.dist --memory-limit=512M"
]
},
"conflict": {
@ -103,26 +101,20 @@ @@ -103,26 +101,20 @@
"extra": {
"symfony": {
"allow-contrib": false,
"require": "7.3.*",
"require": "7.1.*",
"docker": true
},
"runtime": {
"dotenv_overload": false
},
"patches": {
"swentel/nostr-php": {
"Fix PHPDoc for Symfony ErrorHandler (setTags, connect)": "patches/swentel-nostr-php-symfony-debugclassloader-docblocks.patch"
}
}
},
"require-dev": {
"phpstan/phpstan": "^2.0",
"phpstan/phpstan-doctrine": "^2.0",
"phpstan/phpstan-symfony": "^2.0",
"phpunit/phpunit": "^9.5",
"symfony/browser-kit": "7.1.*",
"symfony/css-selector": "7.1.*",
"symfony/maker-bundle": "^1.63",
"symfony/phpunit-bridge": "^7.2",
"symfony/stopwatch": "7.3.*",
"symfony/web-profiler-bundle": "7.3.*"
"symfony/stopwatch": "7.1.*",
"symfony/web-profiler-bundle": "7.1.*"
}
}

3046
composer.lock generated

File diff suppressed because it is too large Load Diff

11
config/packages/csrf.yaml

@ -1,11 +0,0 @@ @@ -1,11 +0,0 @@
# Enable stateless CSRF protection for forms and logins/logouts
framework:
form:
csrf_protection:
token_id: submit
csrf_protection:
stateless_token_ids:
- submit
- authenticate
- logout

6
config/packages/doctrine.yaml

@ -2,9 +2,9 @@ doctrine: @@ -2,9 +2,9 @@ doctrine:
dbal:
url: '%env(resolve:DATABASE_URL)%'
driver: pdo_mysql
# Pin version so DBAL 4 does not treat the server as "MySQL &lt; 8" and emit deprecations
# on every request (see AbstractMySQLDriver + serverVersion auto-detection).
server_version: '8.0'
# IMPORTANT: You MUST configure your server version,
# either here or in the DATABASE_URL env var (see .env file)
#server_version: '8.0'
charset: utf8mb4
default_table_options:
charset: utf8mb4

2
config/packages/monolog.yaml

@ -24,8 +24,6 @@ when@dev: @@ -24,8 +24,6 @@ when@dev:
path: "php://stderr"
# Min level info: debug stays out of stderr (file only).
level: info
# User deprecations (vendor) still land in var/log/…; avoid duplicating to Docker stderr.
channels: [ "!event", "!deprecation" ]
console:
type: console
process_psr_3_messages: false

11
config/packages/nyholm_psr7.yaml

@ -1,11 +0,0 @@ @@ -1,11 +0,0 @@
services:
# Register nyholm/psr7 services for autowiring with PSR-17 (HTTP factories)
Psr\Http\Message\RequestFactoryInterface: '@nyholm.psr7.psr17_factory'
Psr\Http\Message\ResponseFactoryInterface: '@nyholm.psr7.psr17_factory'
Psr\Http\Message\ServerRequestFactoryInterface: '@nyholm.psr7.psr17_factory'
Psr\Http\Message\StreamFactoryInterface: '@nyholm.psr7.psr17_factory'
Psr\Http\Message\UploadedFileFactoryInterface: '@nyholm.psr7.psr17_factory'
Psr\Http\Message\UriFactoryInterface: '@nyholm.psr7.psr17_factory'
nyholm.psr7.psr17_factory:
class: Nyholm\Psr7\Factory\Psr17Factory

3
config/packages/property_info.yaml

@ -1,3 +0,0 @@ @@ -1,3 +0,0 @@
framework:
property_info:
with_constructor_extractor: true

25
config/services.yaml

@ -12,9 +12,6 @@ parameters: @@ -12,9 +12,6 @@ parameters:
env(TRUSTED_PROXIES): '127.0.0.0/8,::1'
services:
App\Service\HighlightAuthorMetadataProvider:
alias: App\Service\CacheService
# default configuration for services in *this* file
_defaults:
autowire: true # Automatically injects dependencies in your services.
@ -35,23 +32,11 @@ services: @@ -35,23 +32,11 @@ services:
Symfony\Component\HttpFoundation\Session\Storage\Handler\PdoSessionHandler:
arguments:
- '%env(DATABASE_URL)%'
App\Service\NostrRelayRequestFactory:
arguments:
$relayRequestTimeoutSec: '%nostr_relay_request_timeout_sec%'
App\Service\NostrRelayFanoutTransport:
arguments:
$projectDir: '%kernel.project_dir%'
App\Service\NostrRelayListFactory:
App\Service\NostrClient:
arguments:
$defaultRelayUrl: '%default_relay%'
$articleRelayUrls: '%article_relays%'
$profileRelayUrls: '%profile_relays%'
App\Service\NostrAuthorRelayCache:
lazy: true
arguments:
$relayQueryCache: '@cache.app'
App\Service\NostrClient:
arguments:
$projectDir: '%kernel.project_dir%'
App\Service\ArticleCommentThreadLoader:
arguments:
@ -64,8 +49,6 @@ services: @@ -64,8 +49,6 @@ services:
tags: [ 'twig.extension' ]
App\Twig\MagazineJumbleExtension:
tags: [ 'twig.extension' ]
App\Twig\TopTopicsExtension:
tags: [ 'twig.extension' ]
App\Service\MagazineRefresher:
arguments:
$appCache: '@cache.app'
@ -77,9 +60,3 @@ services: @@ -77,9 +60,3 @@ services:
App\Service\Nip05VerificationService:
arguments:
$appCache: '@cache.app'
when@test:
services:
Symfony\Component\HttpFoundation\Session\Storage\Handler\PdoSessionHandler:
class: Symfony\Component\HttpFoundation\Session\Storage\Handler\NativeFileSessionHandler
arguments: ['%kernel.project_dir%/var/sessions_test']

31
config/unfold.yaml

@ -1,9 +1,6 @@ @@ -1,9 +1,6 @@
# Site identity and theme (see assets/theme/local/ to override default assets).
parameters:
# Per-relay WebSocket I/O (seconds) in NostrClient; also default_socket_timeout during app:prewarm.
nostr_relay_request_timeout_sec: 12
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.'
@ -11,29 +8,17 @@ parameters: @@ -11,29 +8,17 @@ parameters:
og_headline: 'Nostr, Curated Thoughtfully'
og_subheading: 'Imwald Blog by Laeserin'
default_relay: 'wss://theforest.nostr1.com'
default_relay: 'wss://TheForest.nostr1.com'
# Extra wss:// URLs for article sync (articles:get), comment threads (NIP-22 / getArticleDiscussion),
# and any request that merges the default set with author-specific relays. default_relay is first; duplicates ignored.
article_relays: [
'wss://christpill.nostr1.com',
'wss://nostr.land',
'wss://nostr.wine',
'wss://nostr21.com',
'wss://nostr.sovbit.host',
'wss://orly-relay.imwald.eu',
'wss://nostr.einundzwei.space'
]
article_relays: ['wss://christpill.nostr1.com', 'wss://nostr.land', 'wss://nostr.wine', 'wss://nostr21.com', 'wss://nostr.sovbit.host']
# Kind-0 / profile fetches (author metadata, prewarm). Tried first, then default + article_relays (deduped).
# Also used as a second pass for kind 30040 (magazine category indices) and category long-form ingest
# when article_relays return nothing — not used for the generic /articles DB listing or getArticles().
profile_relays: [
'wss://relay.damus.io',
'wss://nos.lol',
'wss://profiles.nostr1.com',
'wss://thecitadel.nostr1.com',
'wss://nostr.wine',
'wss://orly-relay.imwald.eu'
]
profile_relays:
- 'wss://relay.damus.io'
- 'wss://nos.lol'
- 'wss://profiles.nostr1.com'
- 'wss://thecitadel.nostr1.com'
- 'wss://nostr.wine'
# Example:
# article_relays:
# - 'wss://nos.lol'

31
migrations/Version20260425200000.php

@ -1,31 +0,0 @@ @@ -1,31 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Kind 9802 (highlights) for long-form articles — stored locally; not part of the relay-only comment thread cache.
*/
final class Version20260425200000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Table article_highlight for Nostr kind-9802 highlights (linked to article rows)';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE article_highlight (id INT AUTO_INCREMENT NOT NULL, event_id VARCHAR(64) NOT NULL, article_id INT NOT NULL, author_pubkey VARCHAR(64) NOT NULL, content LONGTEXT NOT NULL, tags JSON NOT NULL, event_created_at BIGINT NOT NULL, quote_excerpt VARCHAR(512) DEFAULT NULL, INDEX IDX_8F7E8A72946689E (article_id), INDEX IDX_highlight_event_created (event_created_at), UNIQUE INDEX UNIQ_8F7E8A7271F7E88B (event_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('ALTER TABLE article_highlight ADD CONSTRAINT FK_8F7E8A72946689E FOREIGN KEY (article_id) REFERENCES article (id) ON DELETE CASCADE');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE article_highlight DROP FOREIGN KEY FK_8F7E8A72946689E');
$this->addSql('DROP TABLE article_highlight');
}
}

22
patches/swentel-nostr-php-symfony-debugclassloader-docblocks.patch

@ -1,22 +0,0 @@ @@ -1,22 +0,0 @@
--- a/src/EventInterface.php 2026-04-27 18:53:54.019977813 +0200
+++ b/src/EventInterface.php 2026-04-27 18:53:54.021843273 +0200
@@ -109,7 +109,7 @@
/**
* Set the event tags with values.
*
- * @param array $tags[]
+ * @param array $tags
* [
* ["e", "..."],
* ["p", "...", "..."],
--- a/src/RelaySetInterface.php 2026-04-27 18:53:54.021655232 +0200
+++ b/src/RelaySetInterface.php 2026-04-27 18:53:54.023843373 +0200
@@ -54,7 +54,7 @@
/**
* Connect to all relays in this set.
*
- * @param bool $throwOnErrorx
+ * @param bool $throwOnError
* If true, throw an exception if any relay fails to connect.
* If false, return false if any relay fails to connect.
* @return bool

673
phpstan-baseline.neon

@ -1,673 +0,0 @@ @@ -1,673 +0,0 @@
parameters:
ignoreErrors:
-
message: '#^Instanceof between App\\Entity\\ArticleHighlight and App\\Entity\\ArticleHighlight will always evaluate to true\.$#'
identifier: instanceof.alwaysTrue
count: 1
path: src/Command/ArticleHighlightsAuditCommand.php
-
message: '#^Using nullsafe property access "\?\-\>value" on left side of \?\? is unnecessary\. Use \-\> instead\.$#'
identifier: nullsafe.neverNull
count: 1
path: src/Command/ArticleHighlightsAuditCommand.php
-
message: '#^Call to an undefined method Symfony\\Contracts\\Cache\\CacheInterface\:\:getItem\(\)\.$#'
identifier: method.notFound
count: 1
path: src/Command/NostrEventFromYamlDefinitionCommand.php
-
message: '#^Call to an undefined method Symfony\\Contracts\\Cache\\CacheInterface\:\:save\(\)\.$#'
identifier: method.notFound
count: 1
path: src/Command/NostrEventFromYamlDefinitionCommand.php
-
message: '#^Call to function is_array\(\) with bool\|int\|string\|null will always evaluate to false\.$#'
identifier: function.impossibleType
count: 1
path: src/Command/PrewarmCommand.php
-
message: '#^Call to function is_string\(\) with string will always evaluate to true\.$#'
identifier: function.alreadyNarrowedType
count: 2
path: src/Command/PrewarmCommand.php
-
message: '#^Call to function method_exists\(\) with Symfony\\Component\\Console\\Helper\\ProgressBar and ''clear'' will always evaluate to true\.$#'
identifier: function.alreadyNarrowedType
count: 1
path: src/Command/PrewarmCommand.php
-
message: '#^Call to function method_exists\(\) with Symfony\\Component\\Console\\Helper\\ProgressBar and ''setMinSecondsBetwee…'' will always evaluate to false\.$#'
identifier: function.impossibleType
count: 1
path: src/Command/PrewarmCommand.php
-
message: '#^Offset ''categories'' on array\{categories\: int, listed\: int, resolved\: int, missing\: int\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Command/PrewarmCommand.php
-
message: '#^Offset ''categories'' on array\{categories\: list\<array\{slug\: string, title\: string, event_id\: string, listed_total\: int, resolved_total\: int, missing_total\: int, entries\: list\<array\{coordinate\: string, status\: string, reason\: string, article_title\?\: string\}\>\}\>, totals\: array\{categories\: int, listed\: int, resolved\: int, missing\: int\}\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Command/PrewarmCommand.php
-
message: '#^Offset ''coordinate'' on array\{coordinate\: string, status\: string, reason\: string, article_title\?\: string\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Command/PrewarmCommand.php
-
message: '#^Offset ''entries'' on array\{slug\: string, title\: string, event_id\: string, listed_total\: int, resolved_total\: int, missing_total\: int, entries\: list\<array\{coordinate\: string, status\: string, reason\: string, article_title\?\: string\}\>\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Command/PrewarmCommand.php
-
message: '#^Offset ''event_id'' on array\{slug\: string, title\: string, event_id\: string, listed_total\: int, resolved_total\: int, missing_total\: int, entries\: list\<array\{coordinate\: string, status\: string, reason\: string, article_title\?\: string\}\>\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Command/PrewarmCommand.php
-
message: '#^Offset ''kind0_tags'' on array\{content\: stdClass, kind0_tags\: list\<list\<string\>\>\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Command/PrewarmCommand.php
-
message: '#^Offset ''label'' on array\{label\: string, href\: string\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Command/PrewarmCommand.php
-
message: '#^Offset ''listed'' on array\{categories\: int, listed\: int, resolved\: int, missing\: int\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Command/PrewarmCommand.php
-
message: '#^Offset ''listed_total'' on array\{slug\: string, title\: string, event_id\: string, listed_total\: int, resolved_total\: int, missing_total\: int, entries\: list\<array\{coordinate\: string, status\: string, reason\: string, article_title\?\: string\}\>\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Command/PrewarmCommand.php
-
message: '#^Offset ''missing'' on array\{categories\: int, listed\: int, resolved\: int, missing\: int\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Command/PrewarmCommand.php
-
message: '#^Offset ''missing_total'' on array\{slug\: string, title\: string, event_id\: string, listed_total\: int, resolved_total\: int, missing_total\: int, entries\: list\<array\{coordinate\: string, status\: string, reason\: string, article_title\?\: string\}\>\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Command/PrewarmCommand.php
-
message: '#^Offset ''reason'' on array\{coordinate\: string, status\: string, reason\: string, article_title\?\: string\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Command/PrewarmCommand.php
-
message: '#^Offset ''resolved'' on array\{categories\: int, listed\: int, resolved\: int, missing\: int\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Command/PrewarmCommand.php
-
message: '#^Offset ''resolved_total'' on array\{slug\: string, title\: string, event_id\: string, listed_total\: int, resolved_total\: int, missing_total\: int, entries\: list\<array\{coordinate\: string, status\: string, reason\: string, article_title\?\: string\}\>\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Command/PrewarmCommand.php
-
message: '#^Offset ''slug'' on array\{slug\: string, title\: string, event_id\: string, listed_total\: int, resolved_total\: int, missing_total\: int, entries\: list\<array\{coordinate\: string, status\: string, reason\: string, article_title\?\: string\}\>\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Command/PrewarmCommand.php
-
message: '#^Offset ''status'' on array\{coordinate\: string, status\: string, reason\: string, article_title\?\: string\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Command/PrewarmCommand.php
-
message: '#^Offset ''title'' on array\{slug\: string, title\: string, event_id\: string, listed_total\: int, resolved_total\: int, missing_total\: int, entries\: list\<array\{coordinate\: string, status\: string, reason\: string, article_title\?\: string\}\>\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Command/PrewarmCommand.php
-
message: '#^Offset ''totals'' on array\{categories\: list\<array\{slug\: string, title\: string, event_id\: string, listed_total\: int, resolved_total\: int, missing_total\: int, entries\: list\<array\{coordinate\: string, status\: string, reason\: string, article_title\?\: string\}\>\}\>, totals\: array\{categories\: int, listed\: int, resolved\: int, missing\: int\}\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Command/PrewarmCommand.php
-
message: '#^Offset 0 on non\-empty\-list\<non\-falsy\-string\> on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Command/PrewarmCommand.php
-
message: '#^Strict comparison using \=\=\= between \*NEVER\* and 1 will always evaluate to false\.$#'
identifier: identical.alwaysFalse
count: 1
path: src/Command/PrewarmCommand.php
-
message: '#^Strict comparison using \=\=\= between array\{\} and array\{\} will always evaluate to true\.$#'
identifier: identical.alwaysTrue
count: 1
path: src/Command/PrewarmCommand.php
-
message: '#^Using nullsafe property access "\?\-\>value" on left side of \?\? is unnecessary\. Use \-\> instead\.$#'
identifier: nullsafe.neverNull
count: 1
path: src/Command/PrewarmCommand.php
-
message: '#^Call to an undefined method Symfony\\Component\\Form\\FormInterface\<mixed\>\:\:getClickedButton\(\)\.$#'
identifier: method.notFound
count: 3
path: src/Controller/ArticleController.php
-
message: '#^Call to an undefined method Symfony\\Component\\Security\\Core\\User\\UserInterface\:\:getMetadata\(\)\.$#'
identifier: method.notFound
count: 1
path: src/Controller/ArticleController.php
-
message: '#^Call to function is_object\(\) with object will always evaluate to true\.$#'
identifier: function.alreadyNarrowedType
count: 1
path: src/Controller/ArticleController.php
-
message: '#^Call to function is_object\(\) with stdClass will always evaluate to true\.$#'
identifier: function.alreadyNarrowedType
count: 1
path: src/Controller/ArticleController.php
-
message: '#^Offset ''comment_reply…'' on 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\|null, parent_kind\: int, rows\: array\<int, array\<string, mixed\>\>, fragment_url\: string\}\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Controller/ArticleController.php
-
message: '#^Offset ''list'' on 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\>\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Controller/ArticleController.php
-
message: '#^Offset 0 on non\-empty\-list\<string\> in isset\(\) always exists and is not nullable\.$#'
identifier: isset.offset
count: 1
path: src/Controller/ArticleController.php
-
message: '#^Using nullsafe property access "\?\-\>value" on left side of \?\? is unnecessary\. Use \-\> instead\.$#'
identifier: nullsafe.neverNull
count: 1
path: src/Controller/ArticleController.php
-
message: '#^Offset ''ok_relays'' on array\{ok\: true, id\: string, relays\: array\<string, mixed\>, ok_relays\: int, total_relays\: int\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Controller/CommentReplyController.php
-
message: '#^Offset ''total_relays'' on array\{ok\: true, id\: string, relays\: array\<string, mixed\>, ok_relays\: int, total_relays\: int\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Controller/CommentReplyController.php
-
message: '#^Negated boolean expression is always false\.$#'
identifier: booleanNot.alwaysFalse
count: 1
path: src/Controller/DefaultController.php
-
message: '#^Call to function is_array\(\) with array\<int, string\> will always evaluate to true\.$#'
identifier: function.alreadyNarrowedType
count: 1
path: src/Controller/SeoController.php
-
message: '#^Call to function is_string\(\) with string will always evaluate to true\.$#'
identifier: function.alreadyNarrowedType
count: 1
path: src/Controller/SeoController.php
-
message: '#^Offset ''list'' on array\{list\: list\<App\\Entity\\Article\>, category\: array\{title\: string, summary\: string\}, pagination\: array\{page\: int, per_page\: int, total\: int, last_page\: int\}\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Controller/SeoController.php
-
message: '#^Offset ''summary'' on array\{title\: string, summary\: string\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Controller/SeoController.php
-
message: '#^Offset ''title'' on array\{title\: string, summary\: string\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Controller/SeoController.php
-
message: '#^Call to function is_array\(\) with non\-empty\-array will always evaluate to true\.$#'
identifier: function.alreadyNarrowedType
count: 1
path: src/Form/DataTransformer/CommaSeparatedToArrayTransformer.php
-
message: '#^Call to function is_string\(\) with non\-empty\-string will always evaluate to true\.$#'
identifier: function.alreadyNarrowedType
count: 1
path: src/Form/DataTransformer/CommaSeparatedToArrayTransformer.php
-
message: '#^Call to function is_string\(\) with string will always evaluate to true\.$#'
identifier: function.alreadyNarrowedType
count: 1
path: src/Nostr/MagazineEventKeys.php
-
message: '#^Call to function is_array\(\) with array will always evaluate to true\.$#'
identifier: function.alreadyNarrowedType
count: 3
path: src/Nostr/Nip19Codec.php
-
message: '#^Call to function is_array\(\) with array\<mixed\> will always evaluate to true\.$#'
identifier: function.alreadyNarrowedType
count: 1
path: src/Nostr/Nip22CommentTags.php
-
message: '#^Comparison operation "\>\=" between int\<1, max\> and 1 is always true\.$#'
identifier: greaterOrEqual.alwaysTrue
count: 1
path: src/Nostr/Nip22CommentTags.php
-
message: '#^Instanceof between App\\Entity\\ArticleHighlight and App\\Entity\\ArticleHighlight will always evaluate to true\.$#'
identifier: instanceof.alwaysTrue
count: 2
path: src/Service/ArticleBodyHighlightInjector.php
-
message: '#^Instanceof between DOMElement and DOMElement will always evaluate to true\.$#'
identifier: instanceof.alwaysTrue
count: 1
path: src/Service/ArticleBodyHighlightInjector.php
-
message: '#^Negated boolean expression is always false\.$#'
identifier: booleanNot.alwaysFalse
count: 1
path: src/Service/ArticleBodyHighlightInjector.php
-
message: '#^Strict comparison using \=\=\= between false and DOMElement will always evaluate to false\.$#'
identifier: identical.alwaysFalse
count: 1
path: src/Service/ArticleBodyHighlightInjector.php
-
message: '#^Call to function is_object\(\) with object will always evaluate to true\.$#'
identifier: function.alreadyNarrowedType
count: 2
path: src/Service/ArticleCommentThreadLoader.php
-
message: '#^Offset ''partial'' on array\{thread\: array\<int, object\>, quotes\: array\<int, object\>\} on left side of \?\? does not exist\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Service/ArticleCommentThreadLoader.php
-
message: '#^Offset ''quotes'' on array\{thread\: array\<int, object\>, quotes\: array\<int, object\>, partial\?\: bool\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Service/ArticleCommentThreadLoader.php
-
message: '#^Offset ''quotes'' on array\{thread\: array\<int, object\>, quotes\: array\<int, object\>\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Service/ArticleCommentThreadLoader.php
-
message: '#^Offset ''thread'' on array\{thread\: array\<int, object\>, quotes\: array\<int, object\>, partial\?\: bool\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Service/ArticleCommentThreadLoader.php
-
message: '#^Offset ''thread'' on array\{thread\: array\<int, object\>, quotes\: array\<int, object\>\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Service/ArticleCommentThreadLoader.php
-
message: '#^Offset 0 on non\-empty\-list\<string\> on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Service/ArticleCommentThreadLoader.php
-
message: '#^Call to function is_object\(\) with stdClass will always evaluate to true\.$#'
identifier: function.alreadyNarrowedType
count: 2
path: src/Service/CacheService.php
-
message: '#^Negated boolean expression is always false\.$#'
identifier: booleanNot.alwaysFalse
count: 1
path: src/Service/CacheService.php
-
message: '#^Offset 0 on non\-empty\-list\<string\> on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Service/CacheService.php
-
message: '#^Parameter \#1 \$array \(non\-empty\-list\<string\>\) of array_values is already a list, call has no effect\.$#'
identifier: arrayValues.list
count: 1
path: src/Service/CacheService.php
-
message: '#^Strict comparison using \!\=\= between non\-empty\-list\<string\> and array\{\} will always evaluate to true\.$#'
identifier: notIdentical.alwaysTrue
count: 1
path: src/Service/CacheService.php
-
message: '#^Comparison operation "\>\=" between 3 and 2 is always true\.$#'
identifier: greaterOrEqual.alwaysTrue
count: 1
path: src/Service/CommentReplyService.php
-
message: '#^Call to function is_object\(\) with object will always evaluate to true\.$#'
identifier: function.alreadyNarrowedType
count: 1
path: src/Service/HighlightSyncService.php
-
message: '#^Using nullsafe property access "\?\-\>value" on left side of \?\? is unnecessary\. Use \-\> instead\.$#'
identifier: nullsafe.neverNull
count: 1
path: src/Service/HighlightSyncService.php
-
message: '#^Call to function is_string\(\) with string will always evaluate to true\.$#'
identifier: function.alreadyNarrowedType
count: 3
path: src/Service/MagazineContentService.php
-
message: '#^Call to function method_exists\(\) with App\\Entity\\Event and ''getTags'' will always evaluate to true\.$#'
identifier: function.alreadyNarrowedType
count: 1
path: src/Service/MagazineContentService.php
-
message: '#^Offset ''categories'' on array\{categories\: list\<array\{entries\: list\<array\{coordinate\: string, status\: string, reason\: string\}\>\}\>\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Service/MagazineContentService.php
-
message: '#^Offset ''coordinate'' on array\{coordinate\: string, status\: ''missing'', reason\: ''article_not_in_db''\} in isset\(\) always exists and is not nullable\.$#'
identifier: isset.offset
count: 1
path: src/Service/MagazineContentService.php
-
message: '#^Offset ''entries'' on array\{entries\: list\<array\{coordinate\: string, status\: string, reason\: string\}\>\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Service/MagazineContentService.php
-
message: '#^Offset ''reason'' on array\{coordinate\: string, status\: ''missing'', reason\: string\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Service/MagazineContentService.php
-
message: '#^Offset ''status'' on array\{coordinate\: string, status\: string, reason\: string\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Service/MagazineContentService.php
-
message: '#^Offset 0 on non\-empty\-list\<string\> on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Service/MagazineContentService.php
-
message: '#^Cannot call method __invoke\(\) on callable\.$#'
identifier: method.nonObject
count: 4
path: src/Service/MagazineRefresher.php
-
message: '#^Offset ''label'' on array\{label\: string, href\: string, verified\?\: bool\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Service/Nip05VerificationService.php
-
message: '#^Call to function is_object\(\) with object will always evaluate to true\.$#'
identifier: function.alreadyNarrowedType
count: 1
path: src/Service/Nip09DeletionApplier.php
-
message: '#^Call to an undefined method Symfony\\Component\\Security\\Core\\User\\UserInterface\:\:getRelays\(\)\.$#'
identifier: method.notFound
count: 2
path: src/Service/NostrClient.php
-
message: '#^Call to function is_array\(\) with array will always evaluate to true\.$#'
identifier: function.alreadyNarrowedType
count: 1
path: src/Service/NostrClient.php
-
message: '#^Call to function is_object\(\) with object will always evaluate to true\.$#'
identifier: function.alreadyNarrowedType
count: 6
path: src/Service/NostrClient.php
-
message: '#^Call to function is_string\(\) with string will always evaluate to true\.$#'
identifier: function.alreadyNarrowedType
count: 6
path: src/Service/NostrClient.php
-
message: '#^Method App\\Service\\NostrClient\:\:fetchKind5DeletionEventsForAuthors\(\) has invalid return type App\\Service\\stdClass\.$#'
identifier: class.notFound
count: 1
path: src/Service/NostrClient.php
-
message: '#^Offset ''dTags'' on array\{pubkey\: string, kind\: int\<1, max\>, dTags\: non\-empty\-list\<non\-empty\-string\>\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Service/NostrClient.php
-
message: '#^Offset ''kind'' on array\{pubkey\: string, kind\: int\<1, max\>, dTags\: non\-empty\-list\<non\-empty\-string\>\} in isset\(\) always exists and is not nullable\.$#'
identifier: isset.offset
count: 1
path: src/Service/NostrClient.php
-
message: '#^Offset ''pubkey'' on array\{pubkey\: string, kind\: int\<1, max\>, dTags\: non\-empty\-list\<non\-empty\-string\>\} in isset\(\) always exists and is not nullable\.$#'
identifier: isset.offset
count: 1
path: src/Service/NostrClient.php
-
message: '#^Offset ''pubkey'' on array\{pubkey\: string, kind\: int\<1, max\>, dTags\: non\-empty\-list\<non\-empty\-string\>\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Service/NostrClient.php
-
message: '#^PHPDoc tag @return with type swentel\\nostr\\Event\\Event\|null is not subtype of native type stdClass\|null\.$#'
identifier: return.phpDocType
count: 1
path: src/Service/NostrClient.php
-
message: '#^Parameter \#1 \$array \(non\-empty\-list\<string\>\) of array_values is already a list, call has no effect\.$#'
identifier: arrayValues.list
count: 1
path: src/Service/NostrClient.php
-
message: '#^Result of \|\| is always false\.$#'
identifier: booleanOr.alwaysFalse
count: 1
path: src/Service/NostrClient.php
-
message: '#^Strict comparison using \=\=\= between non\-empty\-list\<non\-empty\-string\> and array\{\} will always evaluate to false\.$#'
identifier: identical.alwaysFalse
count: 1
path: src/Service/NostrClient.php
-
message: '#^PHPDoc tag @param for parameter \$event with type ArrayObject\<int, mixed\>\|list\<mixed\> is not subtype of native type object\.$#'
identifier: parameter.phpDocType
count: 1
path: src/Service/NostrShareMenuBuilder.php
-
message: '#^Using nullsafe property access "\?\-\>value" on left side of \?\? is unnecessary\. Use \-\> instead\.$#'
identifier: nullsafe.neverNull
count: 1
path: src/Service/NostrShareMenuBuilder.php
-
message: '#^Offset ''label'' on array\{label\: string, href\: string\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Service/ProfileIdentityLinksBuilder.php
-
message: '#^Offset 0 on non\-empty\-list\<string\> on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 2
path: src/Service/ProfileIdentityLinksBuilder.php
-
message: '#^Call to function is_object\(\) with object will always evaluate to true\.$#'
identifier: function.alreadyNarrowedType
count: 1
path: src/Service/ProfilePaymentLinksBuilder.php
-
message: '#^Offset non\-falsy\-string on array\{\} in isset\(\) does not exist\.$#'
identifier: isset.offset
count: 1
path: src/Service/ProfilePaymentLinksBuilder.php
-
message: '#^Parameter \#1 \$array \(non\-empty\-list\<string\>\) of array_values is already a list, call has no effect\.$#'
identifier: arrayValues.list
count: 1
path: src/Service/ProfilePaymentLinksBuilder.php
-
message: '#^Strict comparison using \!\=\= between non\-empty\-list\<string\> and array\{\} will always evaluate to true\.$#'
identifier: notIdentical.alwaysTrue
count: 1
path: src/Service/ProfilePaymentLinksBuilder.php
-
message: '#^Call to protected method getEntityManager\(\) of class Doctrine\\ORM\\EntityRepository\<object\>\.$#'
identifier: method.protected
count: 1
path: src/Service/TopicIndexService.php
-
message: '#^Property App\\Twig\\Components\\IndexTabs\:\:\$index is never read, only written\.$#'
identifier: property.onlyWritten
count: 1
path: src/Twig/Components/IndexTabs.php
-
message: '#^Call to function method_exists\(\) with App\\Entity\\Event and ''getTags'' will always evaluate to true\.$#'
identifier: function.alreadyNarrowedType
count: 1
path: src/Twig/Components/Molecules/CategoryLink.php
-
message: '#^Call to function method_exists\(\) with App\\Entity\\Event and ''getTags'' will always evaluate to true\.$#'
identifier: function.alreadyNarrowedType
count: 1
path: src/Twig/Components/Organisms/FeaturedList.php
-
message: '#^Offset 0 on non\-empty\-list\<string\> on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Util/NostrEventTags.php
-
message: '#^PHPDoc tag @param references unknown parameter\: \$eventIdsLowerOrMixed$#'
identifier: parameter.notFound
count: 1
path: tests/Service/ArticleBodyHighlightInjectorTest.php
-
message: '#^Negated boolean expression is always true\.$#'
identifier: booleanNot.alwaysTrue
count: 1
path: tests/Service/ArticleHighlightCommonMarkPipelineTest.php
-
message: '#^Unreachable statement \- code above always terminates\.$#'
identifier: deadCode.unreachable
count: 1
path: tests/Service/ArticleHighlightCommonMarkPipelineTest.php
-
message: '#^Call to function method_exists\(\) with ''Symfony\\\\Component\\\\Dotenv\\\\Dotenv'' and ''bootEnv'' will always evaluate to true\.$#'
identifier: function.alreadyNarrowedType
count: 1
path: tests/bootstrap.php

17
phpstan.neon.dist

@ -1,17 +0,0 @@ @@ -1,17 +0,0 @@
# Dependency notes (composer why):
# - phpdocumentor/reflection-docblock: required directly; symfony/* constrain <6
# - phpstan/phpdoc-parser: direct + reflection-docblock / type-resolver
includes:
- vendor/phpstan/phpstan-symfony/extension.neon
- vendor/phpstan/phpstan-doctrine/extension.neon
- phpstan-baseline.neon
parameters:
level: 5
paths:
- src
- tests
symfony:
containerXmlPath: var/cache/dev/App_KernelDevDebugContainer.xml
doctrine:
objectManagerLoader: tests/phpstan-doctrine-object-manager.php

5
scripts/docker-prewarm.sh

@ -17,9 +17,6 @@ echo "==> articles:get (last 2 months → now)" @@ -17,9 +17,6 @@ echo "==> articles:get (last 2 months → now)"
docker compose exec -T php php bin/console articles:get -- '-2 month' 'now'
echo "==> app:prewarm"
# Unbounded PHP time: MagazineRefresher no longer sets a ~210s cap, but -d is a backstop for slow
# Nostr WebSocket I/O. Optional: `export SYMFONY_DEPRECATIONS_HELPER=weak` or
# `NOSTR_RELAY_REQUEST_TIMEOUT=…` to override config/unfold.yaml (see .env.dist).
docker compose exec -T php php -d max_execution_time=0 bin/console app:prewarm
docker compose exec -T php php bin/console app:prewarm
echo "Done."

160
src/Command/ArticleHighlightsAuditCommand.php

@ -1,160 +0,0 @@ @@ -1,160 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Entity\ArticleHighlight;
use App\Repository\ArticleHighlightRepository;
use App\Repository\ArticleRepository;
use App\Service\ArticleBodyHighlightInjector;
use App\Util\CommonMark\Converter;
use App\Service\NostrKeyHelper;
use League\CommonMark\Exception\CommonMarkException;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Run inside the app container, e.g.:
* `php bin/console app:article-highlights-audit bitcoin-is-time --npub=npub1…`
*/
#[AsCommand(
name: 'app:article-highlights-audit',
description: 'Show how many kind-9802 rows match the article and how many <mark> injections succeed (debugging)',
)]
final class ArticleHighlightsAuditCommand extends Command
{
public function __construct(
private readonly ArticleRepository $articleRepository,
private readonly ArticleHighlightRepository $articleHighlightRepository,
private readonly Converter $converter,
private readonly ArticleBodyHighlightInjector $articleBodyHighlightInjector,
private readonly NostrKeyHelper $nostrKeyHelper,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('slug', InputArgument::REQUIRED, 'Article d-identifier (slug), e.g. bitcoin-is-time')
->addOption('npub', null, InputOption::VALUE_OPTIONAL, 'If set, must match the article author (npub1…)');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$slug = trim((string) $input->getArgument('slug'));
if ($slug === '') {
$io->error('Empty slug.');
return Command::FAILURE;
}
$article = $this->articleRepository->findLatestBySlug($slug);
if (null === $article) {
$io->error('No article row for this slug.');
return Command::FAILURE;
}
$expectedNpub = $this->nostrKeyHelper->convertPublicKeyToBech32((string) $article->getPubkey());
$optNpub = $input->getOption('npub');
if (\is_string($optNpub) && $optNpub !== '') {
if ($this->nostrKeyHelper->convertToHex($optNpub) !== strtolower((string) $article->getPubkey())) {
$io->error('npub does not match this article’s author (expected: '.$expectedNpub.').');
return Command::FAILURE;
}
}
$io->title('Article highlights audit: '.$slug);
$io->writeln('Author npub: <info>'.$expectedNpub.'</info>');
$io->writeln('Article id: <info>'.(string) $article->getId().'</info> · kind: <info>'.
($article->getKind()?->value ?? 'null').'</info>');
$highlights = $this->articleHighlightRepository->findByArticle($article);
$io->writeln('Rows from <comment>findByArticle</comment>: <info>'.\count($highlights).'</info>');
try {
$html = $this->converter->convertToHTML((string) $article->getContent());
} catch (CommonMarkException $e) {
$io->error('CommonMark: '.$e->getMessage());
return Command::FAILURE;
}
$out = $this->articleBodyHighlightInjector->inject($html, $highlights);
$injected = $out['injectedEventIds'];
$markCount = \substr_count($out['html'], 'user-highlight__marker');
$io->writeln('Injected event ids with <comment>all highlights together</comment> (duplicates = same passage): <info>'.\count($injected).'</info>');
$io->writeln('<mark class="user-highlight__marker"> count in body: <info>'.$markCount.'</info>');
$io->section('Each highlight in isolation (same HTML, one 9802 at a time)');
$rows = [];
$isolatedOk = 0;
foreach ($highlights as $h) {
if (! $h instanceof ArticleHighlight) {
continue;
}
$eid = \strtolower($h->getEventId());
$one = $this->articleBodyHighlightInjector->inject($html, [$h]);
$found = 1 === \preg_match(
'/\bid=([\'"])highlight-'.preg_quote($eid, '/').'\1/i',
$one['html']
);
if ($found) {
++$isolatedOk;
}
$snippet = $this->excerptOneLine((string) $h->getContent(), 72);
$rows[] = [
$found ? 'yes' : 'no',
$eid,
$snippet,
];
}
$io->table(['Match', 'event id', 'stored `content` (excerpt)'], $rows);
if ($isolatedOk < \count($highlights)) {
$io->writeln(
'‘Match: no’ means the stored passage is absent from the flattened body text, or it diverges '.
'(soft hyphens, smart quotes, edits, footnotes, etc.). Re-sync kind 9802 from relays, or adjust matching in ArticleBodyHighlightInjector.'
);
}
if ($markCount < 1 && \count($highlights) > 0) {
$io->warning('With all highlights together, nothing was injected. Per-row check above still shows if any row matches in isolation.');
} elseif (\count($highlights) < 1) {
$io->note('No article_highlight rows for this slug+author. Run prewarm highlight sync or check MySQL.');
} elseif ($markCount > 0) {
$io->success('At least one <mark> was produced when all rows were passed to the injector together.');
}
if ($io->isVerbose() && $injected !== []) {
$io->section('Injected event ids (batch, may include several per passage)');
$io->listing($injected);
}
return Command::SUCCESS;
}
/**
* One line for the table: reflect {@see ArticleHighlight::getContent()} bytes faithfully.
* Only line breaks are folded to a space so the row stays one line — we do not collapse
* {@see \p{Z}} or remove U+00AD (soft hyphen); doing that made passages look like they
* contained ASCII spaces the Nostr `content` never had.
*/
private function excerptOneLine(string $s, int $max): string
{
$s = (string) \preg_replace('/\R/u', ' ', $s);
if (\mb_strlen($s, 'UTF-8') > $max) {
$s = \mb_substr($s, 0, $max - 1, 'UTF-8').'…';
}
return $s;
}
}

386
src/Command/PrewarmCommand.php

@ -12,13 +12,12 @@ use App\Service\CacheService; @@ -12,13 +12,12 @@ use App\Service\CacheService;
use App\Service\FeaturedAuthorSync;
use App\Service\MagazineContentService;
use App\Service\Nip05VerificationService;
use App\Service\HighlightSyncService;
use App\Service\MagazineRefresher;
use App\Service\Nip09DeletionApplier;
use App\Service\NostrClient;
use App\Service\NostrKeyHelper;
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;
@ -31,12 +30,12 @@ use Symfony\Component\Console\Terminal; @@ -31,12 +30,12 @@ use Symfony\Component\Console\Terminal;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
/**
* Prewarms magazine index cache, author metadata cache, optional comment thread cache, and
* kind-9802 highlights into MySQL. Comments remain cache-only; highlights use `article_highlight`.
* Prewarms magazine index cache, author metadata cache, and optional comment thread cache.
* Does not persist comments to MySQL; comments are cache-only in this app.
*/
#[AsCommand(
name: 'app:prewarm',
description: 'Refresh magazine indices, NIP-09 deletions, profile metadata, NIP-05, comment caches, and highlight DB',
description: 'Refresh magazine indices, NIP-09 deletions, profile metadata, NIP-05 verification cache, and comment caches',
)]
final class PrewarmCommand extends Command
{
@ -54,8 +53,6 @@ final class PrewarmCommand extends Command @@ -54,8 +53,6 @@ final class PrewarmCommand extends Command
private readonly Nip05VerificationService $nip05Verification,
private readonly ProfileIdentityLinksBuilder $profileIdentityLinks,
private readonly FeaturedAuthorRepository $featuredAuthorRepository,
private readonly HighlightSyncService $highlightSyncService,
private readonly NostrKeyHelper $nostrKeyHelper,
) {
parent::__construct();
}
@ -64,30 +61,23 @@ final class PrewarmCommand extends Command @@ -64,30 +61,23 @@ final class PrewarmCommand extends Command
{
$this
->addOption('no-magazine', null, InputOption::VALUE_NONE, 'Skip magazine 30040 index fetch')
->addOption('no-deletions', null, InputOption::VALUE_NONE, 'Skip NIP-09 kind 5 deletion sync (articles + event rows for stored kinds)')
->addOption('no-deletions', null, InputOption::VALUE_NONE, 'Skip NIP-09 kind 5 deletion sync (30023/30024 DB + 30040 magazine cache)')
->addOption('deletion-since', null, InputOption::VALUE_REQUIRED, 'strtotime() window start for kind 5 fetch', '-2 month')
->addOption('no-metadata', null, InputOption::VALUE_NONE, 'Skip batched kind-0 profile prewarm (MySQL event table)')
->addOption('no-metadata', null, InputOption::VALUE_NONE, 'Skip Nostr profile metadata cache')
->addOption('no-comments', null, InputOption::VALUE_NONE, 'Skip comment thread cache')
->addOption('magazine-budget', null, InputOption::VALUE_REQUIRED, 'Seconds wall time for the category 30040 phase only (root fetch is not counted; capped at 600s). If many slugs, raise this or set MAGAZINE_PREWARM_PREFER_SLUGS', '90')
->addOption('metadata-limit', null, InputOption::VALUE_REQUIRED, 'Max distinct author pubkeys to warm (0 = all)', '0')
->addOption('metadata-batch', null, InputOption::VALUE_REQUIRED, 'Kind-0 metadata: pubkeys per Nostr REQ (batched)', '50')
->addOption('comments-max', null, InputOption::VALUE_REQUIRED, 'Newest N magazine category articles to warm comment cache for (0 = all, order: createdAt DESC; excludes generic /articles feed-only rows)', '10')
->addOption('comments-budget', null, InputOption::VALUE_REQUIRED, 'Wall-clock seconds for the whole comments phase (Nostr fetches are slow; a single long thread can exceed a short budget; use 1200+ if prewarming many articles)', '600')
->addOption('no-highlights', null, InputOption::VALUE_NONE, 'Skip kind-9802 highlight fetch → MySQL')
->addOption('highlights-max', null, InputOption::VALUE_REQUIRED, 'Newest N magazine category articles to sync highlights for (0 = all; each Nostr fetch is slow — default 25 keeps prewarm bounded)', '25')
->addOption('highlights-budget', null, InputOption::VALUE_REQUIRED, 'Wall-clock seconds for the highlight sync phase', '600');
->addOption('comments-budget', null, InputOption::VALUE_REQUIRED, 'Wall-clock seconds for the whole comments phase (Nostr fetches are slow; a single long thread can exceed a short budget; use 1200+ if prewarming many articles)', '600');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$this->disableCliExecutionTimeLimit();
$socketTo = (int) $this->params->get('nostr_relay_request_timeout_sec');
if ($socketTo > 0) {
// Align PHP stream layer with Nostr WebSocket timeout (avoids 60s default stalling a relay step).
ini_set('default_socket_timeout', (string) $socketTo);
}
$io = new SymfonyStyle($input, $output);
$keys = new Key();
if (!$input->getOption('no-magazine')) {
$budget = max(1, (int) $input->getOption('magazine-budget'));
@ -184,12 +174,17 @@ final class PrewarmCommand extends Command @@ -184,12 +174,17 @@ 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());
}
}
// MagazineRefresher used to set max_execution_time (~2×budget); re-assert unlimited before
// any later Nostr phase (long-form can exceed that old cap and was causing max-time fatals).
$this->disableCliExecutionTimeLimit();
$io->section('Long-form in DB (category `a` tags — refresh from Nostr)');
try {
$n = $this->magazineContent->ingestLongformForAllMagazineCategories();
@ -198,41 +193,12 @@ final class PrewarmCommand extends Command @@ -198,41 +193,12 @@ final class PrewarmCommand extends Command
} else {
$io->writeln(sprintf('Fetched latest long-form for <info>%d</info> coordinate(s) (new rows + NIP-33 updates).', $n));
}
$report = $this->magazineContent->buildCategoryArticleDbCoverageReport();
$missingCoords = $this->magazineContent->missingInDbCoordinatesFromCoverageReport($report);
$attempt = 0;
while ($missingCoords !== [] && $attempt < 2) {
$attempt++;
$io->writeln(sprintf(
'Retrying unresolved category coordinates from relays (attempt <info>%d</info>, coordinates: <comment>%d</comment>)…',
$attempt,
\count($missingCoords)
));
$this->nostrClient->ingestLongformForCategoryCoordinates($missingCoords);
$report = $this->magazineContent->buildCategoryArticleDbCoverageReport();
$missingCoords = $this->magazineContent->missingInDbCoordinatesFromCoverageReport($report);
}
$this->printCategoryCoverageSummary($io, $report);
} catch (\Throwable $e) {
$this->logger->error('app:prewarm longform ingest failed', ['e' => $e]);
$io->warning('Long-form backfill failed: '.$e->getMessage());
}
$io->section('Featured authors / NIP-05 source list');
try {
$fa = $this->featuredAuthorSync->reconcileListedAuthorsFromMagazineCategories();
$io->writeln(sprintf(
'Derived from category `a` tags: listed now <info>%d</info> · added <info>%d</info> · relisted <info>%d</info> · unlisted <comment>%d</comment>',
$fa['listed_total'],
$fa['added'],
$fa['relisted'],
$fa['unlisted'],
));
} catch (\Throwable $e) {
$this->logger->warning('app:prewarm featured author reconcile', ['e' => $e->getMessage()]);
$io->warning('Featured author reconcile failed: '.$e->getMessage());
}
// MagazineRefresher sets max_execution_time (budget + headroom); restore before metadata.
$this->disableCliExecutionTimeLimit();
if (!$input->getOption('no-deletions')) {
@ -252,7 +218,7 @@ final class PrewarmCommand extends Command @@ -252,7 +218,7 @@ final class PrewarmCommand extends Command
$npubParam = (string) $this->params->get('npub');
if (str_starts_with($npubParam, 'npub')) {
try {
$sitePk = $this->nostrKeyHelper->convertToHex($npubParam);
$sitePk = $keys->convertToHex($npubParam);
if ($sitePk !== '' && 64 === \strlen($sitePk) && !\in_array($sitePk, $deletionPubkeys, true)) {
$deletionPubkeys[] = $sitePk;
}
@ -307,7 +273,7 @@ final class PrewarmCommand extends Command @@ -307,7 +273,7 @@ final class PrewarmCommand extends Command
$npubParam = (string) $this->params->get('npub');
if (str_starts_with($npubParam, 'npub')) {
try {
$sitePk = $this->nostrKeyHelper->convertToHex($npubParam);
$sitePk = $keys->convertToHex($npubParam);
if ($sitePk !== '' && !\in_array($sitePk, $pubkeys, true)) {
$pubkeys[] = $sitePk;
}
@ -373,19 +339,16 @@ final class PrewarmCommand extends Command @@ -373,19 +339,16 @@ final class PrewarmCommand extends Command
$io->success(sprintf('Warmed metadata for %d of %d author(s).', $n, $total));
if ($toWarm !== []) {
$domain = trim((string) $this->params->get('nip05_domain'));
if ($domain !== '') {
$this->waitForSiteWellKnownBeforeVerification($io, $domain);
}
$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 = $this->nostrKeyHelper->convertPublicKeyToBech32($hex);
$npub = $keys->convertPublicKeyToBech32($hex);
$bundle = $this->cacheService->getMetadataBundle($npub);
$rows = $this->profileIdentityLinks->buildNip05($bundle['content'], $bundle['kind0_tags'] ?? []);
$fa = $this->featuredAuthorRepository->findOneByPubkeyHex($hex);
@ -417,235 +380,76 @@ final class PrewarmCommand extends Command @@ -417,235 +380,76 @@ final class PrewarmCommand extends Command
if ($input->getOption('no-comments')) {
$io->note('Skipping comments (--no-comments).');
} else {
$maxArticles = (int) $input->getOption('comments-max');
$io->section('Comment / interaction cache');
$commentBudgetSeconds = max(1, (int) $input->getOption('comments-budget'));
$commentPhaseStart = microtime(true);
$deadline = $commentPhaseStart + $commentBudgetSeconds;
$magazineList = $this->magazineContent->getAllMagazineCategoryArticlesForSyndication();
if ($maxArticles > 0) {
$magazineList = \array_slice($magazineList, 0, $maxArticles);
}
$articles = $magazineList;
$articleCount = \count($articles);
$w = 0;
if ($articleCount === 0) {
$io->note('No articles in DB to scan for comment cache.');
} else {
$cBar = $this->createPrewarmProgressBar($io, $articleCount, 'Comment threads');
$cBar->start();
try {
/** @var Article $article */
foreach ($articles as $article) {
if (microtime(true) >= $deadline) {
$io->warning(sprintf(
'Comment phase stopped: comments-budget reached (%s).',
$this->formatCommentBudgetSecondsPair(microtime(true) - $commentPhaseStart, $commentBudgetSeconds),
));
break;
}
$slug = trim((string) $article->getSlug());
$pubkey = (string) $article->getPubkey();
if ($slug === '' || strlen($pubkey) !== 64) {
$cBar->advance(1);
$cBar->setMessage('skip · invalid row');
continue;
}
$kind = $article->getKind()?->value ?? 30023;
$coordinate = $kind.':'.$pubkey.':'.$slug;
$msg = $slug;
if (strlen($msg) > 56) {
$msg = substr($msg, 0, 53).'…';
}
$cBar->setMessage($msg);
$eventHex = (string) ($article->getEventId() ?? '');
try {
$this->commentThreadLoader->load($coordinate, $eventHex !== '' ? $eventHex : null);
++$w;
} catch (\Throwable $e) {
$this->logger->warning('app:prewarm comment load', ['coord' => $coordinate, 'error' => $e->getMessage()]);
}
$cBar->advance(1);
}
} finally {
$this->finishPrewarmProgressBarWithoutFillingToMax($cBar, $io);
}
}
$io->success(sprintf(
'Warmed comment cache for %d of %d article(s). Comment phase wall time %s.',
$w,
$articleCount,
$this->formatCommentBudgetSecondsPair(microtime(true) - $commentPhaseStart, $commentBudgetSeconds),
));
}
if ($input->getOption('no-highlights')) {
$io->note('Skipping highlight DB sync (--no-highlights).');
return Command::SUCCESS;
}
$maxH = (int) $input->getOption('highlights-max');
$io->section('Highlights (kind 9802 → MySQL)');
$hBudget = max(1, (int) $input->getOption('highlights-budget'));
$hStart = microtime(true);
$hDeadline = $hStart + $hBudget;
$hList = $this->magazineContent->getAllMagazineCategoryArticlesForSyndication();
if ($maxH > 0) {
$hList = \array_slice($hList, 0, $maxH);
$maxArticles = (int) $input->getOption('comments-max');
$io->section('Comment / interaction cache');
$commentBudgetSeconds = max(1, (int) $input->getOption('comments-budget'));
$commentPhaseStart = microtime(true);
$deadline = $commentPhaseStart + $commentBudgetSeconds;
$magazineList = $this->magazineContent->getAllMagazineCategoryArticlesForSyndication();
if ($maxArticles > 0) {
$magazineList = \array_slice($magazineList, 0, $maxArticles);
}
$hCount = \count($hList);
$hW = 0;
$hScanned = 0;
if ($hCount === 0) {
$io->note('No articles in DB to scan for highlights.');
$articles = $magazineList;
$articleCount = \count($articles);
$w = 0;
if ($articleCount === 0) {
$io->note('No articles in DB to scan for comment cache.');
} else {
$hBar = $this->createPrewarmProgressBar($io, $hCount, 'Kind 9802 highlights');
$hBar->start();
$cBar = $this->createPrewarmProgressBar($io, $articleCount, 'Comment threads');
$cBar->start();
try {
/** @var Article $article */
foreach ($hList as $article) {
if (microtime(true) >= $hDeadline) {
$io->warning(sprintf('Highlight phase stopped: highlights-budget reached (%d s).', $hBudget));
foreach ($articles as $article) {
if (microtime(true) >= $deadline) {
$io->warning(sprintf(
'Comment phase stopped: comments-budget reached (%s).',
$this->formatCommentBudgetSecondsPair(microtime(true) - $commentPhaseStart, $commentBudgetSeconds),
));
break;
}
++$hScanned;
$slug = trim((string) $article->getSlug());
$pubkey = (string) $article->getPubkey();
if ($slug === '' || strlen($pubkey) !== 64) {
$hBar->advance(1);
$hBar->setMessage('skip · invalid row');
$cBar->advance(1);
$cBar->setMessage('skip · invalid row');
continue;
}
$tmsg = $slug;
if (strlen($tmsg) > 56) {
$tmsg = substr($tmsg, 0, 53).'…';
$kind = $article->getKind()?->value ?? 30023;
$coordinate = $kind.':'.$pubkey.':'.$slug;
$msg = $slug;
if (strlen($msg) > 56) {
$msg = substr($msg, 0, 53).'…';
}
$hBar->setMessage($tmsg);
$cBar->setMessage($msg);
$eventHex = (string) ($article->getEventId() ?? '');
try {
$hW += $this->highlightSyncService->syncForArticle($article);
$this->commentThreadLoader->load($coordinate, $eventHex !== '' ? $eventHex : null);
++$w;
} catch (\Throwable $e) {
$this->logger->warning('app:prewarm highlight', ['slug' => $slug, 'error' => $e->getMessage()]);
$this->logger->warning('app:prewarm comment load', ['coord' => $coordinate, 'error' => $e->getMessage()]);
}
$hBar->advance(1);
$cBar->advance(1);
}
} finally {
$this->finishPrewarmProgressBarWithoutFillingToMax($hBar, $io);
$this->finishPrewarmProgressBarWithoutFillingToMax($cBar, $io);
}
}
$io->success(sprintf(
'Highlight rows written/updated: <info>%d</info> (articles scanned: <info>%d</info> of %d, wall time <info>%.0f</info>s / %d s budget).',
$hW,
$hScanned,
$hCount,
microtime(true) - $hStart,
$hBudget
'Warmed comment cache for %d of %d article(s). Comment phase wall time %s.',
$w,
$articleCount,
$this->formatCommentBudgetSecondsPair(microtime(true) - $commentPhaseStart, $commentBudgetSeconds),
));
return Command::SUCCESS;
}
private function waitForSiteWellKnownBeforeVerification(SymfonyStyle $io, string $domain): void
{
$expected = [];
foreach ($this->featuredAuthorRepository->findAllListedOrderByLocalPart() as $row) {
$local = trim((string) $row->getLocalPart());
$hex = strtolower(trim((string) $row->getPubkeyHex()));
if ($local === '' || 64 !== \strlen($hex) || !ctype_xdigit($hex)) {
continue;
}
$expected[$local] = $hex;
}
if ($expected === []) {
return;
}
$io->writeln(sprintf(
'Ensuring site NIP-05 directory is current before verification (<comment>%s</comment>, names: <info>%d</info>)…',
$domain,
\count($expected)
));
$url = 'https://'.$domain.'/.well-known/nostr.json';
$attempt = 0;
$maxAttempts = 4;
while ($attempt < $maxAttempts) {
$attempt++;
$payload = $this->fetchWellKnownNamesMap($url);
if ($payload !== null && $this->wellKnownHasExpectedNames($payload, $expected)) {
$io->writeln(sprintf(' <info>OK</info> /.well-known/nostr.json is up-to-date (attempt %d/%d).', $attempt, $maxAttempts));
return;
}
if ($attempt < $maxAttempts) {
usleep(1_500_000);
}
}
$io->warning('Site /.well-known/nostr.json did not reflect current featured authors before verification; NIP-05 checks may fail transiently.');
}
/**
* @return array<string, string>|null
*/
private function fetchWellKnownNamesMap(string $url): ?array
{
$ctx = stream_context_create([
'http' => [
'method' => 'GET',
'header' => "User-Agent: Unfold-Prewarm/1.0\r\nAccept: application/json\r\n",
'timeout' => 8,
'ignore_errors' => true,
],
'ssl' => [
'verify_peer' => true,
'verify_peer_name' => true,
],
]);
$raw = @file_get_contents($url, false, $ctx);
if ($raw === false) {
return null;
}
try {
$decoded = json_decode($raw, true, 512, \JSON_THROW_ON_ERROR);
} catch (\JsonException) {
return null;
}
if (!\is_array($decoded) || !isset($decoded['names']) || !\is_array($decoded['names'])) {
return null;
}
$out = [];
foreach ($decoded['names'] as $k => $v) {
if (!\is_string($k) || !\is_string($v)) {
continue;
}
$key = trim($k);
$hex = strtolower(trim($v));
if ($key === '' || 64 !== \strlen($hex) || !ctype_xdigit($hex)) {
continue;
}
$out[$key] = $hex;
}
return $out;
}
/**
* @param array<string, string> $names
* @param array<string, string> $expected
*/
private function wellKnownHasExpectedNames(array $names, array $expected): bool
{
foreach ($expected as $local => $hex) {
if (!isset($names[$local]) || !hash_equals($hex, $names[$local])) {
return false;
}
}
return true;
}
/**
* Absolute used/budget wall seconds for the comment phase, e.g. "127.4/600 s" (not a percentage).
*/
@ -752,68 +556,4 @@ final class PrewarmCommand extends Command @@ -752,68 +556,4 @@ final class PrewarmCommand extends Command
@set_time_limit(0);
@ini_set('max_execution_time', '0');
}
/**
* @param array{
* categories: list<array{
* slug: string,
* title: string,
* event_id: string,
* listed_total: int,
* resolved_total: int,
* missing_total: int,
* entries: list<array{
* coordinate: string,
* status: string,
* reason: string,
* article_title?: string
* }>
* }>,
* totals: array{categories: int, listed: int, resolved: int, missing: int}
* } $report
*/
private function printCategoryCoverageSummary(SymfonyStyle $io, array $report): void
{
$io->section('Category index -> DB coverage');
$tot = $report['totals'] ?? ['categories' => 0, 'listed' => 0, 'resolved' => 0, 'missing' => 0];
$io->writeln(sprintf(
'Categories: <info>%d</info> · listed coordinates: <info>%d</info> · in DB: <info>%d</info> · missing: <comment>%d</comment>',
(int) ($tot['categories'] ?? 0),
(int) ($tot['listed'] ?? 0),
(int) ($tot['resolved'] ?? 0),
(int) ($tot['missing'] ?? 0),
));
foreach ($report['categories'] ?? [] as $cat) {
$title = trim((string) ($cat['title'] ?? ''));
$slug = (string) ($cat['slug'] ?? '');
$eventId = (string) ($cat['event_id'] ?? '');
$io->writeln(sprintf(
' - <info>%s</info> (%s) · event <comment>%s</comment> · listed <info>%d</info>, in DB <info>%d</info>, missing <comment>%d</comment>',
$title !== '' ? $title : $slug,
$slug,
$eventId !== '' ? $eventId : 'n/a',
(int) ($cat['listed_total'] ?? 0),
(int) ($cat['resolved_total'] ?? 0),
(int) ($cat['missing_total'] ?? 0),
));
foreach ($cat['entries'] ?? [] as $entry) {
$coord = (string) ($entry['coordinate'] ?? '');
if ($coord === '') {
continue;
}
$status = (string) ($entry['status'] ?? 'missing');
if ($status === 'resolved') {
$titleOut = trim((string) ($entry['article_title'] ?? ''));
$io->writeln(sprintf(
' + <info>OK</info> %s%s',
$coord,
$titleOut !== '' ? ' -> '.$titleOut : ''
));
} else {
$reason = (string) ($entry['reason'] ?? 'unknown');
$io->writeln(sprintf(' - <comment>MISSING</comment> %s (%s)', $coord, $reason));
}
}
}
}
}

130
src/Controller/ArticleController.php

@ -3,23 +3,21 @@ @@ -3,23 +3,21 @@
namespace App\Controller;
use App\Entity\Article;
use App\Repository\ArticleHighlightRepository;
use App\Repository\ArticleRepository;
use App\Service\ArticleBodyHighlightInjector;
use App\Enum\KindsEnum;
use App\Nostr\Nip22CommentTags;
use App\Form\EditorType;
use App\Service\ArticleCommentThreadLoader;
use App\Service\NostrClient;
use App\Service\NostrKeyHelper;
use App\Service\CacheService;
use App\Nostr\Nip19Codec;
use App\Util\CommonMark\Converter;
use Doctrine\ORM\EntityManagerInterface;
use League\CommonMark\Exception\CommonMarkException;
use nostriphant\NIP19\Bech32;
use nostriphant\NIP19\Data\NAddr;
use Psr\Log\LoggerInterface;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException;
use swentel\nostr\Key\Key;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@ -252,14 +250,15 @@ class ArticleController extends AbstractController @@ -252,14 +250,15 @@ class ArticleController extends AbstractController
* @throws \Exception
*/
#[Route('/article/{naddr}', name: 'article-naddr')]
public function naddr(NostrClient $nostrClient, Nip19Codec $nip19, NostrKeyHelper $nostrKeyHelper, $naddr)
public function naddr(NostrClient $nostrClient, $naddr)
{
$decoded = $nip19->decode($naddr);
$decoded = new Bech32($naddr);
if ($decoded->type !== 'naddr') {
throw new \Exception('Invalid naddr');
}
/** @var NAddr $data */
$data = $decoded->data;
$slug = $data->identifier;
$relays = $data->relays;
@ -273,7 +272,7 @@ class ArticleController extends AbstractController @@ -273,7 +272,7 @@ class ArticleController extends AbstractController
$nostrClient->getLongFormFromNaddr($slug, $relays, $author, $kind);
if ($slug) {
$npub = $nostrKeyHelper->convertPublicKeyToBech32((string) $author);
$npub = (new Key())->convertPublicKeyToBech32((string) $author);
return $this->redirectToRoute('article', ['npub' => $npub, 'slug' => $slug], Response::HTTP_MOVED_PERMANENTLY);
}
@ -297,16 +296,15 @@ class ArticleController extends AbstractController @@ -297,16 +296,15 @@ class ArticleController extends AbstractController
EntityManagerInterface $entityManager,
CacheService $cacheService,
Converter $converter,
ArticleCommentThreadLoader $commentThreadLoader,
ArticleHighlightRepository $articleHighlightRepository,
ArticleBodyHighlightInjector $articleBodyHighlightInjector,
NostrKeyHelper $nostrKeyHelper,
): Response {
ArticleCommentThreadLoader $commentThreadLoader
): Response
{
$article = $this->loadLatestArticleBySlug($entityManager, $slug);
if ($article === null) {
throw $this->createNotFoundException('The article could not be found');
}
if ($nostrKeyHelper->convertToHex($npub) !== strtolower((string) $article->getPubkey())) {
$key = new Key();
if ($key->convertToHex($npub) !== strtolower((string) $article->getPubkey())) {
throw $this->createNotFoundException('The article could not be found');
}
@ -314,10 +312,7 @@ class ArticleController extends AbstractController @@ -314,10 +312,7 @@ class ArticleController extends AbstractController
$article,
$cacheService,
$converter,
$commentThreadLoader,
$articleHighlightRepository,
$articleBodyHighlightInjector,
$nostrKeyHelper
$commentThreadLoader
);
}
@ -333,40 +328,49 @@ class ArticleController extends AbstractController @@ -333,40 +328,49 @@ class ArticleController extends AbstractController
public function articleLegacyRedirect(
string $slug,
EntityManagerInterface $entityManager,
NostrKeyHelper $nostrKeyHelper,
): Response {
$article = $this->loadLatestArticleBySlug($entityManager, $slug);
if ($article === null) {
throw $this->createNotFoundException('The article could not be found');
}
$npub = $nostrKeyHelper->convertPublicKeyToBech32((string) $article->getPubkey());
$key = new Key();
$npub = $key->convertPublicKeyToBech32((string) $article->getPubkey());
return $this->redirectToRoute('article', ['npub' => $npub, 'slug' => $slug], Response::HTTP_MOVED_PERMANENTLY);
}
private function loadLatestArticleBySlug(EntityManagerInterface $entityManager, string $slug): ?Article
{
/** @var ArticleRepository $repository */
$repository = $entityManager->getRepository(Article::class);
$articles = $repository->findBy(['slug' => $slug]);
$revisions = \count($articles);
if ($revisions === 0) {
return null;
}
if ($revisions > 1) {
usort($articles, function ($a, $b) {
return $b->getCreatedAt() <=> $a->getCreatedAt();
});
return end($articles);
}
return $repository->findLatestBySlug($slug);
return $articles[0];
}
private function renderArticle(
Article $article,
CacheService $cacheService,
Converter $converter,
ArticleCommentThreadLoader $commentThreadLoader,
ArticleHighlightRepository $articleHighlightRepository,
ArticleBodyHighlightInjector $articleBodyHighlightInjector,
NostrKeyHelper $nostrKeyHelper,
ArticleCommentThreadLoader $commentThreadLoader
): Response {
set_time_limit(300); // 5 minutes
ini_set('max_execution_time', '300');
$html = $converter->convertToHtml($article->getContent());
$npub = $nostrKeyHelper->convertPublicKeyToBech32($article->getPubkey());
$key = new Key();
$npub = $key->convertPublicKeyToBech32($article->getPubkey());
$author = $cacheService->getMetadata($npub);
$kind = $article->getKind()?->value ?? 30023;
@ -379,7 +383,6 @@ class ArticleController extends AbstractController @@ -379,7 +383,6 @@ class ArticleController extends AbstractController
$commentsData = null;
$commentsPreloaded = false;
$commentReplyContext = $this->buildArticleReplyContext($coordinate, $eid, $articleTitle);
$cached = $commentThreadLoader->tryLoadFromCacheOnly($coordinate, $eid);
if (null !== $cached) {
$commentsData = $this->enrichCommentDataWithReplyContext(
@ -388,14 +391,9 @@ class ArticleController extends AbstractController @@ -388,14 +391,9 @@ class ArticleController extends AbstractController
$eid,
$articleTitle
);
$commentReplyContext = $commentsData['comment_reply_context'] ?? $commentReplyContext;
$commentsPreloaded = true;
}
$highlights = $articleHighlightRepository->findByArticle($article);
$injection = $articleBodyHighlightInjector->inject($html, $highlights);
$html = $injection['html'];
return $this->render('pages/article.html.twig', [
'article' => $article,
'author' => $author,
@ -403,36 +401,9 @@ class ArticleController extends AbstractController @@ -403,36 +401,9 @@ class ArticleController extends AbstractController
'content' => $html,
'comments_data' => $commentsData,
'comments_preloaded' => $commentsPreloaded,
'comment_reply_context' => $commentReplyContext,
]);
}
/**
* Base article-level reply context so the top "Reply" button can render before async comments load.
*
* @return array{
* can_publish: bool,
* coordinate: string,
* article_event_id: ?string,
* parent_kind: int,
* rows: array<int, array<string, mixed>>,
* fragment_url: string
* }
*/
private function buildArticleReplyContext(string $coordinate, ?string $articleEventId, string $articleTitle): array
{
$base = [
'list' => [],
'quotes' => [],
'commentLinks' => [],
'quoteLinks' => [],
'processedContent' => [],
];
$enriched = $this->enrichCommentDataWithReplyContext($base, $coordinate, $articleEventId, $articleTitle);
return $enriched['comment_reply_context'];
}
/**
* Fetch complete event to show as preview
* POST data contains an object with request params
@ -442,7 +413,6 @@ class ArticleController extends AbstractController @@ -442,7 +413,6 @@ class ArticleController extends AbstractController
Request $request,
NostrClient $nostrClient,
CacheService $cacheService,
NostrKeyHelper $nostrKeyHelper,
): Response {
$data = $request->getContent();
$descriptor = json_decode($data);
@ -466,7 +436,8 @@ class ArticleController extends AbstractController @@ -466,7 +436,8 @@ class ArticleController extends AbstractController
if (!\is_object($hint) || !isset($hint->pubkey)) {
$html = '<span class="text-subtle">Profile preview unavailable.</span>';
} else {
$npub = $nostrKeyHelper->convertPublicKeyToBech32($hint->pubkey);
$key = new Key();
$npub = $key->convertPublicKeyToBech32($hint->pubkey);
$metadata = $cacheService->getMetadata($npub);
$metadata->npub = $npub;
$metadata->pubkey = $hint->pubkey;
@ -513,7 +484,7 @@ class ArticleController extends AbstractController @@ -513,7 +484,7 @@ class ArticleController extends AbstractController
#[Route('/article-editor/create', name: 'editor-create')]
#[Route('/article-editor/edit/{id}', name: 'editor-edit')]
public function newArticle(Request $request, EntityManagerInterface $entityManager, CacheItemPoolInterface $articlesCache,
WorkflowInterface $articlePublishingWorkflow, NostrKeyHelper $nostrKeyHelper, Article $article = null): Response
WorkflowInterface $articlePublishingWorkflow, Article $article = null): Response
{
if (!$article) {
$article = new Article();
@ -530,7 +501,8 @@ class ArticleController extends AbstractController @@ -530,7 +501,8 @@ class ArticleController extends AbstractController
// Step 3: Check if the form is submitted and valid
if ($form->isSubmitted() && $form->isValid()) {
$user = $this->getUser();
$currentPubkey = $nostrKeyHelper->convertToHex($user->getUserIdentifier());
$key = new Key();
$currentPubkey = $key->convertToHex($user->getUserIdentifier());
if ($article->getPubkey() === null) {
$article->setPubkey($currentPubkey);
@ -574,17 +546,18 @@ class ArticleController extends AbstractController @@ -574,17 +546,18 @@ class ArticleController extends AbstractController
*/
#[Route('/article-preview/{d}', name: 'article-preview')]
public function preview($d, Converter $converter,
CacheItemPoolInterface $articlesCache, NostrKeyHelper $nostrKeyHelper): Response
CacheItemPoolInterface $articlesCache): Response
{
$user = $this->getUser();
$currentPubkey = $nostrKeyHelper->convertToHex($user->getUserIdentifier());
$key = new Key();
$currentPubkey = $key->convertToHex($user->getUserIdentifier());
$cacheKey = 'article_' . $currentPubkey . '_' . $d;
$cacheItem = $articlesCache->getItem($cacheKey);
$article = $cacheItem->get();
$content = $converter->convertToHtml($article->getContent());
$previewNpub = $nostrKeyHelper->convertPublicKeyToBech32($currentPubkey);
$previewNpub = (new Key())->convertPublicKeyToBech32($currentPubkey);
return $this->render('pages/article.html.twig', [
'article' => $article,
@ -596,25 +569,16 @@ class ArticleController extends AbstractController @@ -596,25 +569,16 @@ class ArticleController extends AbstractController
}
/**
* Display latest community articles (paginated).
* Display latest 20 community articles
*/
#[Route('/articles', name: 'articles')]
public function latestArticles(Request $request, EntityManagerInterface $entityManager): Response
public function latestArticles(EntityManagerInterface $entityManager): Response
{
set_time_limit(300); // 5 minutes
ini_set('max_execution_time', '300');
$perPage = 25;
$page = max(1, $request->query->getInt('page', 1));
$offset = ($page - 1) * $perPage;
$repo = $entityManager->getRepository(Article::class);
$total = $repo->count([]);
$lastPage = max(1, (int) ceil($total / $perPage));
if ($page > $lastPage) {
$page = $lastPage;
$offset = ($page - 1) * $perPage;
}
$articles = $repo->findBy([], ['createdAt' => 'DESC'], $perPage, $offset);
$articles = $entityManager->getRepository(Article::class)
->findBy([], ['createdAt' => 'DESC'], 20);
$category = (object) [
'title' => 'Community Articles',
@ -625,12 +589,6 @@ class ArticleController extends AbstractController @@ -625,12 +589,6 @@ class ArticleController extends AbstractController
'category' => $category,
'list' => $articles,
'sync_slug' => '',
'pagination' => [
'page' => $page,
'per_page' => $perPage,
'total' => $total,
'last_page' => $lastPage,
],
]);
}

49
src/Controller/AuthorController.php

@ -9,12 +9,11 @@ use App\Repository\FeaturedAuthorRepository; @@ -9,12 +9,11 @@ use App\Repository\FeaturedAuthorRepository;
use App\Service\CacheService;
use App\Service\Nip05VerificationService;
use App\Service\NostrClient;
use App\Service\NostrKeyHelper;
use App\Service\ProfileIdentityLinksBuilder;
use App\Service\ProfilePaymentLinksBuilder;
use Exception;
use swentel\nostr\Key\Key;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
@ -25,7 +24,6 @@ class AuthorController extends AbstractController @@ -25,7 +24,6 @@ class AuthorController extends AbstractController
*/
#[Route('/p/{npub}', name: 'author-profile', requirements: ['npub' => '^npub1.*'])]
public function index(
Request $request,
$npub,
NostrClient $nostrClient,
CacheService $cacheService,
@ -34,27 +32,41 @@ class AuthorController extends AbstractController @@ -34,27 +32,41 @@ class AuthorController extends AbstractController
Nip05VerificationService $nip05Verification,
ProfilePaymentLinksBuilder $profilePaymentLinks,
ProfileIdentityLinksBuilder $profileIdentityLinks,
NostrKeyHelper $nostrKeyHelper,
): Response {
// Profile pages chain several sequential Nostr REQ runs; match article pages so a slow relay
// set does not hit PHP’s default 30s max_execution_time during Twig render.
@set_time_limit(300);
@ini_set('max_execution_time', '300');
$pubkey = $nostrKeyHelper->convertToHex($npub);
$keys = new Key();
$pubkey = $keys->convertToHex($npub);
$bundle = $cacheService->getMetadataBundle($npub);
$author = $bundle['content'];
$kind0Tags = $bundle['kind0_tags'];
$perPage = 25;
$page = max(1, $request->query->getInt('page', 1));
$total = $articleRepository->countByPubkey($pubkey);
$lastPage = max(1, (int) ceil($total / $perPage));
if ($page > $lastPage) {
$page = $lastPage;
// Retrieve long-form content for the author
try {
$list = $nostrClient->getLongFormContentForPubkey($npub);
} catch (Exception $e) {
$list = [];
}
$offset = ($page - 1) * $perPage;
$articles = $articleRepository->findByPubkeyPaginated($pubkey, $perPage, $offset);
// Also look for articles in the database by pubkey
$dbArticles = $articleRepository->findByPubkey($pubkey, 25);
$list = array_merge($list, $dbArticles);
$articles = [];
// Deduplicate by slugs
foreach ($list as $item) {
if (!key_exists((string) $item->getSlug(), $articles)) {
$articles[(string) $item->getSlug()] = $item;
}
}
// Sort articles by date
usort($articles, function ($a, $b) {
return $b->getCreatedAt() <=> $a->getCreatedAt();
});
$kind10133 = [];
try {
@ -80,12 +92,6 @@ class AuthorController extends AbstractController @@ -80,12 +92,6 @@ class AuthorController extends AbstractController
'profile_websites' => $profileIdentityLinks->buildWebsites($author, $kind0Tags),
'profile_nip05' => $profileNip05,
'profile_payment_links' => $profilePaymentLinks->buildPaymentRows($author, $kind0Tags, $extraPayto),
'pagination' => [
'page' => $page,
'per_page' => $perPage,
'total' => $total,
'last_page' => $lastPage,
],
]);
}
@ -93,9 +99,10 @@ class AuthorController extends AbstractController @@ -93,9 +99,10 @@ class AuthorController extends AbstractController
* @throws Exception
*/
#[Route('/p/{pubkey}', name: 'author-redirect')]
public function authorRedirect($pubkey, NostrKeyHelper $nostrKeyHelper): Response
public function authorRedirect($pubkey): Response
{
$npub = $nostrKeyHelper->convertPublicKeyToBech32($pubkey);
$keys = new Key();
$npub = $keys->convertPublicKeyToBech32($pubkey);
return $this->redirectToRoute('author-profile', ['npub' => $npub]);
}

7
src/Controller/CommentReplyController.php

@ -54,12 +54,7 @@ final class CommentReplyController extends AbstractController @@ -54,12 +54,7 @@ final class CommentReplyController extends AbstractController
$commentThreadLoader->invalidateThread($coord, 64 === \strlen((string) $eid) && ctype_xdigit((string) $eid) ? $eid : null);
}
return $this->json([
'ok' => true,
'id' => $out['id'],
'ok_relays' => $out['ok_relays'] ?? null,
'total_relays' => $out['total_relays'] ?? null,
]);
return $this->json(['ok' => true, 'id' => $out['id']]);
}
/** @var array{ok: false, error: string, code: int} $out */

14
src/Controller/DefaultController.php

@ -4,12 +4,10 @@ declare(strict_types=1); @@ -4,12 +4,10 @@ declare(strict_types=1);
namespace App\Controller;
use App\Repository\ArticleHighlightRepository;
use App\Service\MagazineContentService;
use Exception;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
@ -17,31 +15,25 @@ class DefaultController extends AbstractController @@ -17,31 +15,25 @@ class DefaultController extends AbstractController
{
public function __construct(
private readonly MagazineContentService $magazineContent,
private readonly ArticleHighlightRepository $articleHighlightRepository,
) {
}
#[Route('/', name: 'home')]
public function index(): Response
{
$categoryATags = $this->magazineContent->getHomeCategoryAIndexTagsFromStoreOnly();
return $this->render('home.html.twig', [
'home_featured_tiles' => $this->magazineContent->buildHomeMixedFeaturedWallTiles($categoryATags),
'home_highlights' => $this->articleHighlightRepository->findRecentForHome(40),
'indices' => $this->magazineContent->getHomeCategoryAIndexTagsFromStoreOnly(),
]);
}
#[Route('/cat/{slug}', name: 'magazine-category')]
public function magCategory(Request $request, string $slug): Response
public function magCategory(string $slug): Response
{
$page = max(1, $request->query->getInt('page', 1));
$data = $this->magazineContent->getCategoryPageData($slug, $page, 25);
$data = $this->magazineContent->getCategoryPageData($slug);
return $this->render('pages/category.html.twig', [
'list' => $data['list'],
'category' => $data['category'],
'pagination' => $data['pagination'],
'sync_slug' => $slug,
]);
}

18
src/Controller/EventController.php

@ -4,14 +4,15 @@ declare(strict_types=1); @@ -4,14 +4,15 @@ declare(strict_types=1);
namespace App\Controller;
use App\Nostr\Nip19Codec;
use App\Service\NostrClient;
use App\Service\NostrLinkParser;
use App\Service\NostrShareMenuBuilder;
use App\Service\CacheService;
use App\Service\NostrKeyHelper;
use Exception;
use nostriphant\NIP19\Bech32;
use nostriphant\NIP19\Data;
use Psr\Log\LoggerInterface;
use swentel\nostr\Key\Key;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@ -27,21 +28,21 @@ class EventController extends AbstractController @@ -27,21 +28,21 @@ class EventController extends AbstractController
public function index(
$nevent,
Request $request,
Nip19Codec $nip19,
NostrClient $nostrClient,
CacheService $cacheService,
NostrLinkParser $nostrLinkParser,
NostrShareMenuBuilder $nostrShareMenuBuilder,
NostrKeyHelper $nostrKeyHelper,
LoggerInterface $logger,
): Response {
$logger->info('Accessing event page', ['nevent' => $nevent]);
try {
// Decode nevent - nevent1... is a NIP-19 encoded event identifier
$decoded = $nip19->decode($nevent);
$decoded = new Bech32($nevent);
$logger->info('Decoded event', ['decoded' => json_encode($decoded)]);
// Get the event using the event ID
/** @var Data $data */
$data = $decoded->data;
$logger->info('Event data', ['data' => json_encode($data)]);
@ -49,12 +50,12 @@ class EventController extends AbstractController @@ -49,12 +50,12 @@ class EventController extends AbstractController
// Sort which event type this is using $data->type
switch ($decoded->type) {
case 'note':
$eventHex = (string) ($data->data ?? '');
// Handle note (regular event)
$relays = $data->relays ?? [];
if (!\is_array($relays)) {
$relays = [];
}
$event = $nostrClient->getEventById($eventHex, $relays);
$event = $nostrClient->getEventById($data->identifier, $relays);
break;
case 'nprofile':
@ -108,7 +109,8 @@ class EventController extends AbstractController @@ -108,7 +109,8 @@ class EventController extends AbstractController
// If author is included in the event, get metadata
$authorMetadata = null;
if (isset($event->pubkey)) {
$npub = $nostrKeyHelper->convertPublicKeyToBech32($event->pubkey);
$key = new Key();
$npub = $key->convertPublicKeyToBech32($event->pubkey);
$authorMetadata = $cacheService->getMetadata($npub);
}

46
src/Controller/FeaturedAuthorsController.php

@ -5,10 +5,13 @@ declare(strict_types=1); @@ -5,10 +5,13 @@ declare(strict_types=1);
namespace App\Controller;
use App\Repository\FeaturedAuthorRepository;
use App\Service\FeaturedAuthorListedRows;
use App\Service\CacheService;
use App\Service\NostrClient;
use App\Service\ProfileIdentityLinksBuilder;
use App\Service\ProfilePaymentLinksBuilder;
use swentel\nostr\Key\Key;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
@ -19,31 +22,38 @@ final class FeaturedAuthorsController extends AbstractController @@ -19,31 +22,38 @@ final class FeaturedAuthorsController extends AbstractController
{
#[Route('/featured-authors', name: 'featured_authors', methods: ['GET'])]
public function index(
Request $request,
FeaturedAuthorRepository $featuredAuthorRepository,
FeaturedAuthorListedRows $featuredAuthorListedRows,
CacheService $cacheService,
NostrClient $nostrClient,
ProfileIdentityLinksBuilder $profileIdentityLinks,
ProfilePaymentLinksBuilder $profilePaymentLinks,
ParameterBagInterface $params,
): Response {
$domain = trim((string) $params->get('nip05_domain'));
$perPage = 25;
$page = max(1, $request->query->getInt('page', 1));
$total = $featuredAuthorRepository->countListed();
$lastPage = max(1, (int) ceil($total / $perPage));
if ($page > $lastPage) {
$page = $lastPage;
$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'];
$kind10133 = [];
try {
$kind10133 = $nostrClient->getKind10133PaymentTargetEventsForNpub($npub, 20);
} catch (\Throwable) {
}
$extraPayto = $profilePaymentLinks->collectPaytoUrisFromNipA3Kind10133Events($kind10133);
$authors[] = [
'author' => $author,
'npub' => $npub,
'profile_websites' => $profileIdentityLinks->buildWebsites($author, $kind0Tags),
'profile_payment_links' => $profilePaymentLinks->buildPaymentRows($author, $kind0Tags, $extraPayto),
];
}
$offset = ($page - 1) * $perPage;
$authors = $featuredAuthorListedRows->buildListedByLocalPartPage($perPage, $offset);
return $this->render('pages/featured_authors.html.twig', [
'authors' => $authors,
'nip05_domain' => $domain,
'pagination' => [
'page' => $page,
'per_page' => $perPage,
'total' => $total,
'last_page' => $lastPage,
],
]);
}
}

34
src/Controller/SearchController.php

@ -4,43 +4,15 @@ declare(strict_types=1); @@ -4,43 +4,15 @@ declare(strict_types=1);
namespace App\Controller;
use App\Repository\ArticleRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class SearchController extends AbstractController
{
#[Route('/search', name: 'search', methods: ['GET'])]
public function index(Request $request, ArticleRepository $articleRepository): Response
#[Route('/search')]
public function index(): Response
{
$query = trim((string) $request->query->get('q', ''));
$perPage = 25;
$page = max(1, $request->query->getInt('page', 1));
$total = 0;
$results = [];
$lastPage = 1;
if ($query !== '') {
$total = $articleRepository->countSearchArticles($query);
$lastPage = max(1, (int) ceil($total / $perPage));
if ($page > $lastPage) {
$page = $lastPage;
}
$offset = ($page - 1) * $perPage;
$results = $articleRepository->searchArticles($query, $perPage, $offset);
}
return $this->render('pages/search.html.twig', [
'query' => $query,
'results' => $results,
'pagination' => [
'page' => $page,
'per_page' => $perPage,
'total' => $total,
'last_page' => $lastPage,
],
]);
return $this->render('pages/search.html.twig');
}
}

2
src/Controller/SeoController.php

@ -300,7 +300,7 @@ final class SeoController extends AbstractController @@ -300,7 +300,7 @@ final class SeoController extends AbstractController
$entryId = 'urn:web:'.$this->urlHostId($request)
.':db-article:'.($dbId !== null && $dbId !== '' ? (string) $dbId : \spl_object_id($article));
$pub = $article->getDisplayDateTime() ?? $tArticle;
$pub = $article->getPublishedAt() ?? $article->getCreatedAt() ?? $tArticle;
$out = "\n <entry>";
$out .= "\n <title>".$this->xmlText($title)."</title>";
$out .= "\n <link href=\"".$this->xmlAttr($permalink)."\" rel=\"alternate\" type=\"text/html\"/>";

49
src/Controller/TopicController.php

@ -1,49 +0,0 @@ @@ -1,49 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Repository\ArticleRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class TopicController extends AbstractController
{
#[Route(
path: '/topic/{topic}',
name: 'topic',
methods: ['GET'],
requirements: ['topic' => '[^/]+'],
)]
public function byTopic(
string $topic,
Request $request,
ArticleRepository $articleRepository,
): Response {
$perPage = 25;
$page = max(1, $request->query->getInt('page', 1));
$total = $articleRepository->countPublishedByTopic($topic);
$lastPage = max(1, (int) ceil($total / $perPage));
if ($page > $lastPage) {
$page = $lastPage;
}
$offset = ($page - 1) * $perPage;
$list = $articleRepository->findPublishedByTopic($topic, $perPage, $offset);
$topicParam = $articleRepository->normalizeTopicParam(rawurldecode($topic));
return $this->render('pages/topic.html.twig', [
'topic_param' => $topicParam,
'topic_label' => $topicParam,
'list' => $list,
'pagination' => [
'page' => $page,
'per_page' => $perPage,
'total' => $total,
'last_page' => $lastPage,
],
]);
}
}

11
src/Dto/FeaturedArticleCard.php

@ -16,7 +16,6 @@ final readonly class FeaturedArticleCard @@ -16,7 +16,6 @@ final readonly class FeaturedArticleCard
private ?string $summary,
private ?string $image,
private ?\DateTimeImmutable $createdAt,
private ?\DateTimeImmutable $publishedAt,
private ?string $pubkey,
) {
}
@ -51,16 +50,6 @@ final readonly class FeaturedArticleCard @@ -51,16 +50,6 @@ final readonly class FeaturedArticleCard
return $this->createdAt;
}
public function getPublishedAt(): ?\DateTimeImmutable
{
return $this->publishedAt;
}
public function getDisplayAt(): ?\DateTimeImmutable
{
return $this->publishedAt ?? $this->createdAt;
}
public function getPubkey(): ?string
{
return $this->pubkey;

8
src/Entity/Article.php

@ -227,14 +227,6 @@ class Article @@ -227,14 +227,6 @@ class Article
return $this;
}
/**
* Prefer NIP-23 {@see publishedAt} for display/SEO; fall back to event {@see createdAt}.
*/
public function getDisplayDateTime(): ?\DateTimeImmutable
{
return $this->publishedAt ?? $this->createdAt;
}
public function getTopics()
{
return $this->topics;

163
src/Entity/ArticleHighlight.php

@ -1,163 +0,0 @@ @@ -1,163 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Repository\ArticleHighlightRepository;
use App\Util\HighlightEventTags;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
/**
* Nostr kind 9802 (highlight) events that reference a long-form article by `a` / `A` address.
* Ingested from relays and served from MySQL (not from the comment-thread cache).
*/
#[ORM\Entity(repositoryClass: ArticleHighlightRepository::class)]
#[ORM\Table(name: 'article_highlight')]
#[ORM\Index(name: 'IDX_highlight_event_created', columns: ['event_created_at'])]
class ArticleHighlight
{
#[ORM\Id]
#[ORM\GeneratedValue(strategy: 'IDENTITY')]
#[ORM\Column]
private ?int $id = null;
/** Event id (hex, lowercase) — globally unique. */
#[ORM\Column(length: 64, unique: true)]
private string $eventId = '';
#[ORM\ManyToOne(targetEntity: Article::class, inversedBy: null)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private ?Article $article = null;
/** Pubkey (hex) of the account that created the highlight. */
#[ORM\Column(length: 64)]
private string $authorPubkey = '';
#[ORM\Column(type: Types::TEXT)]
private string $content = '';
/** Full tag array as returned on the wire (includes textquoteselector, a/A, etc.). */
#[ORM\Column(type: Types::JSON)]
private array $tags = [];
/** Nostr `created_at` (unix seconds). */
#[ORM\Column(type: Types::BIGINT)]
private int $eventCreatedAt = 0;
/** Short quote line for list UI / deep-link hint (from textquoteselector or content). */
#[ORM\Column(type: Types::STRING, length: 512, nullable: true)]
private ?string $quoteExcerpt = null;
public function getId(): ?int
{
return $this->id;
}
public function getEventId(): string
{
return $this->eventId;
}
public function setEventId(string $eventId): static
{
$this->eventId = strtolower($eventId);
return $this;
}
public function getArticle(): ?Article
{
return $this->article;
}
public function setArticle(?Article $article): static
{
$this->article = $article;
return $this;
}
public function getAuthorPubkey(): string
{
return $this->authorPubkey;
}
public function setAuthorPubkey(string $authorPubkey): static
{
$this->authorPubkey = $authorPubkey;
return $this;
}
public function getContent(): string
{
return $this->content;
}
public function setContent(string $content): static
{
$this->content = $content;
return $this;
}
public function getTags(): array
{
return $this->tags;
}
public function setTags(array $tags): static
{
$this->tags = $tags;
return $this;
}
public function getEventCreatedAt(): int
{
return $this->eventCreatedAt;
}
public function setEventCreatedAt(int $eventCreatedAt): static
{
$this->eventCreatedAt = $eventCreatedAt;
return $this;
}
public function getQuoteExcerpt(): ?string
{
return $this->quoteExcerpt;
}
public function setQuoteExcerpt(?string $quoteExcerpt): static
{
$this->quoteExcerpt = $quoteExcerpt;
return $this;
}
/** The full quote from the `context` tag (empty if absent). */
public function getContextText(): string
{
return HighlightEventTags::contextFromTags($this->tags);
}
/**
* Card body HTML (home aside, line-clamp): `context` = full quote, `content` = highlighted part.
* If there is no `context` (or it is empty), the passage is the same as `content`. The passage
* is aligned so the clamped block starts at the highlight, not with long unmarked lead-in text.
*/
public function getBodyHtml(): string
{
$c = (string) $this->getContent();
return HighlightEventTags::buildHighlightedBodyHtmlForNarrowList(
HighlightEventTags::fullPassageForHighlightDisplay($c, $this->tags),
$c,
0
);
}
}

42
src/Factory/ArticleFactory.php

@ -20,11 +20,7 @@ class ArticleFactory @@ -20,11 +20,7 @@ class ArticleFactory
$entity = new Article();
$entity->setRaw($source);
$entity->setEventId($source->id);
$created = $this->parseEventTimeValue($source->created_at ?? null);
if ($created === null) {
throw new InvalidArgumentException('Long-form event has invalid or missing created_at');
}
$entity->setCreatedAt($created);
$entity->setCreatedAt(\DateTimeImmutable::createFromFormat('U', (string)$source->created_at));
$entity->setContent($source->content);
$entity->setKind(KindsEnum::from($source->kind));
$entity->setPubkey($source->pubkey);
@ -48,10 +44,7 @@ class ArticleFactory @@ -48,10 +44,7 @@ class ArticleFactory
$entity->setImage($tag[1]);
break;
case 'published_at':
$parsed = $this->parseEventTimeValue($tag[1] ?? null);
if ($parsed !== null) {
$entity->setPublishedAt($parsed);
}
$entity->setPublishedAt(\DateTimeImmutable::createFromFormat('U', (string)$tag[1]));
break;
case 't':
$entity->addTopic($tag[1]);
@ -63,35 +56,4 @@ class ArticleFactory @@ -63,35 +56,4 @@ class ArticleFactory
}
return $entity;
}
/**
* NIP-23 times are usually Unix seconds; `published_at` may be ISO or other strings that make createFromFormat('U', …) return false.
*/
private function parseEventTimeValue(mixed $raw): ?\DateTimeImmutable
{
if (!\is_string($raw) && !\is_int($raw) && !\is_float($raw)) {
return null;
}
$s = trim((string) $raw);
if ($s === '') {
return null;
}
if (ctype_digit($s)) {
$sec = (int) $s;
if ($sec > 0) {
return (new \DateTimeImmutable('@'.$sec))->setTimezone(new \DateTimeZone('UTC'));
}
return null;
}
$fromU = \DateTimeImmutable::createFromFormat('U', $s);
if ($fromU instanceof \DateTimeImmutable) {
return $fromU;
}
try {
return new \DateTimeImmutable($s);
} catch (\Exception) {
return null;
}
}
}

4
src/Form/RoleType.php

@ -14,7 +14,7 @@ use Symfony\Component\OptionsResolver\OptionsResolver; @@ -14,7 +14,7 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
class RoleType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->setAction('/admin/role/add')
@ -27,7 +27,7 @@ class RoleType extends AbstractType @@ -27,7 +27,7 @@ class RoleType extends AbstractType
;
}
public function configureOptions(OptionsResolver $resolver): void
public function configureOptions(OptionsResolver $resolver)
{
}
}

4
src/Nostr/MagazineEventKeys.php

@ -4,7 +4,7 @@ declare(strict_types=1); @@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Nostr;
use App\Service\NostrKeyHelper;
use swentel\nostr\Key\Key;
/**
* Stable keys for {@see Event} rows: magazine root/category indices and kind-0 profiles in MySQL.
@ -52,7 +52,7 @@ final class MagazineEventKeys @@ -52,7 +52,7 @@ final class MagazineEventKeys
return strtolower($npub);
}
try {
$h = (new NostrKeyHelper())->convertToHex($npub);
$h = (new Key())->convertToHex($npub);
} catch (\Throwable) {
$h = '';
}

8
src/Nostr/Nip19Addressable.php

@ -5,6 +5,7 @@ declare(strict_types=1); @@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Nostr;
use App\Entity\Event;
use nostriphant\NIP19\Bech32;
/**
* NIP-33 / NIP-19 helpers: naddr for parameterized replaceable events (kind:pubkey:d).
@ -62,6 +63,11 @@ final class Nip19Addressable @@ -62,6 +63,11 @@ final class Nip19Addressable
throw new \InvalidArgumentException('Invalid pubkey hex for naddr.');
}
return (new Nip19Codec())->encodeNaddr($kind, $pubkeyHex, $dIdentifier, $relays);
return (string) Bech32::naddr(
kind: $kind,
pubkey: $pubkeyHex,
identifier: $dIdentifier,
relays: $relays,
);
}
}

120
src/Nostr/Nip19Codec.php

@ -1,120 +0,0 @@ @@ -1,120 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Nostr;
use swentel\nostr\Event\Event;
use swentel\nostr\Nip19\Nip19Helper;
/**
* NIP-19 encode/decode using swentel/nostr-php, output-shaped for code that previously used nostriphant\NIP19\Bech32
* (objects with {@see $type} and {@see $data} for decoded entities).
*/
final class Nip19Codec
{
private Nip19Helper $nip19Helper;
public function __construct(?Nip19Helper $nip19Helper = null)
{
$this->nip19Helper = $nip19Helper ?? new Nip19Helper();
}
public function decode(string $bech32): object
{
$pos = strrpos($bech32, '1');
if (false === $pos || $pos < 1) {
throw new \InvalidArgumentException('Invalid bech32 string');
}
$hrp = substr($bech32, 0, $pos);
$raw = $this->nip19Helper->decode($bech32);
$out = new \stdClass();
if ($hrp === 'npub' || $hrp === 'nsec') {
if (!\is_array($raw) || !isset($raw[1]) || !\is_array($raw[1])) {
throw new \RuntimeException('Unexpected npub/nsec decode shape');
}
$out->type = $hrp;
$d = new \stdClass();
$hex = '';
foreach ($raw[1] as $byte) {
$hex .= str_pad(\dechex($byte & 0xff), 2, '0', STR_PAD_LEFT);
}
$d->data = $hex;
$out->data = $d;
return $out;
}
if ($hrp === 'note') {
if (!\is_array($raw) || !isset($raw['event_id']) || !\is_string($raw['event_id'])) {
throw new \RuntimeException('Unexpected note decode shape');
}
$out->type = 'note';
$d = new \stdClass();
$d->data = $raw['event_id'];
$d->relays = [];
$out->data = $d;
return $out;
}
if (!\is_array($raw)) {
throw new \RuntimeException('Unexpected NIP-19 decode shape');
}
$out->type = $hrp;
$d = new \stdClass();
if ($hrp === 'nprofile') {
$d->pubkey = $raw['pubkey'] ?? '';
$d->relays = isset($raw['relays']) && \is_array($raw['relays']) ? $raw['relays'] : [];
} elseif ($hrp === 'nevent') {
$d->id = (string) ($raw['event_id'] ?? '');
$d->relays = isset($raw['relays']) && \is_array($raw['relays']) ? $raw['relays'] : [];
$d->author = \array_key_exists('author', $raw) ? (string) $raw['author'] : null;
if ($d->author === '') {
$d->author = null;
}
$d->pubkey = $d->author;
$d->kind = \array_key_exists('kind', $raw) && $raw['kind'] !== null && $raw['kind'] !== '' ? (int) $raw['kind'] : null;
} elseif ($hrp === 'naddr') {
$d->identifier = (string) ($raw['identifier'] ?? '');
$d->pubkey = (string) ($raw['author'] ?? '');
$d->relays = isset($raw['relays']) && \is_array($raw['relays']) ? $raw['relays'] : [];
$d->kind = \array_key_exists('kind', $raw) && $raw['kind'] !== null && $raw['kind'] !== '' ? (int) $raw['kind'] : 0;
} else {
throw new \InvalidArgumentException('Unsupported NIP-19 prefix: '.$hrp);
}
$out->data = $d;
return $out;
}
public function encodeNevent(string $eventIdHex, array $relays, string $authorHex, int $kind): string
{
$e = new Event();
$e->setId(strtolower($eventIdHex));
$e->setPublicKey(strtolower($authorHex));
$e->setKind($kind);
return $this->nip19Helper->encodeEvent($e, $relays, $authorHex, $kind);
}
public function encodeNaddr(int $kind, string $pubkeyHex, string $dTag, array $relays = []): string
{
$pk = strtolower($pubkeyHex);
if (64 !== \strlen($pk) || !ctype_xdigit($pk)) {
throw new \InvalidArgumentException('Invalid pubkey hex for naddr.');
}
if ($dTag === '') {
throw new \InvalidArgumentException('d tag required for naddr');
}
$e = new Event();
$e->setPublicKey($pk);
$e->setKind($kind);
$e->setId(str_repeat('0', 64));
return $this->nip19Helper->encodeAddr($e, $dTag, $kind, $pk, $relays);
}
}

88
src/Repository/ArticleHighlightRepository.php

@ -1,88 +0,0 @@ @@ -1,88 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Article;
use App\Entity\ArticleHighlight;
use App\Enum\EventStatusEnum;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<ArticleHighlight>
*/
class ArticleHighlightRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ArticleHighlight::class);
}
/**
* Newest highlights across published/archived long-form, for the home aside.
*
* @return list<ArticleHighlight>
*/
public function findRecentForHome(int $limit = 36): array
{
if ($limit <= 0) {
return [];
}
$qb = $this->createQueryBuilder('h')
->innerJoin('h.article', 'a')
->where('a.eventStatus IN (:st)')
->setParameter('st', [EventStatusEnum::PUBLISHED, EventStatusEnum::ARCHIVED])
->orderBy('h.eventCreatedAt', 'DESC')
->addOrderBy('h.id', 'DESC')
->setMaxResults($limit);
/** @var list<ArticleHighlight> $rows */
$rows = $qb->getQuery()->getResult();
return $rows;
}
/**
* Returns highlights for this NIP-33 address (kind + pubkey + slug), not only the current
* {@see Article} row id — `article` can have multiple DB rows for the same slug (revisions).
*
* @return list<ArticleHighlight>
*/
public function findByArticle(Article $article): array
{
$id = $article->getId();
if (null === $id || (int) $id < 1) {
return [];
}
$pubkey = (string) $article->getPubkey();
if ('' === $pubkey) {
return [];
}
$slug = trim((string) $article->getSlug());
if ('' === $slug) {
return [];
}
$qb = $this->createQueryBuilder('h')
->innerJoin('h.article', 'a')
// Hex pubkeys are case-insensitive; utf8mb4_bin would otherwise miss rows.
->where('LOWER(a.pubkey) = LOWER(:pubkey)')
->andWhere('a.slug = :slug')
->setParameter('pubkey', $pubkey)
->setParameter('slug', $slug)
->orderBy('h.eventCreatedAt', 'DESC');
// Do not filter on `a.kind`: replaceable long-form can leave several `article` rows per slug
// with different kind (or NULL vs 30023). Highlights are still tied to the same NIP-33
// address; filtering by the *current* row's kind dropped rows synced to an older revision.
/** @var list<ArticleHighlight> $out */
$out = $qb->getQuery()->getResult();
return $out;
}
}

165
src/Repository/ArticleRepository.php

@ -54,42 +54,6 @@ class ArticleRepository extends ServiceEntityRepository @@ -54,42 +54,6 @@ class ArticleRepository extends ServiceEntityRepository
->getResult();
}
public function countSearchArticles(string $query): int
{
$qb = $this->createQueryBuilder('a')
->select('COUNT(a.id)');
$searchTerms = explode(' ', trim($query));
$conditions = $qb->expr()->orX();
foreach ($searchTerms as $index => $term) {
$term = trim($term);
if (empty($term)) {
continue;
}
$paramName = 'term' . $index;
$termCondition = $qb->expr()->orX(
$qb->expr()->like('a.title', ':' . $paramName),
$qb->expr()->like('a.content', ':' . $paramName),
$qb->expr()->like('a.summary', ':' . $paramName)
);
$conditions->add($termCondition);
$qb->setParameter($paramName, '%' . $term . '%');
}
if (\count($conditions->getParts()) === 0) {
return 0;
}
return (int) $qb
->where($conditions)
->andWhere('a.content IS NOT NULL')
->andWhere('LENGTH(a.content) > 250')
->getQuery()
->getSingleScalarResult();
}
/**
* List-card fields only: avoids loading `content` / `raw` (can be very large) for home/category featured rows.
*
@ -104,7 +68,7 @@ class ArticleRepository extends ServiceEntityRepository @@ -104,7 +68,7 @@ class ArticleRepository extends ServiceEntityRepository
$conn = $this->getEntityManager()->getConnection();
$qb = $conn->createQueryBuilder();
$qb
->select('a.id', 'a.slug', 'a.title', 'a.summary', 'a.image', 'a.created_at', 'a.published_at', 'a.pubkey')
->select('a.id', 'a.slug', 'a.title', 'a.summary', 'a.image', 'a.created_at', 'a.pubkey')
->from('article', 'a')
->where($qb->expr()->in('a.slug', ':slugs'))
->setParameter('slugs', $slugs, ArrayParameterType::STRING)
@ -115,7 +79,6 @@ class ArticleRepository extends ServiceEntityRepository @@ -115,7 +79,6 @@ class ArticleRepository extends ServiceEntityRepository
$out = [];
foreach ($rows as $row) {
$ca = $row['created_at'] ?? null;
$pa = $row['published_at'] ?? null;
$out[] = new FeaturedArticleCard(
isset($row['id']) ? (int) $row['id'] : null,
isset($row['slug']) ? (string) $row['slug'] : null,
@ -123,7 +86,6 @@ class ArticleRepository extends ServiceEntityRepository @@ -123,7 +86,6 @@ class ArticleRepository extends ServiceEntityRepository
isset($row['summary']) ? (string) $row['summary'] : null,
isset($row['image']) ? (string) $row['image'] : null,
$ca !== null && $ca !== '' ? new \DateTimeImmutable((string) $ca) : null,
$pa !== null && $pa !== '' ? new \DateTimeImmutable((string) $pa) : null,
isset($row['pubkey']) ? (string) $row['pubkey'] : null,
);
}
@ -193,25 +155,6 @@ class ArticleRepository extends ServiceEntityRepository @@ -193,25 +155,6 @@ class ArticleRepository extends ServiceEntityRepository
return $this->findOneBy(['eventId' => $eventId]);
}
/**
* Newest row for a NIP-23/24 `d` value (replaceable long-form can leave multiple `article` rows per slug).
*/
public function findLatestBySlug(string $slug): ?Article
{
$slug = trim($slug);
if ($slug === '') {
return null;
}
return $this->createQueryBuilder('a')
->where('a.slug = :slug')
->setParameter('slug', $slug)
->orderBy('a.createdAt', 'DESC')
->setMaxResults(1)
->getQuery()
->getOneOrNullResult();
}
/**
* Find articles by author's public key
*/
@ -226,28 +169,6 @@ class ArticleRepository extends ServiceEntityRepository @@ -226,28 +169,6 @@ class ArticleRepository extends ServiceEntityRepository
->getResult();
}
public function findByPubkeyPaginated(string $pubkey, int $limit, int $offset): array
{
return $this->createQueryBuilder('a')
->where('a.pubkey = :pubkey')
->setParameter('pubkey', $pubkey)
->orderBy('a.createdAt', 'DESC')
->setFirstResult($offset)
->setMaxResults($limit)
->getQuery()
->getResult();
}
public function countByPubkey(string $pubkey): int
{
return (int) $this->createQueryBuilder('a')
->select('COUNT(a.id)')
->where('a.pubkey = :pubkey')
->setParameter('pubkey', $pubkey)
->getQuery()
->getSingleScalarResult();
}
/**
* Published or archived long-form rows for sitemap/Atom (may include multiple rows per slug);
* callers should dedupe by slug if URLs are slug-only.
@ -266,88 +187,4 @@ class ArticleRepository extends ServiceEntityRepository @@ -266,88 +187,4 @@ class ArticleRepository extends ServiceEntityRepository
->getQuery()
->getResult();
}
/**
* Published or archived long-form with at least one stored topic, matched case-insensitively.
* Ordered newest first. Uses an in-process filter; suitable for moderate table sizes.
*
* @return list<Article>
*/
public function findPublishedByTopic(string $topic, int $limit, int $offset): array
{
$all = $this->articlesMatchingTopicNormalized(
$this->normalizeTopicLabel($topic)
);
return \array_slice($all, $offset, $limit);
}
public function countPublishedByTopic(string $topic): int
{
return \count(
$this->articlesMatchingTopicNormalized(
$this->normalizeTopicLabel($topic)
)
);
}
/**
* @return list<Article>
*/
private function articlesMatchingTopicNormalized(string $topicKey): array
{
if ($topicKey === '') {
return [];
}
$qb = $this->createQueryBuilder('a')
->where('a.topics IS NOT NULL')
->andWhere('a.content IS NOT NULL')
->andWhere('LENGTH(a.content) > 250')
->andWhere('a.eventStatus IN (:st)')
->setParameter('st', [EventStatusEnum::PUBLISHED, EventStatusEnum::ARCHIVED])
->orderBy('a.createdAt', 'DESC');
/** @var list<Article> $candidates */
$candidates = $qb->getQuery()->getResult();
$out = [];
foreach ($candidates as $a) {
$topics = $a->getTopics();
if (!\is_array($topics) || $topics === []) {
continue;
}
foreach ($topics as $t) {
if (!\is_string($t)) {
continue;
}
$k = $this->normalizeTopicLabel($t);
if ($k === $topicKey) {
$out[] = $a;
break;
}
}
}
return $out;
}
/**
* Public key for {@see findPublishedByTopic} and generating `/topic/…` URLs.
*/
public function normalizeTopicParam(string $topic): string
{
return $this->normalizeTopicLabel($topic);
}
private function normalizeTopicLabel(string $topic): string
{
$t = \strtolower(\trim($topic));
if ($t === '') {
return '';
}
if (\str_starts_with($t, '#')) {
$t = ltrim($t, '#');
}
return \trim($t);
}
}

44
src/Repository/FeaturedAuthorRepository.php

@ -51,48 +51,4 @@ class FeaturedAuthorRepository extends ServiceEntityRepository @@ -51,48 +51,4 @@ class FeaturedAuthorRepository extends ServiceEntityRepository
->getResult();
}
/**
* Listed authors who first appeared in a category index, most recently added first.
* {@see FeaturedAuthor::createdAt} is set when the row is created (sync discovered the pubkey in an `a` tag).
*
* @return list<FeaturedAuthor>
*/
public function findListedMostRecentlyAdded(int $limit, int $offset = 0): array
{
return $this->createQueryBuilder('f')
->where('f.isListed = :t')
->setParameter('t', true)
->orderBy('f.createdAt', 'DESC')
->addOrderBy('f.id', 'DESC')
->setFirstResult($offset)
->setMaxResults($limit)
->getQuery()
->getResult();
}
/**
* @return list<FeaturedAuthor>
*/
public function findListedOrderByLocalPartPaginated(int $limit, int $offset): array
{
return $this->createQueryBuilder('f')
->where('f.isListed = :t')
->setParameter('t', true)
->orderBy('f.localPart', 'ASC')
->setFirstResult($offset)
->setMaxResults($limit)
->getQuery()
->getResult();
}
public function countListed(): int
{
return (int) $this->createQueryBuilder('f')
->select('COUNT(f.id)')
->where('f.isListed = :t')
->setParameter('t', true)
->getQuery()
->getSingleScalarResult();
}
}

44
src/Security/NostrAuthenticator.php

@ -2,9 +2,9 @@ @@ -2,9 +2,9 @@
namespace App\Security;
use App\Service\NostrKeyHelper;
use App\Entity\Event;
use Mdanter\Ecc\Crypto\Signature\SchnorrSignature;
use swentel\nostr\Event\Event;
use swentel\nostr\Key\Key;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
@ -13,6 +13,9 @@ use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator; @@ -13,6 +13,9 @@ use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
use Symfony\Component\Security\Http\Authenticator\InteractiveAuthenticatorInterface;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
use Symfony\Component\Serializer\Encoder\JsonEncoder;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
use Symfony\Component\Serializer\Serializer;
/**
* Authenticator for Nostr protocol-based authentication.
@ -25,11 +28,6 @@ use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPasspor @@ -25,11 +28,6 @@ use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPasspor
*/
class NostrAuthenticator extends AbstractAuthenticator implements InteractiveAuthenticatorInterface
{
public function __construct(
private readonly NostrKeyHelper $nostrKeyHelper,
) {
}
/**
* Checks if the request should be handled by this authenticator.
*
@ -59,37 +57,23 @@ class NostrAuthenticator extends AbstractAuthenticator implements InteractiveAut @@ -59,37 +57,23 @@ class NostrAuthenticator extends AbstractAuthenticator implements InteractiveAut
}
$eventStr = base64_decode(substr($authHeader, 6), true);
if (false === $eventStr) {
throw new AuthenticationException('Invalid Authorization header');
}
try {
$data = json_decode($eventStr, false, 512, \JSON_THROW_ON_ERROR);
} catch (\JsonException) {
throw new AuthenticationException('Invalid Authorization header');
}
if (!\is_object($data) || !isset(
$data->id, $data->pubkey, $data->created_at, $data->kind, $data->content, $data->sig
)) {
throw new AuthenticationException('Invalid Authorization header');
}
if (!isset($data->tags) || !\is_array($data->tags)) {
$data->tags = [];
}
$event = (new Event())->populate($data);
$encoders = [new JsonEncoder()];
$normalizers = [new ObjectNormalizer()];
$serializer = new Serializer($normalizers, $encoders);
/** @var Event $event */
$event = $serializer->deserialize($eventStr, Event::class, 'json');
if (time() > $event->getCreatedAt() + 60) {
throw new AuthenticationException('Expired');
}
$validity = (new SchnorrSignature())->verify(
$event->getPublicKey(),
$event->getSignature(),
$event->getId()
);
$validity = (new SchnorrSignature())->verify($event->getPubkey(), $event->getSig(), $event->getId());
if (!$validity) {
throw new AuthenticationException('Invalid Authorization header');
}
$key = new Key();
return new SelfValidatingPassport(
new UserBadge($this->nostrKeyHelper->convertPublicKeyToBech32($event->getPublicKey()))
new UserBadge($key->convertPublicKeyToBech32($event->getPubkey()))
);
}

712
src/Service/ArticleBodyHighlightInjector.php

@ -1,712 +0,0 @@ @@ -1,712 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\ArticleHighlight;
use App\Util\HighlightEventTags;
use DOMDocument;
use DOMElement;
use DOMText;
use DOMXPath;
/**
* Injects kind-9802 highlight marks into the rendered article body by searching the visible text
* in NIP-84 order: event `content` (highlighted span) first, then the `context` tag when set, then
* the full passage ({@see HighlightEventTags::fullPassageForHighlightDisplay}, same as `content`
* when `context` is missing), then `textquoteselector`. The first string that matches the body wins.
* Matches across inline elements (e.g. em, strong) by concatenating text in document order. Text
* inside a prior `mark.user-highlight__marker` is still considered so a narrower 9802 can
* be nested and receive its own fragment id (deep link from the landing aside).
* If a literal match fails, compares a normalized form (NBSP→space, strip U+00AD / ZW, line breaks,
* etc.) via {@see HighlightEventTags::stringForSearch}, then maps the match back to the original
* HTML text (for e‑book style soft hyphens in 9802 content). CommonMark footnote callouts
* (League CommonMark `sup#fnref…`) are ignored for matching so “realm 1 always” in the DOM does not
* block a NIP-84 passage that says “realm always”.
*/
final class ArticleBodyHighlightInjector
{
private const ROOT_ID = '_article_hl';
private DOMDocument $dom;
private ?DOMElement $root = null;
public function __construct(
private readonly HighlightAuthorMetadataProvider $highlightAuthorMetadata,
private readonly NostrKeyHelper $nostrKeyHelper,
) {
}
/**
* @param list<ArticleHighlight> $highlights
*
* @return array{html: string, injectedEventIds: list<string>}
*/
public function inject(string $html, array $highlights): array
{
if ($highlights === [] || $html === '') {
return ['html' => $html, 'injectedEventIds' => []];
}
$sorted = $highlights;
usort(
$sorted,
static fn (ArticleHighlight $a, ArticleHighlight $b) => $a->getEventCreatedAt() <=> $b->getEventCreatedAt()
);
$this->loadDom($html);
if (null === $this->root) {
return ['html' => $html, 'injectedEventIds' => []];
}
$injected = [];
$groups = $this->groupHighlightsForInjection($sorted);
foreach ($groups as $group) {
if ($group === []) {
continue;
}
$added = $this->tryInjectHighlightGroup($this->root, $group);
foreach ($added as $eid) {
$injected[] = $eid;
}
}
$out = '';
foreach ($this->root->childNodes as $child) {
$out .= (string) $this->dom->saveHTML($child);
}
return ['html' => $out, 'injectedEventIds' => $injected];
}
private function loadDom(string $html): void
{
$this->dom = new DOMDocument('1.0', 'UTF-8');
$this->root = null;
if ($html === '') {
return;
}
$enc = '<?xml encoding="UTF-8"?>'.'<div id="'.self::ROOT_ID.'">'.$html.'</div>';
$prev = libxml_use_internal_errors(true);
try {
if (false === $this->dom->loadHTML(
$enc,
\LIBXML_HTML_NOIMPLIED | \LIBXML_HTML_NODEFDTD
)) {
libxml_clear_errors();
}
} finally {
libxml_use_internal_errors($prev);
libxml_clear_errors();
}
$this->root = $this->resolveRootWrapperElement();
if (null === $this->root) {
// Some libxml/fragment combinations drop the root with HTML_NOIMPLIED; parse a plain wrapper
$this->dom = new DOMDocument('1.0', 'UTF-8');
$prevInner = libxml_use_internal_errors(true);
try {
$this->dom->loadHTML(
'<?xml encoding="UTF-8"?>'.'<div id="'.self::ROOT_ID.'">'.$html.'</div>',
\LIBXML_HTML_NODEFDTD
);
$this->root = $this->resolveRootWrapperElement();
} finally {
libxml_use_internal_errors($prevInner);
libxml_clear_errors();
}
}
}
private function resolveRootWrapperElement(): ?DOMElement
{
$xp = new DOMXPath($this->dom);
$nodes = $xp->query('//div[@id="'.self::ROOT_ID.'"]');
if (false !== $nodes && $nodes->length > 0) {
$first = $nodes->item(0);
return $first instanceof DOMElement ? $first : null;
}
$de = $this->dom->documentElement;
if ($de instanceof DOMElement && $de->getAttribute('id') === self::ROOT_ID) {
return $de;
}
$d = $this->findFirstDivById(self::ROOT_ID);
if (null !== $d) {
return $d;
}
$el = $this->findElementByIdFallback(self::ROOT_ID);
return $el instanceof DOMElement ? $el : null;
}
private function findFirstDivById(string $id): ?DOMElement
{
if ('' === $id) {
return null;
}
$n = $this->dom->getElementsByTagName('div');
for ($i = 0, $L = $n->length; $i < $L; ++$i) {
$d = $n->item($i);
if ($d instanceof DOMElement && $d->getAttribute('id') === $id) {
return $d;
}
}
return null;
}
private function findElementByIdFallback(string $id): ?DOMElement
{
if ('' === $id) {
return null;
}
$stack = [];
if (null === $this->dom->documentElement) {
return null;
}
$stack[] = $this->dom->documentElement;
while ($stack !== []) {
$el = \array_pop($stack);
if (! $el instanceof DOMElement) {
continue;
}
if ($el->getAttribute('id') === $id) {
return $el;
}
for ($c = $el->lastChild; $c; $c = $c->previousSibling) {
if ($c instanceof DOMElement) {
$stack[] = $c;
}
}
}
return null;
}
/**
* @param list<ArticleHighlight> $group same highlight text; oldest first
*
* @return list<string> event ids that were applied
*/
private function tryInjectHighlightGroup(DOMElement $root, array $group): array
{
if ($group === []) {
return [];
}
$first = $group[0];
$eid = \strtolower($first->getEventId());
if (64 !== \strlen($eid) || !ctype_xdigit($eid)) {
return [];
}
$outEids = [];
foreach ($group as $h) {
$id = \strtolower($h->getEventId());
if (64 === \strlen($id) && ctype_xdigit($id)) {
$outEids[] = $id;
}
}
if ($outEids === []) {
return [];
}
$authorJson = $this->buildHighlightAuthorsJson($group);
$bases = $this->injectionNeedleBasesInPriority($first);
if ($bases === []) {
return [];
}
foreach ($bases as $base) {
foreach ($this->needleSearchVariants($base) as $needle) {
if ($needle === '') {
continue;
}
if ($this->tryWrapInDocument($root, $needle, $eid, $authorJson)) {
$this->addFragmentIdAliasesForHighlightGroup($eid, $outEids);
return $outEids;
}
}
}
return [];
}
/**
* One <mark> per passage group, with id highlight-{oldest eid}. The landing aside links each
* 9802 by that row's event id, so we add zero-footprint #highlight-{id} spans for every other
* event in the same group (same place in the text as the mark).
*
* @param list<string> $outEids lowercase 64-hex, includes $canonicalEid; first is the oldest
*/
private function addFragmentIdAliasesForHighlightGroup(string $canonicalEid, array $outEids): void
{
if (\count($outEids) < 2) {
return;
}
$mark = $this->getHighlightMarkElementById('highlight-'.$canonicalEid);
if (null === $mark) {
return;
}
$parent = $mark->parentNode;
if (null === $parent) {
return;
}
foreach ($outEids as $other) {
if ($other === $canonicalEid) {
continue;
}
if (64 !== \strlen($other) || !ctype_xdigit($other)) {
continue;
}
if ($this->getHighlightMarkElementById('highlight-'.$other) !== null) {
continue;
}
$span = $this->dom->createElement('span');
if (false === $span) {
continue;
}
$span->setAttribute('id', 'highlight-'.$other);
$span->setAttribute('class', 'user-highlight__fragment-target');
$span->setAttribute('aria-hidden', 'true');
$span->appendChild($this->dom->createTextNode("\u{200B}"));
$parent->insertBefore($span, $mark);
}
}
private function getHighlightMarkElementById(string $id): ?DOMElement
{
if (null === $this->root || $id === '') {
return null;
}
$el = $this->dom->getElementById($id);
if ($el instanceof DOMElement) {
return $el;
}
if (! \preg_match('/^highlight-[a-f0-9]{64}$/D', $id)) {
return null;
}
$xp = new DOMXPath($this->dom);
$q = '//*[@id="'.(string) $id.'"]';
$nodes = $xp->query($q, $this->root);
if (false === $nodes || 0 === $nodes->length) {
return null;
}
$n = $nodes->item(0);
return $n instanceof DOMElement ? $n : null;
}
/**
* @param list<ArticleHighlight> $sorted by created_at asc
*
* @return list<list<ArticleHighlight>>
*/
private function groupHighlightsForInjection(array $sorted): array
{
$buckets = [];
foreach ($sorted as $h) {
$primary = $this->primaryNeedleForGrouping($h);
if ($primary === '') {
continue;
}
$key = HighlightEventTags::stringForSearch($primary);
if ($key === '') {
$key = 'x'.\md5($primary);
}
if (!isset($buckets[$key])) {
$buckets[$key] = [];
}
$buckets[$key][] = $h;
}
$groups = \array_values($buckets);
\usort(
$groups,
static function (array $a, array $b): int {
$ta = $a[0] instanceof ArticleHighlight ? $a[0]->getEventCreatedAt() : 0;
$tb = $b[0] instanceof ArticleHighlight ? $b[0]->getEventCreatedAt() : 0;
return $ta <=> $tb;
}
);
return $groups;
}
/**
* NIP-84: same highlighted passage → one mark, dedupe authors by npub, profile from cache.
*
* @param list<ArticleHighlight> $group
*/
private function buildHighlightAuthorsJson(array $group): string
{
$byNpub = [];
foreach ($group as $h) {
$eidH = $h->getEventId();
if (64 !== \strlen($eidH) || !ctype_xdigit($eidH)) {
continue;
}
$pk = $h->getAuthorPubkey();
if (64 !== \strlen($pk) || !ctype_xdigit($pk)) {
continue;
}
try {
$npub = $this->nostrKeyHelper->convertPublicKeyToBech32($pk);
} catch (\Throwable) {
continue;
}
if (isset($byNpub[$npub])) {
continue;
}
$name = '';
$pic = '';
try {
$meta = $this->highlightAuthorMetadata->getMetadata($npub);
if (isset($meta->display_name) && \is_string($meta->display_name) && $meta->display_name !== '') {
$name = $meta->display_name;
} elseif (isset($meta->name) && \is_string($meta->name) && $meta->name !== '') {
$name = $meta->name;
}
if (isset($meta->picture) && \is_string($meta->picture) && $meta->picture !== '') {
$pic = $meta->picture;
} elseif (isset($meta->image) && \is_string($meta->image) && $meta->image !== '') {
$pic = $meta->image;
}
} catch (\Throwable) {
}
$byNpub[$npub] = [
'e' => \strtolower($eidH),
'n' => $npub,
'a' => $name,
'p' => $pic,
];
}
return \json_encode(\array_values($byNpub), \JSON_UNESCAPED_UNICODE | \JSON_THROW_ON_ERROR);
}
/**
* Same priority as the card: event `content` (NIP-84 sub-span) first, then the `context` tag when
* set, then {@see HighlightEventTags::fullPassageForHighlightDisplay} (so missing/empty `context`
* is treated as “passage = `content`” before `textquoteselector`). Tries each in order until one
* matches the rendered body.
*/
private function primaryNeedleForGrouping(ArticleHighlight $h): string
{
$b = $this->injectionNeedleBasesInPriority($h);
return $b[0] ?? '';
}
/**
* @return list<string> unique non-empty strings, highest priority first
*/
private function injectionNeedleBasesInPriority(ArticleHighlight $h): array
{
$rawContent = (string) $h->getContent();
$tags = $h->getTags();
$c = HighlightEventTags::trimNostrText($rawContent);
$ctx = HighlightEventTags::trimNostrText(HighlightEventTags::contextFromTags($tags));
$fullPassage = HighlightEventTags::trimNostrText(
HighlightEventTags::fullPassageForHighlightDisplay($rawContent, $tags)
);
$tq = HighlightEventTags::trimNostrText(HighlightEventTags::textquoteselectorPassageFromTags($tags));
$out = [];
$seen = [];
// NIP-84: `context` = full quote; `content` = highlighted span. Missing/empty `context` is
// the same as “full passage = `content`” (entirely highlighted) — see fullPassageForHighlightDisplay.
foreach ([$c, $ctx, $fullPassage, $tq] as $s) {
if ($s === '' || isset($seen[$s])) {
continue;
}
$seen[$s] = true;
$out[] = $s;
}
return $out;
}
/**
* Nostr/Unicode vs rendered HTML: try a few equivalent strings for `mb_strpos` on the flattened text.
*
* @return list<string>
*/
private function needleSearchVariants(string $base): array
{
if ($base === '') {
return [];
}
$candidates = [
$base,
$this->replaceTypographicQuotes($base),
];
$noLineBreaks = (string) \preg_replace('/\R/u', '', $base);
if ($noLineBreaks !== $base && $noLineBreaks !== '') {
$candidates[] = $noLineBreaks;
}
$nEnd = (string) \preg_replace('/[.!?…,;:]+$/u', '', $base);
if ($nEnd !== $base && $nEnd !== '') {
$candidates[] = $nEnd;
}
if (\class_exists(\Normalizer::class)) {
$c = \Normalizer::normalize($base, \Normalizer::FORM_C);
if (\is_string($c) && $c !== '' && $c !== $base) {
$candidates[] = $c;
}
}
$out = [];
$seen = [];
foreach ($candidates as $n) {
if ($n === '' || isset($seen[$n])) {
continue;
}
$seen[$n] = true;
$out[] = $n;
}
return $out;
}
private function replaceTypographicQuotes(string $s): string
{
return \strtr($s, [
"\xC2\xA0" => ' ', // nbsp
"\xE2\x80\x99" => "'",
"\xE2\x80\x98" => "'",
"\xE2\x80\x9C" => "\x22",
"\xE2\x80\x9D" => "\x22",
"\xE2\x80\x93" => '-',
"\xE2\x80\x94" => '-',
]);
}
private function tryWrapInDocument(DOMElement $root, string $needle, string $eventId, string $authorJson = ''): bool
{
$textNodes = $this->collectTextNodes($root);
if ($textNodes === []) {
return false;
}
$cat = '';
/** @var list<array{0: DOMText, 1: int, 2: int}> $segments */
$segments = [];
foreach ($textNodes as $tn) {
$t = (string) $tn->data;
$len = \mb_strlen($t, 'UTF-8');
if ($len === 0) {
continue;
}
$cat .= $t;
}
$p = \mb_strpos($cat, $needle, 0, 'UTF-8');
$pEnd = false;
if (false !== $p) {
$pEnd = $p + \mb_strlen($needle, 'UTF-8');
} else {
// e.g. soft hyphens (U+00AD) or NBSP in the event `content` vs plain text in the article
$catS = HighlightEventTags::stringForSearch($cat);
$needleS = HighlightEventTags::stringForSearch($needle);
if ($needleS === '') {
return false;
}
$pN = \mb_strpos($catS, $needleS, 0, 'UTF-8');
if (false === $pN) {
return false;
}
$nEnd = $pN + \mb_strlen($needleS, 'UTF-8');
[$p, $pEnd] = HighlightEventTags::mapSearchStringRangeToOrigStringRange($cat, $pN, $nEnd);
if ($pEnd <= $p) {
return false;
}
}
$cursor = 0;
foreach ($textNodes as $tn) {
$t = (string) $tn->data;
$nodeLen = \mb_strlen($t, 'UTF-8');
if ($nodeLen === 0) {
continue;
}
$nStart = $cursor;
$nEnd = $cursor + $nodeLen;
if ($pEnd <= $nStart) {
break;
}
if ($p >= $nEnd) {
$cursor = $nEnd;
continue;
}
$oStart = \max($p, $nStart);
$oEnd = \min($pEnd, $nEnd);
if ($oStart < $oEnd) {
$lStart = $oStart - $nStart;
$lLen = $oEnd - $oStart;
$segments[] = [$tn, $lStart, $lLen];
}
$cursor = $nEnd;
if ($oEnd >= $pEnd) {
break;
}
}
if ($segments === []) {
return false;
}
for ($i = \count($segments) - 1; $i >= 0; --$i) {
[$n, $off, $nLen] = $segments[$i];
if (! $this->wrapTextSlice(
$n,
$off,
$nLen,
$eventId,
0 === $i,
$authorJson
)) {
return false;
}
}
return true;
}
/**
* @return list<DOMText>
*/
private function collectTextNodes(DOMElement $el): array
{
$out = [];
for ($c = $el->firstChild; $c; $c = $c->nextSibling) {
if ($c instanceof DOMText) {
if ($this->isSafeTextContext($c)) {
$out[] = $c;
}
} elseif ($c instanceof DOMElement) {
if ($this->shouldNotDescendInto($c)) {
continue;
}
foreach ($this->collectTextNodes($c) as $tn) {
$out[] = $tn;
}
}
}
return $out;
}
private function shouldNotDescendInto(DOMElement $c): bool
{
$n = $c->nodeName;
if ('script' === $n
|| 'style' === $n
|| 'pre' === $n
|| 'textarea' === $n
|| 'code' === $n) {
return true;
}
if ('div' === $n && $this->isFootnotesOrEndnotesElement($c)) {
// End-of-article footnote list (League CommonMark): must not mix into the body search string
// or after main content, which would desync “flat text” from NIP-84 passages.
return true;
}
if ('sup' === $n && $this->isFootnoteCalloutElement($c)) {
// Inline [^ref] callouts: skip the superscript so "realm" + "1" + " always" does not
// break matching "realm always" from kind-9802 `content` (cards use raw Nostr, not the DOM).
return true;
}
if ('mark' === $n) {
$cl = (string) $c->getAttribute('class');
return ! \str_contains($cl, 'user-highlight__marker');
}
return false;
}
private function isFootnoteCalloutElement(DOMElement $c): bool
{
$id = (string) $c->getAttribute('id');
return $id !== '' && \str_starts_with($id, 'fnref');
}
private function isFootnotesOrEndnotesElement(DOMElement $c): bool
{
if (\str_contains((string) $c->getAttribute('class'), 'footnotes')
|| $c->getAttribute('role') === 'doc-endnotes') {
return true;
}
return false;
}
private function isSafeTextContext(DOMText $textNode): bool
{
$p = $textNode->parentNode;
while (null !== $p && $p->nodeType === XML_ELEMENT_NODE) {
if (! $p instanceof DOMElement) {
$p = $p->parentNode;
continue;
}
$n = $p->nodeName;
if ('script' === $n || 'style' === $n || 'pre' === $n || 'textarea' === $n) {
return false;
}
if ('code' === $n) {
return false;
}
if (('div' === $n && $this->isFootnotesOrEndnotesElement($p))
|| ('sup' === $n && $this->isFootnoteCalloutElement($p))) {
return false;
}
if ('a' === $n && \str_contains((string) $p->getAttribute('class'), 'footnote-ref')) {
return false;
}
$p = $p->parentNode;
}
return true;
}
private function wrapTextSlice(DOMText $textNode, int $uOffset, int $uLength, string $eventId, bool $firstInReadingOrder, string $authorJson = ''): bool
{
if ($uLength < 1) {
return false;
}
$t = (string) $textNode->data;
$nLen = \mb_strlen($t, 'UTF-8');
if ($uOffset < 0 || $uOffset + $uLength > $nLen) {
return false;
}
$before = $uOffset > 0 ? \mb_substr($t, 0, $uOffset, 'UTF-8') : '';
$match = \mb_substr($t, $uOffset, $uLength, 'UTF-8');
$restStart = $uOffset + $uLength;
$after = $restStart < $nLen ? \mb_substr($t, $restStart, null, 'UTF-8') : '';
$parent = $textNode->parentNode;
if (null === $parent) {
return false;
}
$ref = $textNode;
if ($before !== '') {
$parent->insertBefore($this->dom->createTextNode($before), $ref);
}
$mark = $this->dom->createElement('mark');
if (! $mark) {
return false;
}
$mark->setAttribute('class', 'user-highlight__marker');
if ($firstInReadingOrder) {
$mark->setAttribute('id', 'highlight-'.$eventId);
}
if ($authorJson !== '') {
$mark->setAttribute('data-hl', $authorJson);
}
$mark->appendChild($this->dom->createTextNode($match));
$parent->insertBefore($mark, $ref);
if ($after === '') {
$parent->removeChild($ref);
} else {
$ref->data = $after;
}
return true;
}
}

51
src/Service/ArticleCommentThreadLoader.php

@ -13,7 +13,6 @@ use Symfony\Contracts\Cache\ItemInterface; @@ -13,7 +13,6 @@ use Symfony\Contracts\Cache\ItemInterface;
/**
* Loads Nostr article discussion: NIP-22 (1111) + legacy kind 1 replies, plus quotes/reposts (q / a tags).
* Kind-9802 highlights are not in this response; they live in `article_highlight`.
*
* Reply blurbs mirror the jumble client: resolve the parent from `e` / `E` tags (NIP-10, `reply` marker,
* last-of-sequence), then show a short preview of the parent’s body (see jumble `ParentNotePreview`). Inline
@ -94,20 +93,18 @@ final readonly class ArticleCommentThreadLoader @@ -94,20 +93,18 @@ final readonly class ArticleCommentThreadLoader
try {
$discussion = $this->cache->get($cacheKey, function (ItemInterface $item) use ($coordinate, $articleEventHexId, $t0): array {
// 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),
]);
$tNostr = microtime(true);
// On failure, let this throw: Symfony cache will not store a value, so a prior good thread is not replaced by [].
$out = $this->nostrClient->getArticleDiscussion($coordinate, $articleEventHexId);
$partial = (bool) ($out['partial'] ?? false);
// Partial relay snapshots are intentionally short-lived so the next request can pick up late relays.
$item->expiresAfter($partial ? 15 : 86400);
$this->logger->info('comments.loader.nostr_ok', [
'nostr_elapsed_ms' => (int) round((microtime(true) - $tNostr) * 1000),
'thread' => \count($out['thread'] ?? []),
'quotes' => \count($out['quotes'] ?? []),
'partial' => $partial,
]);
return $out;
@ -141,7 +138,7 @@ final readonly class ArticleCommentThreadLoader @@ -141,7 +138,7 @@ final readonly class ArticleCommentThreadLoader
*/
private function cacheKeyForThread(string $coordinate, ?string $articleEventHexId): string
{
return 'comments_v6_'.hash('sha256', $coordinate."\0".($articleEventHexId ?? ''));
return 'comments_v5_'.hash('sha256', $coordinate."\0".($articleEventHexId ?? ''));
}
/**
@ -196,7 +193,6 @@ final readonly class ArticleCommentThreadLoader @@ -196,7 +193,6 @@ final readonly class ArticleCommentThreadLoader
'commentLinks' => $commentLinks,
'quoteLinks' => $quoteLinks,
'processedContent' => $processedContent,
'comments_partial' => (bool) ($discussion['partial'] ?? false),
];
}
@ -306,9 +302,6 @@ final readonly class ArticleCommentThreadLoader @@ -306,9 +302,6 @@ final readonly class ArticleCommentThreadLoader
$raw = isset($ev->content) ? (string) $ev->content : '';
$split = $this->splitNip22ReplyBlurb($raw);
$blurb = $split['blurb'];
if ($blurb === null || trim($blurb) === '') {
$blurb = $this->replyBlurbFromAddressTag($ev);
}
if (($blurb === null || trim($blurb) === '') && $id !== '' && isset($parentOf[$id])) {
$pid = $parentOf[$id];
if (isset($idToEvent[$pid])) {
@ -370,44 +363,6 @@ final readonly class ArticleCommentThreadLoader @@ -370,44 +363,6 @@ final readonly class ArticleCommentThreadLoader
return ['blurb' => $first, 'body' => $rest];
}
private function replyBlurbFromAddressTag(object $event): ?string
{
if (!isset($event->tags) || !\is_array($event->tags)) {
return null;
}
foreach ($event->tags as $row) {
if (!\is_array($row) || ($row[0] ?? null) === null || ($row[1] ?? null) === null) {
continue;
}
$name = (string) $row[0];
// Use only direct lowercase `a` tags here; uppercase `A` is often thread-root context.
// Nested replies should derive blurbs from the direct `e` parent (handled via parentOf fallback).
if ($name !== 'a') {
continue;
}
$coord = (string) $row[1];
if ($coord === '') {
continue;
}
$parts = explode(':', $coord, 3);
if (\count($parts) !== 3) {
continue;
}
$kind = ctype_digit((string) $parts[0]) ? (int) $parts[0] : 0;
if (!\in_array($kind, [30023, 30024], true)) {
continue;
}
$dTag = trim((string) $parts[2]);
if ($dTag === '') {
$dTag = $coord;
}
return '> *'.'Replying to'.'* — '."\n> ".$dTag;
}
return null;
}
/**
* Truncated single-line text from a parent’s content (strips a leading NIP-22 quote block when present),
* similar in spirit to Jumble’s {@see ParentNotePreview} + compact ContentPreview.

9
src/Service/CacheService.php

@ -9,16 +9,15 @@ use App\Nostr\MagazineEventKeys; @@ -9,16 +9,15 @@ use App\Nostr\MagazineEventKeys;
use App\Repository\EventRepository;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use swentel\nostr\Key\Key;
readonly class CacheService implements HighlightAuthorMetadataProvider
readonly class CacheService
{
public function __construct(
private NostrClient $nostrClient,
private EntityManagerInterface $entityManager,
private EventRepository $eventRepository,
private LoggerInterface $logger,
private NostrKeyHelper $nostrKeyHelper,
private NostrNip65RelayUrls $nip65RelayUrls,
) {
}
@ -112,7 +111,7 @@ readonly class CacheService implements HighlightAuthorMetadataProvider @@ -112,7 +111,7 @@ readonly class CacheService implements HighlightAuthorMetadataProvider
}
$this->replaceByCoreKey($key, Event::STORAGE_RELAY_LIST_10002, $wire);
return $this->nip65RelayUrls->wssListFromKind10002Wire($wire);
return NostrClient::relayWssListFromNip65Object($wire);
}
/**
@ -153,7 +152,7 @@ readonly class CacheService implements HighlightAuthorMetadataProvider @@ -153,7 +152,7 @@ readonly class CacheService implements HighlightAuthorMetadataProvider
}
if (str_starts_with($npub, 'npub1')) {
try {
$h = $this->nostrKeyHelper->convertToHex($npub);
$h = (new Key())->convertToHex($npub);
} catch (\Throwable) {
$h = '';
}

106
src/Service/CommentReplyService.php

@ -6,8 +6,10 @@ namespace App\Service; @@ -6,8 +6,10 @@ namespace App\Service;
use App\Entity\User;
use App\Enum\KindsEnum;
use nostriphant\NIP19\Bech32;
use Psr\Log\LoggerInterface;
use swentel\nostr\Event\Event as NostrWireEvent;
use swentel\nostr\Key\Key;
/**
* Validates NIP-22 kind-1111 comment events from logged-in users and publishes to article relays.
@ -19,14 +21,13 @@ final readonly class CommentReplyService @@ -19,14 +21,13 @@ final readonly class CommentReplyService
public function __construct(
private NostrClient $nostrClient,
private LoggerInterface $logger,
private readonly NostrKeyHelper $nostrKeyHelper,
) {
}
/**
* @param array<string, mixed> $payload Decoded JSON body
*
* @return array{ok: true, id: string, relays: array<string, mixed>, ok_relays: int, total_relays: int}|array{ok: false, error: string, code: int}
* @return array{ok: true, id: string, relays: array<string, mixed>}|array{ok: false, error: string, code: int}
*/
public function publishFromRequestPayload(User $user, array $payload): array
{
@ -72,7 +73,8 @@ final readonly class CommentReplyService @@ -72,7 +73,8 @@ final readonly class CommentReplyService
return ['ok' => false, 'error' => 'Event created_at out of range', 'code' => 400];
}
$userHex = $this->nostrKeyHelper->convertToHex($user->getNpub() ?? '');
$key = new Key();
$userHex = $key->convertToHex($user->getNpub() ?? '');
if ($userHex === '' || !hash_equals($userHex, $wire->getPublicKey())) {
return ['ok' => false, 'error' => 'Pubkey does not match logged-in user', 'code' => 403];
}
@ -81,8 +83,13 @@ final readonly class CommentReplyService @@ -81,8 +83,13 @@ final readonly class CommentReplyService
return ['ok' => false, 'error' => 'Tags must include a/A for this article', 'code' => 400];
}
if (!$this->tagsReferenceParent($wire->getTags(), $expectedCoordinate, $parentKind, $parentId)) {
return ['ok' => false, 'error' => 'Tags must reference the selected parent (a/A for article or e/E for comment)', 'code' => 400];
if (!$this->contentBlurbReferencesParent(
$wire->getContent(),
$expectedCoordinate,
$parentKind,
$parentId
)) {
return ['ok' => false, 'error' => 'Reply must start with a quote line (>) linking the parent via nostr:nevent1 / naddr1 (reply blurb)', 'code' => 400];
}
$rawParentAuthor = isset($payload['parent_author_pubkey']) && \is_string($payload['parent_author_pubkey'])
@ -107,34 +114,12 @@ final readonly class CommentReplyService @@ -107,34 +114,12 @@ final readonly class CommentReplyService
$relays = $this->nostrClient->getRelayUrlsForCommentPublish($expectedCoordinate, $parentAuthorHex);
$result = $this->nostrClient->publishEvent($wire, $relays);
$okRelays = 0;
foreach ($result as $relayRes) {
if ($relayRes instanceof \Throwable) {
continue;
}
$okRelays++;
}
if ($okRelays < 1) {
$this->logger->warning('comment_reply.publish_failed_all_relays', [
'id' => $wire->getId(),
'relay_count' => \count($result),
]);
return ['ok' => false, 'error' => 'Publish failed on all relays (network/relay error). Please retry.', 'code' => 502];
}
$this->logger->info('comment_reply.published', [
'id' => $wire->getId(),
'relays' => \array_keys($result),
'ok_relays' => $okRelays,
]);
return [
'ok' => true,
'id' => $wire->getId(),
'relays' => $result,
'ok_relays' => $okRelays,
'total_relays' => \count($result),
];
return ['ok' => true, 'id' => $wire->getId(), 'relays' => $result];
}
/**
@ -157,42 +142,53 @@ final readonly class CommentReplyService @@ -157,42 +142,53 @@ final readonly class CommentReplyService
return false;
}
/**
* @param array<int, mixed> $tags
*/
private function tagsReferenceParent(
array $tags,
private function contentBlurbReferencesParent(
string $content,
string $articleCoordinate,
int $parentKind,
string $parentIdHex
): bool {
if (\in_array($parentKind, [KindsEnum::LONGFORM->value, KindsEnum::LONGFORM_DRAFT->value], true)) {
foreach ($tags as $row) {
if (!\is_array($row) || ($row[0] ?? null) === null) {
continue;
}
$n = (string) $row[0];
if (($n === 'a' || $n === 'A') && ($row[1] ?? '') === $articleCoordinate) {
return true;
}
}
$head = \strlen($content) > 800 ? substr($content, 0, 800) : $content;
if (!str_contains($head, "\n\n")) {
return false;
}
if ($parentKind === KindsEnum::COMMENTS->value) {
foreach ($tags as $row) {
if (!\is_array($row) || ($row[0] ?? null) === null) {
continue;
}
$n = (string) $row[0];
if (($n === 'e' || $n === 'E') && \is_string($row[1] ?? null) && hash_equals($parentIdHex, (string) $row[1])) {
return true;
}
[$blurb] = explode("\n\n", $head, 2);
$blurb = trim($blurb);
if ($blurb === '' || !str_starts_with($blurb, '>')) {
return false;
}
if (!preg_match('/nostr:(nevent1[0-9a-z]+|naddr1[0-9a-z]+|note1[0-9a-z]+)/i', $blurb, $m)) {
return false;
}
try {
$decoded = new Bech32($m[1]);
} catch (\Throwable) {
return false;
}
if ($decoded->type === 'nevent') {
$id = $decoded->data->id ?? null;
return \is_string($id) && 64 === \strlen($id) && ctype_xdigit($id) && hash_equals($parentIdHex, $id);
}
if ($decoded->type === 'note') {
$id = $decoded->data->identifier ?? null;
return \is_string($id) && 64 === \strlen($id) && ctype_xdigit($id) && hash_equals($parentIdHex, $id);
}
if ($decoded->type === 'naddr') {
$d = $decoded->data;
$coord = $d->kind.':'.$d->pubkey.':'.$d->identifier;
if (!\in_array($parentKind, [KindsEnum::LONGFORM->value, KindsEnum::LONGFORM_DRAFT->value], true)) {
return false;
}
if (!hash_equals($articleCoordinate, $coord)) {
return false;
}
$zero = str_repeat('0', 64);
return false;
return hash_equals($parentIdHex, $zero);
}
return true;
return false;
}
}

86
src/Service/FeaturedAuthorListedRows.php

@ -1,86 +0,0 @@ @@ -1,86 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Repository\FeaturedAuthorRepository;
/**
* NIP-05 / listed featured author rows (same shape as {@see \App\Controller\FeaturedAuthorsController}).
* Sidebar: falls back to magazine index pubkeys when the `featured_author` list is still empty
* (e.g. prewarm has not yet synced the table).
*/
final class FeaturedAuthorListedRows
{
public function __construct(
private readonly FeaturedAuthorRepository $featuredAuthorRepository,
private readonly CacheService $cacheService,
private readonly MagazineContentService $magazineContent,
private readonly NostrKeyHelper $nostrKeyHelper,
) {
}
/**
* Rows for the left nav: NIP-05–listed authors first, otherwise authors from the magazine index store.
*
* @return list<array{npub: string, pubkey: string, display_name: string, picture: string, local_part: string}>
*/
public function buildSidebarRows(int $limit = 12): array
{
$fromDb = $this->buildListedByLocalPartPage($limit, 0);
if ($fromDb !== []) {
return $fromDb;
}
$authors = [];
$hexes = $this->magazineContent->getAllDistinctCategoryAuthorPubkeyHexes();
foreach (\array_slice($hexes, 0, $limit) as $hex) {
try {
$npub = $this->nostrKeyHelper->convertPublicKeyToBech32($hex);
} catch (\Throwable) {
continue;
}
$authors[] = $this->rowFromNpub($npub, $hex, '');
}
return $authors;
}
/**
* @return list<array{npub: string, pubkey: string, display_name: string, picture: string, local_part: string}>
*/
public function buildListedByLocalPartPage(int $limit, int $offset = 0): array
{
$authors = [];
foreach ($this->featuredAuthorRepository->findListedOrderByLocalPartPaginated($limit, $offset) as $fa) {
try {
$npub = $this->nostrKeyHelper->convertPublicKeyToBech32($fa->getPubkeyHex());
} catch (\Throwable) {
continue;
}
$authors[] = $this->rowFromNpub($npub, $fa->getPubkeyHex(), $fa->getLocalPart());
}
return $authors;
}
/**
* @return array{npub: string, pubkey: string, display_name: string, picture: string, local_part: string}
*/
private function rowFromNpub(string $npub, string $pubkeyHex, string $localPart): array
{
$bundle = $this->cacheService->getMetadataBundle($npub);
$author = $bundle['content'];
$displayName = trim((string) ($author->display_name ?? $author->name ?? ''));
$picture = trim((string) ($author->picture ?? ''));
return [
'npub' => $npub,
'pubkey' => strtolower($pubkeyHex),
'display_name' => $displayName,
'picture' => $picture,
'local_part' => $localPart,
];
}
}

86
src/Service/FeaturedAuthorSync.php

@ -8,10 +8,11 @@ use App\Entity\FeaturedAuthor; @@ -8,10 +8,11 @@ use App\Entity\FeaturedAuthor;
use App\Repository\FeaturedAuthorRepository;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use swentel\nostr\Key\Key;
/**
* Reconciles {@see FeaturedAuthor} rows with pubkeys found in magazine category `a` tags.
* The listed set is derived from current category indices during prewarm.
* 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
{
@ -21,87 +22,44 @@ final class FeaturedAuthorSync @@ -21,87 +22,44 @@ final class FeaturedAuthorSync
private readonly CacheService $cacheService,
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
private readonly NostrKeyHelper $nostrKeyHelper,
) {
}
/**
* @return array{added: int, relisted: int, unlisted: int, listed_total: int}
* @return int Number of newly persisted authors
*/
public function reconcileListedAuthorsFromMagazineCategories(): array
public function syncNewAuthorsFromMagazineCategories(): int
{
$pubkeys = $this->magazineContent->getAllDistinctCategoryAuthorPubkeyHexes();
$target = [];
foreach ($pubkeys as $hex) {
$h = strtolower(trim($hex));
if (64 === \strlen($h) && ctype_xdigit($h)) {
$target[$h] = true;
}
}
$existingByPubkey = [];
foreach ($this->featuredAuthorRepository->findAll() as $row) {
$existingByPubkey[strtolower($row->getPubkeyHex())] = $row;
}
$added = 0;
$relisted = 0;
$unlisted = 0;
$changed = false;
foreach (array_keys($target) as $hex) {
$row = $existingByPubkey[$hex] ?? null;
if ($row === null) {
$entity = new FeaturedAuthor();
$entity->setPubkeyHex($hex);
$base = $this->deriveBaseLocalPart($hex);
$entity->setLocalPart($this->allocateUniqueLocalPart($base));
$entity->setIsListed(true);
$this->entityManager->persist($entity);
$existingByPubkey[$hex] = $entity;
$added++;
$changed = true;
continue;
}
if (!$row->isListed()) {
$row->setIsListed(true);
$relisted++;
$changed = true;
}
if ($pubkeys === []) {
return 0;
}
foreach ($existingByPubkey as $hex => $row) {
if (isset($target[$hex])) {
$keys = new Key();
$n = 0;
foreach ($pubkeys as $hex) {
if ($this->featuredAuthorRepository->findOneByPubkeyHex($hex) !== null) {
continue;
}
if ($row->isListed()) {
$row->setIsListed(false);
$unlisted++;
$changed = true;
}
$entity = new FeaturedAuthor();
$entity->setPubkeyHex($hex);
$base = $this->deriveBaseLocalPart($keys, $hex);
$entity->setLocalPart($this->allocateUniqueLocalPart($base));
$this->entityManager->persist($entity);
++$n;
}
if ($changed) {
if ($n > 0) {
$this->entityManager->flush();
$this->logger->info('featured_author.sync', [
'added' => $added,
'relisted' => $relisted,
'unlisted' => $unlisted,
'listed_total' => \count($target),
]);
$this->logger->info('featured_author.sync', ['new_count' => $n]);
}
return [
'added' => $added,
'relisted' => $relisted,
'unlisted' => $unlisted,
'listed_total' => \count($target),
];
return $n;
}
private function deriveBaseLocalPart(string $pubkeyHex): string
private function deriveBaseLocalPart(Key $keys, string $pubkeyHex): string
{
try {
$npub = $this->nostrKeyHelper->convertPublicKeyToBech32($pubkeyHex);
$npub = $keys->convertPublicKeyToBech32($pubkeyHex);
} catch (\Throwable) {
$npub = null;
}

13
src/Service/HighlightAuthorMetadataProvider.php

@ -1,13 +0,0 @@ @@ -1,13 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Service;
/**
* Subset of {@see CacheService} for {@see ArticleBodyHighlightInjector} (mockable; readonly services cannot be doubled in PHPUnit).
*/
interface HighlightAuthorMetadataProvider
{
public function getMetadata(string $npub): \stdClass;
}

106
src/Service/HighlightSyncService.php

@ -1,106 +0,0 @@ @@ -1,106 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\Article;
use App\Entity\ArticleHighlight;
use App\Enum\KindsEnum;
use App\Repository\ArticleHighlightRepository;
use App\Util\HighlightEventTags;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
/**
* Pulls kind-9802 highlights from relays and upserts into {@see ArticleHighlight}.
*/
final class HighlightSyncService
{
public function __construct(
private readonly NostrClient $nostrClient,
private readonly EntityManagerInterface $entityManager,
private readonly ArticleHighlightRepository $highlightRepository,
private readonly LoggerInterface $logger,
) {
}
/**
* @return int number of highlight rows written/updated
*/
public function syncForArticle(Article $article): int
{
$id = $article->getId();
if (null === $id || (int) $id < 1) {
return 0;
}
$slug = trim((string) $article->getSlug());
$pubkey = (string) $article->getPubkey();
if ($slug === '' || 64 !== \strlen($pubkey) || !ctype_xdigit($pubkey)) {
return 0;
}
$kind = $article->getKind()?->value ?? 30023;
$coordinate = $kind.':'.$pubkey.':'.$slug;
$events = $this->nostrClient->fetchHighlightEventsForArticle($coordinate);
$n = 0;
foreach ($events as $ev) {
if (!\is_object($ev)) {
continue;
}
if ((int) ($ev->kind ?? 0) !== KindsEnum::HIGHLIGHTS->value) {
continue;
}
$eid = strtolower((string) ($ev->id ?? ''));
if (64 !== \strlen($eid) || !ctype_xdigit($eid)) {
continue;
}
$author = strtolower((string) ($ev->pubkey ?? ''));
if (64 !== \strlen($author) || !ctype_xdigit($author)) {
continue;
}
$tags = $ev->tags ?? [];
if (!\is_array($tags)) {
$tags = [];
}
$tags = HighlightEventTags::normalizeTagsForStorage($tags);
$content = (string) ($ev->content ?? '');
$ca = (int) ($ev->created_at ?? 0);
if ($ca < 0) {
$ca = 0;
}
$excerpt = HighlightEventTags::excerptForFeed($content, $tags);
if ($excerpt === '') {
$t = HighlightEventTags::trimNostrText($content);
$excerpt = $t !== '' ? \mb_substr($t, 0, 240) : '';
}
$row = $this->highlightRepository->findOneBy(['eventId' => $eid]);
if ($row === null) {
$row = new ArticleHighlight();
$row->setEventId($eid);
}
$row->setArticle($article);
$row->setAuthorPubkey($author);
$row->setContent($content);
$row->setTags($tags);
$row->setEventCreatedAt($ca);
$row->setQuoteExcerpt($excerpt !== '' ? $excerpt : null);
$this->entityManager->persist($row);
++$n;
}
if ($n > 0) {
$this->entityManager->flush();
}
$this->logger->info('highlight_sync.article', [
'article_id' => $id,
'slug' => $slug,
'ingested' => $n,
]);
return $n;
}
}

391
src/Service/MagazineContentService.php

@ -4,7 +4,6 @@ declare(strict_types=1); @@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Service;
use App\Dto\FeaturedArticleCard;
use App\Entity\Article;
use App\Entity\Event;
use App\Enum\EventStatusEnum;
@ -28,6 +27,16 @@ final class MagazineContentService @@ -28,6 +27,16 @@ final class MagazineContentService
) {
}
/**
* @deprecated use {@see getHomeCategoryAIndexTagsFromStoreOnly} (identical; no blocking relay I/O)
*
* @return list<array<int, string>>
*/
public function getHomeCategoryIndexTags(): array
{
return $this->getHomeCategoryAIndexTagsFromStoreOnly();
}
/**
* Category `a` tags from the persisted root only (no relay). The store is filled by
* `app:prewarm` / cron ({@see MagazineRefresher::refreshFromRelays}), not from HTTP.
@ -187,13 +196,9 @@ final class MagazineContentService @@ -187,13 +196,9 @@ final class MagazineContentService
* Category listing from the persisted 30040 index and DB only. Does not call relays.
* Rows come from MySQL only; run `app:prewarm` to sync new `a` tags and replaceable revisions.
*
* @return array{
* list: list<Article>,
* category: array{title: string, summary: string},
* pagination: array{page: int, per_page: int, total: int, last_page: int}
* }
* @return array{list: list<Article>, category: array{title: string, summary: string}}
*/
public function getCategoryPageData(string $slug, int $page = 1, int $perPage = 25): array
public function getCategoryPageData(string $slug): array
{
$this->warmCategoryIndexIfMissing($slug);
$catIndex = $this->store->getCategory($slug);
@ -251,24 +256,9 @@ final class MagazineContentService @@ -251,24 +256,9 @@ final class MagazineContentService
$category['title'] = $category['title'] ?? '';
$category['summary'] = $category['summary'] ?? '';
$perPage = max(1, $perPage);
$page = max(1, $page);
$total = \count($list);
$lastPage = max(1, (int) \ceil($total / $perPage));
if ($page > $lastPage) {
$page = $lastPage;
}
$offset = ($page - 1) * $perPage;
return [
'list' => \array_slice($list, $offset, $perPage),
'list' => $list,
'category' => $category,
'pagination' => [
'page' => $page,
'per_page' => $perPage,
'total' => $total,
'last_page' => $lastPage,
],
];
}
@ -281,9 +271,6 @@ final class MagazineContentService @@ -281,9 +271,6 @@ final class MagazineContentService
{
$n = 0;
foreach ($this->getCategorySlugsFromStore() as $catSlug) {
// If a category 30040 wasn't persisted during the refresh phase (relay errors/timeouts),
// try one direct fetch here so long-form ingest and reports are not silently incomplete.
$this->warmCategoryIndexIfMissing($catSlug);
$all = $this->findAllLongformCoordinatesForCategory($catSlug);
if ($all === []) {
continue;
@ -295,189 +282,6 @@ final class MagazineContentService @@ -295,189 +282,6 @@ final class MagazineContentService
return $n;
}
/**
* Human-readable prewarm/audit data: what each cached category index (30040) lists and which
* coordinates are unresolved in local MySQL `article`.
*
* @return array{
* categories: list<array{
* slug: string,
* title: string,
* event_id: string,
* listed_total: int,
* resolved_total: int,
* missing_total: int,
* entries: list<array{
* coordinate: string,
* status: 'resolved'|'missing',
* reason: string,
* article_title?: string,
* article_slug?: string
* }>
* }>,
* totals: array{categories: int, listed: int, resolved: int, missing: int}
* }
*/
public function buildCategoryArticleDbCoverageReport(): array
{
$categories = [];
$totListed = 0;
$totResolved = 0;
$totMissing = 0;
foreach ($this->getCategorySlugsFromStore() as $slug) {
$this->warmCategoryIndexIfMissing($slug);
$catIndex = $this->store->getCategory($slug);
if ($catIndex === null) {
$totMissing++;
$categories[] = [
'slug' => $slug,
'title' => $slug,
'event_id' => '',
'listed_total' => 0,
'resolved_total' => 0,
'missing_total' => 1,
'entries' => [[
'coordinate' => 'category:'.$slug,
'status' => 'missing',
'reason' => 'category_index_unavailable',
]],
];
continue;
}
$title = $slug;
$coords = [];
foreach ($catIndex->getTags() as $tag) {
$seq = NostrEventTags::rowToStringList($tag);
if ($seq === null) {
continue;
}
$name = strtolower((string) ($seq[0] ?? ''));
if ($name === 'title' && isset($seq[1]) && trim((string) $seq[1]) !== '') {
$title = trim((string) $seq[1]);
}
if ($name === 'a' && isset($seq[1]) && trim((string) $seq[1]) !== '') {
$coords[] = trim((string) $seq[1]);
}
}
$coords = array_values(array_unique($coords));
$pairs = [];
foreach ($coords as $coordinate) {
$parts = explode(':', $coordinate, 3);
if (\count($parts) < 3) {
continue;
}
$pub = strtolower(trim((string) $parts[1]));
$d = trim((string) $parts[2]);
if ($d === '' || 64 !== \strlen($pub) || !ctype_xdigit($pub)) {
continue;
}
$pairs[] = ['pubkey' => $pub, 'slug' => $d];
}
$byAddress = $this->articleRepository->findByAuthorAndSlugIndexed($pairs);
$entries = [];
$resolved = 0;
$missing = 0;
foreach ($coords as $coordinate) {
$parts = explode(':', $coordinate, 3);
if (\count($parts) < 3) {
$entries[] = ['coordinate' => $coordinate, 'status' => 'missing', 'reason' => 'malformed_coordinate'];
$missing++;
continue;
}
$kind = (int) ($parts[0] ?? 0);
if (!\in_array($kind, [30023, 30024], true)) {
$entries[] = ['coordinate' => $coordinate, 'status' => 'missing', 'reason' => 'unsupported_kind'];
$missing++;
continue;
}
$pub = strtolower(trim((string) $parts[1]));
$d = trim((string) $parts[2]);
if (64 !== \strlen($pub) || !ctype_xdigit($pub)) {
$entries[] = ['coordinate' => $coordinate, 'status' => 'missing', 'reason' => 'invalid_pubkey'];
$missing++;
continue;
}
if ($d === '') {
$entries[] = ['coordinate' => $coordinate, 'status' => 'missing', 'reason' => 'empty_identifier'];
$missing++;
continue;
}
$k = $pub."\0".$d;
if (!isset($byAddress[$k])) {
$entries[] = ['coordinate' => $coordinate, 'status' => 'missing', 'reason' => 'article_not_in_db'];
$missing++;
continue;
}
$article = $byAddress[$k];
$entries[] = [
'coordinate' => $coordinate,
'status' => 'resolved',
'reason' => 'ok',
'article_title' => (string) ($article->getTitle() ?? ''),
'article_slug' => (string) ($article->getSlug() ?? ''),
];
$resolved++;
}
$listed = \count($coords);
$totListed += $listed;
$totResolved += $resolved;
$totMissing += $missing;
$categories[] = [
'slug' => $slug,
'title' => $title,
'event_id' => $catIndex->getId(),
'listed_total' => $listed,
'resolved_total' => $resolved,
'missing_total' => $missing,
'entries' => $entries,
];
}
return [
'categories' => $categories,
'totals' => [
'categories' => \count($categories),
'listed' => $totListed,
'resolved' => $totResolved,
'missing' => $totMissing,
],
];
}
/**
* @param array{
* categories: list<array{
* entries: list<array{coordinate: string, status: string, reason: string}>
* }>
* } $report
* @return list<string>
*/
public function missingInDbCoordinatesFromCoverageReport(array $report): array
{
$out = [];
foreach ($report['categories'] ?? [] as $cat) {
foreach ($cat['entries'] ?? [] as $entry) {
if (($entry['status'] ?? '') !== 'missing') {
continue;
}
if (($entry['reason'] ?? '') !== 'article_not_in_db') {
continue;
}
$coord = isset($entry['coordinate']) ? (string) $entry['coordinate'] : '';
if ($coord !== '') {
$out[] = $coord;
}
}
}
return array_values(array_unique($out));
}
/**
* @return list<string> Nostr coordinates kind:pubkey:identifier
*/
@ -511,8 +315,7 @@ final class MagazineContentService @@ -511,8 +315,7 @@ final class MagazineContentService
* 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.
*
* Each category contributes at most the first page from {@see getCategoryPageData} (default 25
* `a` tags). Dedupes by slug (newest {@see Article::getCreatedAt} wins). Only PUBLISHED/ARCHIVED.
* Dedupes by slug (newest {@see Article::getCreatedAt} wins). Only PUBLISHED/ARCHIVED rows.
*
* @return list<Article> Newest first
*/
@ -621,170 +424,4 @@ final class MagazineContentService @@ -621,170 +424,4 @@ final class MagazineContentService
} catch (\Throwable) {
}
}
/**
* Article slugs that appear in any home “featured” block (per-category first pages), for topic ranking.
*
* @param list<array<int, string>> $categoryATags
*
* @return list<string>
*/
public function collectFeaturedArticleSlugsForHome(array $categoryATags): array
{
$out = [];
foreach ($categoryATags as $row) {
$coord = $row[1] ?? '';
if (!\is_string($coord) || $coord === '') {
continue;
}
$b = $this->buildCategoryFeaturedBlock($coord);
if ($b === null) {
continue;
}
foreach ($b['cards'] as $card) {
$s = \trim((string) $card->getSlug());
if ($s !== '') {
$out[$s] = true;
}
}
}
return array_keys($out);
}
/**
* Interleaves up to four articles per home category in round-robin order (one “wall” mixing all topics).
* Duplicate slugs across categories are skipped so each article appears at most once.
*
* @param list<array<int, string>> $categoryATags
*
* @return list<array{article: FeaturedArticleCard, categoryTitle: string}>
*/
public function buildHomeMixedFeaturedWallTiles(array $categoryATags): array
{
$blocks = [];
foreach ($categoryATags as $row) {
$coord = $row[1] ?? '';
if (!\is_string($coord) || $coord === '') {
continue;
}
$b = $this->buildCategoryFeaturedBlock($coord);
if ($b !== null && $b['cards'] !== []) {
$blocks[] = $b;
}
}
if ($blocks === []) {
return [];
}
$pointers = array_fill(0, \count($blocks), 0);
$seenSlugs = [];
$out = [];
while (true) {
$roundAdded = false;
for ($i = 0, $n = \count($blocks); $i < $n; ++$i) {
while (isset($blocks[$i]['cards'][$pointers[$i]])) {
$card = $blocks[$i]['cards'][$pointers[$i]];
$slug = \trim((string) $card->getSlug());
if ($slug !== '' && isset($seenSlugs[$slug])) {
++$pointers[$i];
continue;
}
if ($slug !== '') {
$seenSlugs[$slug] = true;
}
$out[] = [
'article' => $card,
'categoryTitle' => $blocks[$i]['title'],
];
++$pointers[$i];
$roundAdded = true;
break;
}
}
if (!$roundAdded) {
break;
}
}
return $out;
}
/**
* Same resolution as {@see \App\Twig\Components\Organisms\FeaturedList} (4 cards per category).
*
* @return null|array{title: string, cards: list<FeaturedArticleCard>}
*/
private function buildCategoryFeaturedBlock(string $categoryCoord): ?array
{
$parts = explode(':', $categoryCoord, 3);
if (\count($parts) < 3) {
return null;
}
$slug = $parts[2];
$catIndex = $this->store->getCategory($slug);
if (!\is_object($catIndex) || !\method_exists($catIndex, 'getTags')) {
return null;
}
$title = '';
$slugs = [];
foreach ($catIndex->getTags() as $tag) {
if (($tag[0] ?? null) === 'title' && isset($tag[1])) {
$title = (string) $tag[1];
}
if (($tag[0] ?? null) === 'a' && isset($tag[1])) {
$segs = explode(':', (string) $tag[1], 3);
$slugs[] = \trim((string) end($segs));
if (\count($slugs) >= 5) {
break;
}
}
}
if ($title === '') {
$title = $slug;
}
if ($slugs === []) {
return null;
}
$articles = $this->articleRepository->findFeaturedCardsBySlugs($slugs);
$slugMap = [];
foreach ($articles as $article) {
$articleSlug = \trim((string) $article->getSlug());
if ($articleSlug !== '') {
if (!isset($slugMap[$articleSlug])) {
$slugMap[$articleSlug] = $article;
} elseif ($this->featuredCardIsNewer($article, $slugMap[$articleSlug])) {
$slugMap[$articleSlug] = $article;
}
}
}
$orderedList = [];
foreach ($slugs as $articleSlug) {
$articleSlug = \trim((string) $articleSlug);
if ($articleSlug !== '' && isset($slugMap[$articleSlug])) {
$orderedList[] = $slugMap[$articleSlug];
}
}
$cards = \array_slice($orderedList, 0, 4);
return ['title' => $title, 'cards' => $cards];
}
private function featuredCardIsNewer(FeaturedArticleCard $a, FeaturedArticleCard $b): bool
{
$ca = $a->getDisplayAt();
$cb = $b->getDisplayAt();
if ($ca === null) {
return false;
}
if ($cb === null) {
return true;
}
return $ca > $cb;
}
}

20
src/Service/MagazineRefresher.php

@ -59,11 +59,8 @@ final class MagazineRefresher @@ -59,11 +59,8 @@ final class MagazineRefresher
$dTag = (string) $this->params->get('d_tag');
$preferFromEnv = $this->parseCommaSeparatedSlugs($this->magazinePrewarmPreferSlugs);
// Do not cap max_execution_time here. A previous design used 2×budget+30s (capped 210) which
// outlived this method and then killed the rest of app:prewarm (long-form / highlights)
// while a relay WebSocket was still connecting. Wall time is bounded by $deadline below
// (category phase) and by Nostr request timeouts; PHP should stay unlimited in CLI.
$this->ensureUnlimitedPhpExecutionTime();
// Allow enough PHP wall time for a slow root fetch plus the full category-phase budget.
$this->applyExecutionTimeCap(2 * $budgetSeconds);
$defaultRelay = (string) $this->params->get('default_relay');
$relayLabel = (string) (parse_url($defaultRelay, \PHP_URL_HOST) ?: $defaultRelay);
@ -151,7 +148,7 @@ final class MagazineRefresher @@ -151,7 +148,7 @@ final class MagazineRefresher
}
try {
$this->featuredAuthorSync->reconcileListedAuthorsFromMagazineCategories();
$this->featuredAuthorSync->syncNewAuthorsFromMagazineCategories();
} catch (\Throwable $e) {
$this->logger->warning('MagazineRefresher: featured author sync failed', [
'message' => $e->getMessage(),
@ -253,10 +250,15 @@ final class MagazineRefresher @@ -253,10 +250,15 @@ final class MagazineRefresher
$this->appCache->save($item);
}
private function ensureUnlimitedPhpExecutionTime(): void
/**
* One generous ceiling for PHP so relay/WebSocket I/O in one Nostr call can outlast the soft
* $deadline by seconds without a fatal, while the loop still stops *starting* new fetches in time.
*/
private function applyExecutionTimeCap(int $budgetSeconds): void
{
@\set_time_limit(0);
@\ini_set('max_execution_time', '0');
$sec = max(30, min(700, $budgetSeconds + 30));
@set_time_limit($sec);
@ini_set('max_execution_time', (string) $sec);
}
/**

73
src/Service/Nip05VerificationService.php

@ -7,10 +7,11 @@ namespace App\Service; @@ -7,10 +7,11 @@ 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).
* Uses {@see file_get_contents} with an explicit HTTP 200 check, timeout, and npub/hex normalization.
* Results are stored in the app cache for UI badges and to avoid re-fetching on every request.
*/
final readonly class Nip05VerificationService
{
@ -21,7 +22,6 @@ final readonly class Nip05VerificationService @@ -21,7 +22,6 @@ final readonly class Nip05VerificationService
public function __construct(
private CacheItemPoolInterface $appCache,
private LoggerInterface $logger,
private NostrKeyHelper $nostrKeyHelper = new NostrKeyHelper(),
) {
}
@ -110,7 +110,7 @@ final readonly class Nip05VerificationService @@ -110,7 +110,7 @@ final readonly class Nip05VerificationService
return null;
}
$p = explode('@', $s, 2);
if (!isset($p[1]) || $p[0] === '' || $p[1] === '' || str_contains($p[1], ' ')) {
if (($p[0] ?? '') === '' || ($p[1] ?? '') === '' || str_contains($p[1], ' ')) {
return null;
}
@ -120,41 +120,17 @@ final readonly class Nip05VerificationService @@ -120,41 +120,17 @@ final readonly class Nip05VerificationService
private function checkRemote(string $expectedHex, string $nip05Lower): bool
{
$parts = explode('@', $nip05Lower, 2);
if (!isset($parts[1]) || $parts[0] === '' || $parts[1] === '') {
$local = (string) ($parts[0] ?? '');
$domain = (string) ($parts[1] ?? '');
if ($local === '' || $domain === '') {
return false;
}
$local = $parts[0];
$domain = $parts[1];
$data = $this->fetchNostrJson200($domain, $local, $nip05Lower);
if ($data === null) {
return false;
}
if (!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);
}
/**
* @return array<string, mixed>|null Decoded JSON object on HTTP 200; null on failure or non-200.
*/
private function fetchNostrJson200(string $domain, string $nameLocal, string $nip05LowerForLog): ?array
{
$url = 'https://'.$domain.'/.well-known/nostr.json?name='.rawurlencode($nameLocal);
$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",
'header' => "User-Agent: Unfold-NIP05-Verify/1.0\r\nAccept: application/json,\r\n",
'timeout' => self::FETCH_TIMEOUT_SEC,
'ignore_errors' => true,
],
@ -166,30 +142,40 @@ final readonly class Nip05VerificationService @@ -166,30 +142,40 @@ final readonly class Nip05VerificationService
$raw = @file_get_contents($url, false, $ctx);
if ($raw === false) {
$this->logger->info('nip05.verify_fetch_failed', [
'nip05' => $nip05LowerForLog,
'nip05' => $nip05Lower,
]);
return null;
return false;
}
$statusLine = (string) ($http_response_header[0] ?? '');
$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' => $nip05LowerForLog,
'nip05' => $nip05Lower,
'status' => $statusLine,
]);
return null;
return false;
}
try {
$data = json_decode($raw, true, 512, \JSON_THROW_ON_ERROR);
} catch (\JsonException) {
return null;
return false;
}
if (!\is_array($data)) {
return null;
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 $data;
return hash_equals($expectedHex, $rowHex);
}
/**
@ -218,7 +204,8 @@ final readonly class Nip05VerificationService @@ -218,7 +204,8 @@ final readonly class Nip05VerificationService
}
if (str_starts_with($v, 'npub1')) {
try {
$hex = $this->nostrKeyHelper->convertToHex($v);
$k = new Key();
$hex = $k->convertToHex($v);
if (64 === \strlen($hex) && ctype_xdigit($hex)) {
return strtolower($hex);
}

4
src/Service/Nip09DeletionApplier.php

@ -11,6 +11,7 @@ use App\Repository\ArticleRepository; @@ -11,6 +11,7 @@ use App\Repository\ArticleRepository;
use App\Repository\EventRepository;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use swentel\nostr\Key\Key;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
/**
@ -33,7 +34,6 @@ final class Nip09DeletionApplier @@ -33,7 +34,6 @@ final class Nip09DeletionApplier
private readonly EventRepository $eventRepository,
private readonly ParameterBagInterface $params,
private readonly LoggerInterface $logger,
private readonly NostrKeyHelper $nostrKeyHelper,
) {
}
@ -342,7 +342,7 @@ final class Nip09DeletionApplier @@ -342,7 +342,7 @@ final class Nip09DeletionApplier
$siteHex = '';
if (str_starts_with($npub, 'npub1')) {
try {
$h = $this->nostrKeyHelper->convertToHex($npub);
$h = (new Key())->convertToHex($npub);
if (64 === \strlen($h)) {
$siteHex = $h;
}

169
src/Service/NostrArticleDiscussionSupport.php

@ -1,169 +0,0 @@ @@ -1,169 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Enum\KindsEnum;
use swentel\nostr\Filter\Filter;
/**
* REQ {@link Filter}s and tag-matching rules for long-form article discussion (NIP-22 kind 1111, legacy kind 1, quotes).
* Used by {@see NostrClient::getArticleDiscussion()}.
*/
final class NostrArticleDiscussionSupport
{
/**
* @return array<int, Filter>
*/
public function createArticleDiscussionFilters(string $coordinate, ?string $rootEventHexId): array
{
$limThread = 100;
$limQuote = 80;
$filters = [];
$k1111 = KindsEnum::COMMENTS->value;
$f = new Filter();
$f->setKinds([$k1111]);
$f->setTag('#A', [$coordinate]);
$f->setLimit($limThread);
$filters[] = $f;
$f = new Filter();
$f->setKinds([$k1111]);
$f->setTag('#a', [$coordinate]);
$f->setLimit($limThread);
$filters[] = $f;
$k1 = KindsEnum::TEXT_NOTE->value;
$f = new Filter();
$f->setKinds([$k1]);
$f->setTag('#A', [$coordinate]);
$f->setLimit($limThread);
$filters[] = $f;
$f = new Filter();
$f->setKinds([$k1]);
$f->setTag('#a', [$coordinate]);
$f->setLimit($limThread);
$filters[] = $f;
if ($rootEventHexId !== null && $rootEventHexId !== '') {
$f = new Filter();
$f->setKinds([$k1]);
$f->setTag('#e', [$rootEventHexId]);
$f->setLimit($limThread);
$filters[] = $f;
}
$qKinds = [
KindsEnum::TEXT_NOTE->value,
KindsEnum::REPOST->value,
KindsEnum::GENERIC_REPOST->value,
KindsEnum::COMMENTS->value,
];
$qVals = [$coordinate];
if ($rootEventHexId !== null && $rootEventHexId !== '') {
$qVals[] = $rootEventHexId;
}
$f = new Filter();
$f->setKinds($qKinds);
$f->setTag('#q', $qVals);
$f->setLimit($limQuote);
$filters[] = $f;
$f = new Filter();
$f->setKinds([KindsEnum::GENERIC_REPOST->value]);
$f->setTag('#a', [$coordinate]);
$f->setLimit(50);
$filters[] = $f;
return $filters;
}
public function eventIsNip22ArticleThreadReply(object $event, string $coordinate): bool
{
if ((int) ($event->kind ?? 0) !== KindsEnum::COMMENTS->value) {
return false;
}
foreach ($event->tags ?? [] as $tag) {
if (!\is_array($tag) || \count($tag) < 2) {
continue;
}
$name = (string) ($tag[0] ?? '');
if (($name === 'a' || $name === 'A') && (string) ($tag[1] ?? '') === $coordinate) {
return true;
}
}
return false;
}
public function eventIsLegacyThreadReply(object $event, string $coordinate, ?string $rootEventHexId): bool
{
if ((int) ($event->kind ?? 0) !== KindsEnum::TEXT_NOTE->value) {
return false;
}
foreach ($event->tags ?? [] as $tag) {
if (!\is_array($tag) || \count($tag) < 2) {
continue;
}
$name = (string) ($tag[0] ?? '');
$val = (string) ($tag[1] ?? '');
if (($name === 'a' || $name === 'A') && $val === $coordinate) {
return true;
}
if ($rootEventHexId !== null && $rootEventHexId !== '' && $name === 'e' && $val === $rootEventHexId) {
return true;
}
}
return false;
}
public function eventIsArticleQuote(object $event, string $coordinate, ?string $rootEventHexId): bool
{
$kind = (int) ($event->kind ?? 0);
if ($kind === KindsEnum::HIGHLIGHTS->value) {
return false;
}
if ($kind === KindsEnum::COMMENTS->value) {
foreach ($event->tags ?? [] as $tag) {
if (!\is_array($tag) || \count($tag) < 2) {
continue;
}
if (($tag[0] ?? '') === 'q') {
$val = (string) ($tag[1] ?? '');
if ($val === $coordinate || ($rootEventHexId !== null && $val === $rootEventHexId)) {
return true;
}
}
}
return false;
}
foreach ($event->tags ?? [] as $tag) {
if (!\is_array($tag) || \count($tag) < 2) {
continue;
}
$name = (string) ($tag[0] ?? '');
$val = (string) ($tag[1] ?? '');
if ($name === 'q') {
if ($val === $coordinate || ($rootEventHexId !== null && $val === $rootEventHexId)) {
return true;
}
}
}
if ($kind === KindsEnum::GENERIC_REPOST->value) {
foreach ($event->tags ?? [] as $tag) {
if (!\is_array($tag) || \count($tag) < 2) {
continue;
}
if (($tag[0] ?? '') === 'a' && (string) ($tag[1] ?? '') === $coordinate) {
return true;
}
}
}
return false;
}
}

89
src/Service/NostrAuthorRelayCache.php

@ -1,89 +0,0 @@ @@ -1,89 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Service;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
/**
* Kind-10002 (NIP-65) wss:// lists per author hex pubkey, cached. Fetches wire data via
* {@see NostrClient::getNpubRelays()}; {@see NostrClient} is injected lazily to avoid a container cycle.
*
* Intentionally not `final` so Symfony can generate a lazy proxy for this service.
*/
class NostrAuthorRelayCache
{
public function __construct(
private readonly CacheInterface $relayQueryCache,
private readonly LoggerInterface $logger,
private readonly NostrRelayListFactory $relayListFactory,
private readonly NostrClient $nostrClient,
) {
}
/**
* Full NIP-65 wss:// list for a hex pubkey, cached. Prefer {@see getTopReputableRelaysForAuthor} when
* only a few relays are needed.
*
* @return list<string>
*/
public function getAuthorNip65RelaysList(string $pubkey): array
{
$cacheKey = 'nostr_kind10002_relays_v1_'.hash('sha256', $pubkey);
return $this->relayQueryCache->get($cacheKey, function (ItemInterface $item) use ($pubkey): array {
$item->expiresAfter(3600);
try {
$authorRelays = $this->nostrClient->getNpubRelays($pubkey);
} catch (\Exception $e) {
$this->logger->error('Error getting author NIP-65 relay list', [
'pubkey' => $pubkey,
'error' => $e->getMessage(),
]);
$authorRelays = [];
}
$authorRelays = array_values(array_filter(
$authorRelays,
static function (string $relay): bool {
return str_starts_with($relay, 'wss:')
&& !str_contains($relay, 'localhost');
}
));
if ($authorRelays === []) {
return [];
}
$seen = [];
$out = [];
foreach ($authorRelays as $u) {
if (isset($seen[$u])) {
continue;
}
$seen[$u] = true;
$out[] = $u;
}
return $out;
});
}
/**
* A short prefix of the author NIP-65 list (or default site relay) for queries that do not need every home relay.
*
* @return list<string>
*/
public function getTopReputableRelaysForAuthor(string $pubkey, int $limit = 3): array
{
$all = $this->getAuthorNip65RelaysList($pubkey);
if ($all === []) {
return [$this->relayListFactory->getDefaultRelayUrl()];
}
if ($limit < 1) {
$limit = 1;
}
return \array_slice($all, 0, $limit);
}
}

2124
src/Service/NostrClient.php

File diff suppressed because it is too large Load Diff

41
src/Service/NostrKeyHelper.php

@ -1,41 +0,0 @@ @@ -1,41 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Service;
use swentel\nostr\Key\Key;
/**
* Shared {@link Key} wrapper for npub/nsec/hex conversions. Prefer injecting this service instead of
* instantiating {@see Key} in controllers, commands, and services.
*/
final readonly class NostrKeyHelper
{
private Key $key;
public function __construct()
{
$this->key = new Key();
}
public function convertToHex(string $key): string
{
return $this->key->convertToHex($key);
}
public function convertPublicKeyToBech32(string $key): string
{
return $this->key->convertPublicKeyToBech32($key);
}
public function convertPrivateKeyToBech32(string $key): string
{
return $this->key->convertPrivateKeyToBech32($key);
}
public function generatePrivateKey(): string
{
return $this->key->generatePrivateKey();
}
}

54
src/Service/NostrKind5DeletionFilter.php

@ -1,54 +0,0 @@ @@ -1,54 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Enum\KindsEnum;
/**
* NIP-09 kind-5: keep only deletion events that target kinds persisted in MySQL (profile, relay list, payto,
* long-form, magazine). Skips thread/reply deletions to reduce relay payload.
*/
final class NostrKind5DeletionFilter
{
public function isRelevantToStoredDbData(object $ev): bool
{
$kinds = $this->storedKindValues();
foreach ($ev->tags ?? [] as $tag) {
if (!\is_array($tag) && !\is_object($tag)) {
continue;
}
$r = \is_object($tag) ? array_values((array) $tag) : $tag;
if (!isset($r[0], $r[1])) {
continue;
}
if ((string) $r[0] === 'k' && \in_array((int) $r[1], $kinds, true)) {
return true;
}
if ((string) $r[0] === 'a') {
$parts = explode(':', (string) $r[1], 3);
if (\in_array((int) $parts[0], $kinds, true)) {
return true;
}
}
}
return false;
}
/**
* @return list<int>
*/
private function storedKindValues(): array
{
return [
KindsEnum::METADATA->value,
KindsEnum::RELAY_LIST->value,
KindsEnum::PAYMENT_TARGETS->value,
KindsEnum::LONGFORM->value,
KindsEnum::LONGFORM_DRAFT->value,
KindsEnum::PUBLICATION_INDEX->value,
];
}
}

11
src/Service/NostrLinkParser.php

@ -2,7 +2,7 @@ @@ -2,7 +2,7 @@
namespace App\Service;
use App\Nostr\Nip19Codec;
use nostriphant\NIP19\Bech32;
use Psr\Log\LoggerInterface;
readonly class NostrLinkParser
@ -12,8 +12,7 @@ readonly class NostrLinkParser @@ -12,8 +12,7 @@ readonly class NostrLinkParser
private const URL_PATTERN = '/https?:\/\/[\w\-\.\?\,\'\/\\\+&%@\?\$#_=:\(\)~;]+/i';
public function __construct(
private LoggerInterface $logger,
private Nip19Codec $nip19,
private LoggerInterface $logger
) {}
/**
@ -84,7 +83,7 @@ readonly class NostrLinkParser @@ -84,7 +83,7 @@ readonly class NostrLinkParser
if (preg_match(self::NOSTR_LINK_PATTERN, $url, $nostrMatch)) {
$nostrId = $nostrMatch[1];
try {
$decoded = $this->nip19->decode($nostrId);
$decoded = new Bech32($nostrId);
$nostrType = $decoded->type;
$nostrData = $decoded->data;
} catch (\Exception $e) {
@ -151,7 +150,7 @@ readonly class NostrLinkParser @@ -151,7 +150,7 @@ readonly class NostrLinkParser
$position = $match[0][1];
// This check will be handled in parseLinks by sorting and merging
try {
$decoded = $this->nip19->decode($identifier);
$decoded = new Bech32($identifier);
$links[] = [
'type' => $decoded->type,
'identifier' => $identifier,
@ -180,7 +179,7 @@ readonly class NostrLinkParser @@ -180,7 +179,7 @@ readonly class NostrLinkParser
$position = $match[0][1];
$identifier = ltrim($raw, '@');
try {
$decoded = $this->nip19->decode($identifier);
$decoded = new Bech32($identifier);
if (!\in_array($decoded->type, ['naddr', 'nevent'], true)) {
continue;
}

116
src/Service/NostrLongformArticleStore.php

@ -1,116 +0,0 @@ @@ -1,116 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\Article;
use App\Enum\EventStatusEnum;
use App\Enum\KindsEnum;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\Persistence\ManagerRegistry;
use Psr\Log\LoggerInterface;
/**
* MySQL `article` row updates for NIP-23 / long-form ingest: find-by-(pubkey,slug), merge wire onto row,
* persist. Orchestrated by {@see NostrClient::saveEachArticleToTheDatabase()}.
*/
final class NostrLongformArticleStore
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly ManagerRegistry $managerRegistry,
private readonly LoggerInterface $logger,
private readonly NostrWireEventMerge $wireMerge,
) {
}
public function isEventIdAlreadyStored(string $eventId): bool
{
if ($eventId === '') {
return false;
}
return $this->entityManager->getRepository(Article::class)->findOneBy(['eventId' => $eventId]) !== null;
}
public function findLatestByAuthorAndSlug(string $pubkey, string $slug): ?Article
{
$pubkey = strtolower($pubkey);
/** @var ?Article $row */
$row = $this->entityManager->getRepository(Article::class)->createQueryBuilder('a')
->where('LOWER(a.pubkey) = :pk')
->andWhere('a.slug = :sl')
->setParameter('pk', $pubkey)
->setParameter('sl', $slug)
->orderBy('a.createdAt', 'DESC')
->setMaxResults(1)
->getQuery()
->getOneOrNullResult();
return $row;
}
/**
* Minimal Nostr event shape for {@see NostrWireEventMerge::wireEventSupersedes()} when `raw` is not a full wire object.
*/
public function longFormWireStubFromArticle(Article $a): object
{
$raw = $a->getRaw();
if (\is_object($raw) && isset($raw->id) && (isset($raw->created_at) || isset($raw->createdAt))) {
return $raw;
}
$o = new \stdClass();
$o->id = (string) ($a->getEventId() ?? '');
$ca = $a->getCreatedAt();
$o->created_at = $ca !== null ? $ca->getTimestamp() : 0;
$o->pubkey = (string) ($a->getPubkey() ?? '');
$k = $a->getKind();
$o->kind = $k !== null ? $k->value : KindsEnum::LONGFORM->value;
return $o;
}
public function applySourceOntoTarget(Article $source, Article $target): void
{
$target->setEventId((string) $source->getEventId());
$target->setContent($source->getContent());
$target->setTitle($source->getTitle());
$target->setSummary($source->getSummary());
$target->setImage($source->getImage());
if ($source->getCreatedAt() !== null) {
$target->setCreatedAt($source->getCreatedAt());
}
$target->setSig($source->getSig());
if ($source->getPublishedAt() !== null) {
$target->setPublishedAt($source->getPublishedAt());
}
$target->setTopics($source->getTopics());
if ($source->getKind() !== null) {
$target->setKind($source->getKind());
}
$es = $source->getEventStatus();
$target->setEventStatus($es ?? EventStatusEnum::PUBLISHED);
$target->setRaw($source->getRaw());
}
public function persistNew(Article $article, string $reason = 'unspecified'): void
{
try {
$this->logger->info('[longform_ingest] persistNewArticle', [
'reason' => $reason,
'eventId' => $article->getEventId(),
'slug' => $this->wireMerge->longformIngestShortSlug((string) ($article->getSlug() ?? '')),
]);
$this->entityManager->persist($article);
$this->entityManager->flush();
} catch (\Exception $e) {
$this->logger->error('[longform_ingest] persistNewArticle failed: '.$e->getMessage(), [
'reason' => $reason,
'eventId' => $article->getEventId(),
]);
$this->managerRegistry->resetManager();
}
}
}

36
src/Service/NostrNip65RelayUrls.php

@ -1,36 +0,0 @@ @@ -1,36 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Service;
/**
* NIP-65 kind-10002: collect `r` tag values as relay URLs (wss, excluding localhost). Used for author
* relay lists from wire and from {@see NostrClient::getNpubRelays()}.
*/
final class NostrNip65RelayUrls
{
/**
* @return list<string>
*/
public function wssListFromKind10002Wire(object $wire): array
{
$relays = [];
foreach ($wire->tags ?? [] as $tag) {
if (!\is_array($tag) && !\is_object($tag)) {
continue;
}
$r = \is_object($tag) ? array_values((array) $tag) : $tag;
if (!isset($r[0], $r[1])) {
continue;
}
if ((string) $r[0] === 'r') {
$relays[] = (string) $r[1];
}
}
return array_values(array_filter(array_unique($relays), static function (string $relay) {
return str_starts_with($relay, 'wss:') && !str_contains($relay, 'localhost');
}));
}
}

4
src/Service/NostrPathHelper.php

@ -5,6 +5,7 @@ declare(strict_types=1); @@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Service;
use App\Entity\Article;
use swentel\nostr\Key\Key;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
/**
@ -14,13 +15,12 @@ final class NostrPathHelper @@ -14,13 +15,12 @@ final class NostrPathHelper
{
public function __construct(
private readonly UrlGeneratorInterface $router,
private readonly NostrKeyHelper $nostrKeyHelper,
) {
}
public function npubFromPubkeyHex(string $pubkeyHex): string
{
return $this->nostrKeyHelper->convertPublicKeyToBech32($pubkeyHex);
return (new Key())->convertPublicKeyToBech32($pubkeyHex);
}
public function articlePath(Article $article): string

230
src/Service/NostrRelayFanoutTransport.php

@ -1,230 +0,0 @@ @@ -1,230 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Service;
use Psr\Log\LoggerInterface;
use swentel\nostr\Message\RequestMessage;
use swentel\nostr\Relay\RelaySet;
use Symfony\Component\Process\PhpExecutableFinder;
use Symfony\Component\Process\Process;
/**
* Multi-relay REQ fan-out: one in-process sequential {@see Request::send()} vs. one CLI worker per wss
* ({@see bin/nostr_relay_request_worker.php}). Used for article discussion and kind-9802 highlight fetches.
*/
final readonly class NostrRelayFanoutTransport
{
/** Extra wall time for {@see bin/nostr_relay_request_worker.php} vs. WebSocket timeout. */
private const DISCUSSION_WORKER_GRACE_SEC = 5.0;
/** Soft wall-time before stopping still-running parallel workers. */
private const DISCUSSION_PARALLEL_SOFT_DEADLINE_SEC = 3.5;
/**
* {@see Request::send()} visits relays sequentially; cap how many wss URLs we chain in one process
* so HTTP /fragment/comments do not hit long proxy timeouts.
*/
private const MAX_SEQUENTIAL_RELAY_URLS = 3;
public function __construct(
private LoggerInterface $logger,
private NostrRelayRequestFactory $relayRequestFactory,
private string $projectDir,
) {
}
/**
* @param list<string> $relayUrls
*
* @return list<string>
*/
public function capUrlsForSequential(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_slice($relayUrls, 0, self::MAX_SEQUENTIAL_RELAY_URLS);
}
/**
* One {@see Request} over all relays in the set (library visits each wss:// in series).
*
* @return array<string, mixed> Same shape as {@see Request::send()}
*/
public function sendSequential(RelaySet $relaySet, RequestMessage $requestMessage): array
{
$request = $this->relayRequestFactory->createTimedRequest($relaySet, $requestMessage);
return $request->send();
}
/**
* One short-lived CLI worker per relay URL (parallel WebSocket I/O).
*
* @param list<string> $relayUrls
*
* @return array<string, mixed> Same shape as {@see Request::send()}
*/
public function sendParallelWorkers(array $relayUrls, RequestMessage $requestMessage): array
{
$worker = $this->projectDir.'/bin/nostr_relay_request_worker.php';
$phpBinary = (new PhpExecutableFinder())->find() ?: 'php';
$timeout = $this->relayRequestFactory->getRelayRequestTimeoutSec() + (int) self::DISCUSSION_WORKER_GRACE_SEC;
$workerTimeoutEnv = ['NOSTR_RELAY_REQUEST_TIMEOUT' => (string) $this->relayRequestFactory->getRelayRequestTimeoutSec()];
$rawPayload = serialize($requestMessage);
$tmp = tempnam(sys_get_temp_dir(), 'nrq_');
if ($tmp === false) {
throw new \RuntimeException('tempnam failed for Nostr discussion payload');
}
try {
if (file_put_contents($tmp, $rawPayload) === false) {
throw new \RuntimeException('Could not write Nostr discussion temp payload');
}
/** @var array<string, Process> $procs */
$procs = [];
foreach ($relayUrls as $wss) {
if ($wss === '') {
continue;
}
$p = new Process(
[$phpBinary, $worker, $wss, $tmp],
$this->projectDir,
null,
null,
(float) $timeout
);
$p->start(null, $workerTimeoutEnv);
$procs[$wss] = $p;
}
$merged = [];
$pending = $procs;
$deadlineAt = microtime(true) + self::DISCUSSION_PARALLEL_SOFT_DEADLINE_SEC;
while ($pending !== []) {
foreach ($pending as $wss => $p) {
if ($p->isRunning()) {
continue;
}
unset($pending[$wss]);
if (!$p->isSuccessful()) {
$err = $p->getErrorOutput();
$this->logger->warning('nostr.article_discussion.relay_worker_failed', [
'relay' => $wss,
'exit_code' => $p->getExitCode(),
'stderr' => $err !== '' ? $err : null,
]);
continue;
}
$out = trim($p->getOutput());
if ($out === '') {
continue;
}
$decoded = base64_decode($out, true);
if ($decoded === false || $decoded === '') {
continue;
}
$chunk = unserialize($decoded, ['allowed_classes' => true]);
if (!\is_array($chunk)) {
continue;
}
$merged = array_replace($merged, $chunk);
}
if ($pending === []) {
break;
}
if (microtime(true) >= $deadlineAt) {
foreach ($pending as $wss => $p) {
$this->logger->warning('nostr.article_discussion.relay_worker_soft_timeout', [
'relay' => $wss,
'soft_deadline_sec' => self::DISCUSSION_PARALLEL_SOFT_DEADLINE_SEC,
]);
$p->stop(0.2);
}
break;
}
usleep(100_000);
}
return $merged;
} finally {
if (\is_file($tmp)) {
@unlink($tmp);
}
}
}
/**
* One line per relay after {@see Request::send()}: errors vs message-type counts (EVENT, EOSE, …).
*
* @param array<string, mixed> $response
*/
public function logWireResponseSummary(string $context, array $response): void
{
foreach ($response as $relayUrl => $relayRes) {
if ($relayRes instanceof \Throwable) {
$this->logger->warning(sprintf(
'nostr.wire.relay_throwable [%s]: %s',
NostrRelayQuery::relayLogLabel($relayUrl),
$relayRes->getMessage()
), [
'context' => $context,
'relay' => $relayUrl,
'message' => $relayRes->getMessage(),
'class' => \get_class($relayRes),
]);
continue;
}
if (!\is_iterable($relayRes)) {
$this->logger->warning(sprintf(
'nostr.wire.relay_not_iterable [%s]: %s',
NostrRelayQuery::relayLogLabel($relayUrl),
\get_debug_type($relayRes)
), [
'context' => $context,
'relay' => $relayUrl,
'php_type' => \get_debug_type($relayRes),
]);
continue;
}
$counts = [
'EVENT' => 0,
'EOSE' => 0,
'NOTICE' => 0,
'ERROR' => 0,
'AUTH' => 0,
'CLOSED' => 0,
'other' => 0,
];
foreach ($relayRes as $item) {
if (!\is_object($item)) {
++$counts['other'];
continue;
}
$t = (string) ($item->type ?? 'other');
if (\array_key_exists($t, $counts)) {
++$counts[$t];
} else {
++$counts['other'];
}
}
$this->logger->info(sprintf('nostr.wire.relay_messages [%s]', NostrRelayQuery::relayLogLabel($relayUrl)), [
'context' => $context,
'relay' => $relayUrl,
'counts' => $counts,
]);
}
}
}

317
src/Service/NostrRelayListFactory.php

@ -1,317 +0,0 @@ @@ -1,317 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\User;
use Psr\Log\LoggerInterface;
use swentel\nostr\Relay\Relay;
use swentel\nostr\Relay\RelaySet;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
/**
* Config-driven relay URL lists and {@link RelaySet} construction: default + article + profile URLs,
* profile fetch ordering, sequential cap for slow in-process {@see \swentel\nostr\Request\Request::send()},
* and Nostr Land → aggr.nostr.land for logged-in readers who list the former.
*/
final readonly class NostrRelayListFactory
{
/** 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';
/**
* Aggregated / subscription relay (not for anonymous visitors). Only added when the session user
* has {@see self::NOSTR_LAND} in their NIP-65-style relay list.
*/
private const AGGR_NOSTR_LAND = 'wss://aggr.nostr.land';
/**
* {@see \swentel\nostr\Request\Request::send()} hits relays sequentially; profile pages (metadata, long-form list, 10133) used
* the full default+article+profile list (~8–9 wss) → 2 slow relays can exceed PHP’s 30s default max_execution_time.
*/
private const MAX_PROFILE_SEQUENTIAL_RELAY_URLS = 3;
/**
* @param list<string> $articleRelayUrls
* @param list<string> $profileRelayUrls kind-0 / profile; merged for metadata (see {@see getProfileMetadataQueryRelayUrlList()})
*/
public function __construct(
private string $defaultRelayUrl,
private array $articleRelayUrls,
private array $profileRelayUrls,
private TokenStorageInterface $tokenStorage,
private LoggerInterface $logger,
) {
}
public function getDefaultRelayUrl(): string
{
return $this->defaultRelayUrl;
}
/**
* default_relay + article_relays from config, in order, deduplicated. Used for the static
* default set and as the base when merging author/extra relay URLs in {@see createRelaySetMergedWithArticleList()}.
*
* @return list<string>
*/
public function getConfiguredArticleRelayUrlList(): array
{
$seen = [];
$out = [];
if ($this->defaultRelayUrl !== '') {
$seen[$this->defaultRelayUrl] = true;
$out[] = $this->defaultRelayUrl;
}
foreach ($this->articleRelayUrls as $url) {
if ($url === '' || isset($seen[$url])) {
continue;
}
$seen[$url] = true;
$out[] = $url;
}
if ($out === []) {
$out[] = $this->defaultRelayUrl;
}
return $out;
}
public function getDefaultArticleRelaySet(): RelaySet
{
$relaySet = new RelaySet();
foreach ($this->getConfiguredArticleRelayUrlList() as $url) {
$relaySet->addRelay(new Relay($url));
}
return $relaySet;
}
/**
* Configured profile relays (kind-0 / NIP-05 hints) that are not already in the article relay list.
* Used as a second pass for magazine 30040 and category long-form ingest when article relays return nothing.
* Intentionally excludes merging article URLs again — {@see createRelaySetMergedWithArticleList()} prepends article relays.
*
* @return list<string>
*/
public function getProfileRelayUrlsExcludedFromArticleRelays(): array
{
$article = array_fill_keys($this->getConfiguredArticleRelayUrlList(), true);
$out = [];
foreach ($this->getProfileRelayUrlList() as $u) {
if (!isset($article[$u])) {
$out[] = $u;
}
}
return $out;
}
/**
* Relay set built only from the given URLs (no implicit article-relay merge).
*/
public function createRelaySetFromUrlsOnly(array $relayUrls): RelaySet
{
$relaySet = new RelaySet();
$seen = [];
foreach ($relayUrls as $relayUrl) {
if (!\is_string($relayUrl) || $relayUrl === '' || isset($seen[$relayUrl])) {
continue;
}
$seen[$relayUrl] = true;
$relaySet->addRelay(new Relay($relayUrl));
}
return $relaySet;
}
/**
* Merges all configured article relays (default + article_relays) with the given URLs in order, deduped.
* Used for comment threads, per-author fetches, etc.
*/
public function createRelaySetMergedWithArticleList(array $relayUrls): RelaySet
{
$relaySet = new RelaySet();
$seen = [];
foreach (array_merge($this->getConfiguredArticleRelayUrlList(), $relayUrls) as $relayUrl) {
if (!\is_string($relayUrl) || $relayUrl === '' || isset($seen[$relayUrl])) {
continue;
}
$seen[$relayUrl] = true;
$relaySet->addRelay(new Relay($relayUrl));
}
return $relaySet;
}
/**
* Suffix to segregate HTTP caches: aggr is only used for some logged-in readers, so results differ.
*
* @return string empty when aggr is not used, else a short token
*/
public function getNostrLandAggrReaderCacheSuffix(): string
{
return $this->loggedInUserHasNostrLandInRelayList() ? 'a1' : '';
}
public function loggedInUserHasNostrLandInRelayList(): bool
{
$token = $this->tokenStorage->getToken();
if ($token === null) {
return false;
}
$user = $token->getUser();
if (!$user instanceof User) {
return false;
}
return $this->userRelayListContainsNostrLand($user->getRelays());
}
/**
* @param list<array{0?: string, 1?: string, 2?: string}>|array<array-key, mixed>|null $relays
*/
private function userRelayListContainsNostrLand(?array $relays): bool
{
if ($relays === null || $relays === []) {
return false;
}
$target = $this->normalizeWssUrlForNostrLandMatch(self::NOSTR_LAND);
foreach ($relays as $row) {
if (!\is_array($row) || !isset($row[1]) || !\is_string($row[1])) {
continue;
}
if ($this->normalizeWssUrlForNostrLandMatch($row[1]) === $target) {
return true;
}
}
return false;
}
private function normalizeWssUrlForNostrLandMatch(string $url): string
{
return rtrim(trim($url), '/');
}
/**
* Appends wss://aggr.nostr.land when the current user listed wss://nostr.land (session).
*
* @param list<string> $urls
*
* @return list<string>
*/
public function withAggrNostrLandIfUserSubscribesNostrLand(array $urls): array
{
if (!$this->loggedInUserHasNostrLandInRelayList()) {
return $urls;
}
$seen = array_fill_keys($urls, true);
if (isset($seen[self::AGGR_NOSTR_LAND])) {
return $urls;
}
$this->logger->debug('nostr.relay.append_aggr_nostr_land', [
'user_has_nostr_land' => true,
]);
$out = $urls;
$out[] = self::AGGR_NOSTR_LAND;
return $out;
}
/**
* @param list<string> $urls
*/
public function relaySetFromDistinctUrlList(array $urls): RelaySet
{
$relaySet = new RelaySet();
$seen = [];
foreach ($urls as $relayUrl) {
if ($relayUrl === '' || isset($seen[$relayUrl])) {
continue;
}
$seen[$relayUrl] = true;
$relaySet->addRelay(new Relay($relayUrl));
}
return $relaySet;
}
/**
* @param list<string> $urls
*
* @return list<string>
*/
public function capSequentialRelaysForProfileFetches(array $urls): array
{
if (\count($urls) <= self::MAX_PROFILE_SEQUENTIAL_RELAY_URLS) {
return $urls;
}
$this->logger->notice('nostr.relay_list_capped', [
'context' => 'profile_sequential',
'max' => self::MAX_PROFILE_SEQUENTIAL_RELAY_URLS,
'had' => \count($urls),
]);
return \array_slice($urls, 0, self::MAX_PROFILE_SEQUENTIAL_RELAY_URLS);
}
/**
* @return list<string> Deduplicated profile relay URLs from config
*/
public function getProfileRelayUrlList(): array
{
$seen = [];
$out = [];
foreach ($this->profileRelayUrls as $url) {
if ($url === '' || isset($seen[$url])) {
continue;
}
if (!str_starts_with($url, 'wss:')) {
continue;
}
$seen[$url] = true;
$out[] = $url;
}
return $out;
}
/**
* Profile (kind-0) queries: {@see getProfileRelayUrlList()} first (Damus, nos.lol, …), then default + article set.
* Order matters: {@see \swentel\nostr\Request\Request::send()} walks relays sequentially.
*
* @return list<string>
*/
public function getProfileMetadataQueryRelayUrlList(): array
{
$seen = [];
$ordered = [];
foreach (array_merge($this->getProfileRelayUrlList(), $this->getConfiguredArticleRelayUrlList()) as $u) {
if ($u === '' || isset($seen[$u])) {
continue;
}
$seen[$u] = true;
$ordered[] = $u;
}
if ($ordered === []) {
$ordered[] = $this->defaultRelayUrl;
}
return $this->withAggrNostrLandIfUserSubscribesNostrLand($ordered);
}
/**
* Same relays for kind-0 metadata, without mutating the default article relay set from {@see NostrClient}.
*/
public function getRelaySetForProfileMetadataFetch(): RelaySet
{
$relaySet = new RelaySet();
foreach ($this->getProfileMetadataQueryRelayUrlList() as $url) {
$relaySet->addRelay(new Relay($url));
}
return $relaySet;
}
}

165
src/Service/NostrRelayQuery.php

@ -1,165 +0,0 @@ @@ -1,165 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Service;
use Psr\Log\LoggerInterface;
use swentel\nostr\Filter\Filter;
use swentel\nostr\Message\RequestMessage;
use swentel\nostr\Relay\RelaySet;
use swentel\nostr\Request\Request;
use swentel\nostr\Subscription\Subscription;
/**
* NIP-01 style REQ construction and per-relay response iteration (EVENT / ERROR / …).
* Extracted from {@see NostrClient} for reuse; logging stays on this service.
*/
final readonly class NostrRelayQuery
{
public function __construct(
private LoggerInterface $logger,
private NostrRelayRequestFactory $relayRequestFactory,
) {
}
/**
* Short host/URL for logs (e.g. fragment comments / prewarm) without full wss:// noise.
*/
public static function relayLogLabel(string $relayUrl): string
{
$host = parse_url($relayUrl, \PHP_URL_HOST);
if (\is_string($host) && $host !== '') {
return $host;
}
return $relayUrl;
}
/**
* @param list<int|\BackedEnum> $kinds Integers or PHP 8.1 enums backed by int (e.g. {@see \App\Enum\KindsEnum})
* @param array<string, mixed> $filters Filter builder keys (e.g. authors, ids, tag, limit, …)
*/
public function createNostrRequest(
RelaySet $defaultRelaySet,
?RelaySet $relaySet = null,
array $kinds = [],
array $filters = [],
): Request {
$subscription = new Subscription();
$subscriptionId = $subscription->setId();
$filter = new Filter();
$kindInts = [];
foreach ($kinds as $k) {
$kindInts[] = $k instanceof \BackedEnum ? (int) $k->value : (int) $k;
}
$filter->setKinds($kindInts);
foreach ($filters as $key => $value) {
$method = 'set' . ucfirst($key);
if (method_exists($filter, $method)) {
if ($key === 'tag') {
$filter->setTag($value[0], $value[1]);
} else {
$filter->$method($value);
}
}
}
$requestMessage = new RequestMessage($subscriptionId, [$filter]);
$set = $relaySet ?? $defaultRelaySet;
return $this->relayRequestFactory->createTimedRequest($set, $requestMessage);
}
/**
* @param array<string, mixed> $response Return value of {@see Request::send()}: relay URL → message list|Throwable
* @return list<mixed>
*/
public function processResponse(array $response, callable $eventHandler): array
{
$results = [];
foreach ($response as $relayUrl => $relayRes) {
if ($relayRes instanceof \Throwable) {
$this->logger->error(sprintf(
'Relay error at %s: %s',
self::relayLogLabel($relayUrl),
$relayRes->getMessage()
), [
'relay' => $relayUrl,
'error' => $relayRes->getMessage(),
]);
continue;
}
$itemEstimate = \is_countable($relayRes) ? \count($relayRes) : null;
$this->logger->debug(sprintf('Processing relay response from %s', self::relayLogLabel($relayUrl)), [
'relay' => $relayUrl,
'item_count' => $itemEstimate,
]);
foreach ($relayRes as $item) {
try {
if (!\is_object($item)) {
$this->logger->warning(sprintf(
'Invalid response item from %s',
self::relayLogLabel($relayUrl)
), [
'relay' => $relayUrl,
'item' => $item,
]);
continue;
}
switch ($item->type) {
case 'EVENT':
$this->logger->debug(sprintf('Processing event from %s', self::relayLogLabel($relayUrl)), [
'relay' => $relayUrl,
'event_id' => $item->event->id ?? 'unknown',
]);
$result = $eventHandler($item->event);
if ($result !== null) {
$results[] = $result;
}
break;
case 'AUTH':
$this->logger->warning(sprintf(
'Relay %s requires authentication',
self::relayLogLabel($relayUrl)
), [
'relay' => $relayUrl,
'response' => $item,
]);
break;
case 'ERROR':
case 'NOTICE':
$msg = (string) ($item->message ?? 'No message');
$this->logger->warning(sprintf(
'[%s] %s: %s',
self::relayLogLabel($relayUrl),
$item->type,
$msg
), [
'relay' => $relayUrl,
'type' => $item->type,
'message' => $msg,
]);
break;
}
} catch (\Exception $e) {
$this->logger->error(sprintf(
'Error processing event from relay %s: %s',
self::relayLogLabel($relayUrl),
$e->getMessage()
), [
'relay' => $relayUrl,
'error' => $e->getMessage(),
]);
continue;
}
}
}
return $results;
}
}

49
src/Service/NostrRelayRequestFactory.php

@ -1,49 +0,0 @@ @@ -1,49 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Service;
use swentel\nostr\Message\RequestMessage;
use swentel\nostr\Relay\RelaySet;
use swentel\nostr\Request\Request;
/**
* Builds swentel {@see Request} instances with per-relay I/O timeout (config: `nostr_relay_request_timeout_sec`).
* Shared by {@see NostrClient} so request wiring stays in one place.
*/
final readonly class NostrRelayRequestFactory
{
public function __construct(
private int $relayRequestTimeoutSec = 12,
) {
}
public function getRelayRequestTimeoutSec(): int
{
return $this->relayRequestTimeoutSec;
}
/**
* {@see Request::setTimeout()} drives per-relay WebSocket I/O for {@see Request::send()}.
*/
public function createTimedRequest(RelaySet $relaySet, RequestMessage $requestMessage): Request
{
$request = new Request($relaySet, $requestMessage);
return $request->setTimeout($this->relayRequestTimeoutSec);
}
/**
* For paths that use {@see RelaySet::send()} with a custom message and bypass {@see Request}.
*/
public function applySocketTimeoutToRelaySet(RelaySet $relaySet): void
{
foreach ($relaySet->getRelays() as $relay) {
$client = $relay->getClient();
if (method_exists($client, 'setTimeout')) {
$client->setTimeout($this->relayRequestTimeoutSec);
}
}
}
}

65
src/Service/NostrShareMenuBuilder.php

@ -8,8 +8,9 @@ use App\Dto\NostrShareMenuContext; @@ -8,8 +8,9 @@ use App\Dto\NostrShareMenuContext;
use App\Entity\Article;
use App\Entity\Event;
use App\Nostr\Nip19Addressable;
use App\Nostr\Nip19Codec;
use App\Repository\ArticleRepository;
use nostriphant\NIP19\Bech32;
use swentel\nostr\Key\Key;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Request;
@ -33,14 +34,20 @@ final class NostrShareMenuBuilder @@ -33,14 +34,20 @@ final class NostrShareMenuBuilder
if (64 !== \strlen($pubkeyHex) || !ctype_xdigit($pubkeyHex)) {
return null;
}
$npub = $this->nostrKeyHelper->convertPublicKeyToBech32($pubkeyHex);
$key = new Key();
$npub = $key->convertPublicKeyToBech32($pubkeyHex);
$kind = (int) ($event->kind ?? 0);
$d = self::dTagFromWireEvent($event);
$eventIdHex = strtolower((string) ($event->id ?? ''));
if (Nip19Addressable::isParameterizedReplaceableKind($kind) && $d !== null) {
$naddr = Nip19Addressable::naddrBech32($kind, $pubkeyHex, $d, $relayHints);
$neventForRev = (64 === \strlen($eventIdHex) && ctype_xdigit($eventIdHex))
? $this->nip19->encodeNevent($eventIdHex, $relayHints, $pubkeyHex, $kind)
? (string) Bech32::nevent(
id: $eventIdHex,
relays: $relayHints,
author: $pubkeyHex,
kind: $kind,
)
: null;
return new NostrShareMenuContext(
@ -51,7 +58,12 @@ final class NostrShareMenuBuilder @@ -51,7 +58,12 @@ final class NostrShareMenuBuilder
);
}
if (64 === \strlen($eventIdHex) && ctype_xdigit($eventIdHex)) {
$rebuilt = $this->nip19->encodeNevent($eventIdHex, $relayHints, $pubkeyHex, $kind);
$rebuilt = (string) Bech32::nevent(
id: $eventIdHex,
relays: $relayHints,
author: $pubkeyHex,
kind: $kind,
);
return new NostrShareMenuContext(
$npub,
@ -126,8 +138,6 @@ final class NostrShareMenuBuilder @@ -126,8 +138,6 @@ final class NostrShareMenuBuilder
public function __construct(
private readonly MagazineIndexStore $magazineIndexStore,
private readonly ArticleRepository $articleRepository,
private readonly NostrKeyHelper $nostrKeyHelper,
private readonly Nip19Codec $nip19,
#[Autowire('%npub%')]
private readonly string $siteNpub,
#[Autowire('%d_tag%')]
@ -139,6 +149,11 @@ final class NostrShareMenuBuilder @@ -139,6 +149,11 @@ final class NostrShareMenuBuilder
) {
}
private function nostrKey(): Key
{
return new Key();
}
/**
* Context for the header Nostr menu. Always returns a context on real HTTP requests (never null).
* Templates that do not include the header never call this; no need to suppress on XHR / fragments.
@ -174,7 +189,7 @@ final class NostrShareMenuBuilder @@ -174,7 +189,7 @@ final class NostrShareMenuBuilder
if ($article === null) {
return $this->siteWithRootMenu();
}
if ($this->nostrKeyHelper->convertToHex($npub) !== strtolower((string) $article->getPubkey())) {
if ($this->nostrKey()->convertToHex($npub) !== strtolower((string) $article->getPubkey())) {
return $this->siteWithRootMenu();
}
@ -183,7 +198,7 @@ final class NostrShareMenuBuilder @@ -183,7 +198,7 @@ final class NostrShareMenuBuilder
private function fromArticle(Article $article): NostrShareMenuContext
{
$npub = $this->nostrKeyHelper->convertPublicKeyToBech32((string) $article->getPubkey());
$npub = $this->nostrKey()->convertPublicKeyToBech32((string) $article->getPubkey());
$kind = (int) ($article->getKind()?->value ?? 30023);
$d = (string) ($article->getSlug() ?? '');
if ($d === '') {
@ -198,7 +213,12 @@ final class NostrShareMenuBuilder @@ -198,7 +213,12 @@ final class NostrShareMenuBuilder
$naddr = Nip19Addressable::naddrBech32($kind, $pk, $d, []);
$eid = strtolower((string) ($article->getEventId() ?? ''));
$nevent = (64 === \strlen($eid) && ctype_xdigit($eid))
? $this->nip19->encodeNevent($eid, [], $pk, $kind)
? (string) Bech32::nevent(
id: $eid,
relays: [],
author: $pk,
kind: $kind,
)
: null;
return new NostrShareMenuContext(
@ -250,7 +270,7 @@ final class NostrShareMenuBuilder @@ -250,7 +270,7 @@ final class NostrShareMenuBuilder
return $this->siteWithRootMenu();
}
try {
$decoded = $this->nip19->decode($nevent);
$decoded = new Bech32($nevent);
} catch (\Throwable) {
return $this->siteWithRootMenu();
}
@ -271,10 +291,15 @@ final class NostrShareMenuBuilder @@ -271,10 +291,15 @@ final class NostrShareMenuBuilder
$relays = $decoded->data->relays ?? [];
$relays = \is_array($relays) ? $relays : [];
if ($authorHex !== null) {
$rebuilt = $this->nip19->encodeNevent($eventId, $relays, $authorHex, $kind);
$rebuilt = (string) Bech32::nevent(
id: $eventId,
relays: $relays,
author: $authorHex,
kind: $kind,
);
return new NostrShareMenuContext(
$this->nostrKeyHelper->convertPublicKeyToBech32($authorHex),
$this->nostrKey()->convertPublicKeyToBech32($authorHex),
$rebuilt,
null,
$this->feedJumble($rebuilt),
@ -314,10 +339,15 @@ final class NostrShareMenuBuilder @@ -314,10 +339,15 @@ final class NostrShareMenuBuilder
}
$kind = (int) $e->getKind();
$d = Nip19Addressable::dTagFromEventEntity($e);
$npub = $this->nostrKeyHelper->convertPublicKeyToBech32($pk);
$npub = $this->nostrKey()->convertPublicKeyToBech32($pk);
if (Nip19Addressable::isParameterizedReplaceableKind($kind) && $d !== null) {
$naddr = Nip19Addressable::naddrBech32($kind, $pk, $d, []);
$neventForRev = $this->nip19->encodeNevent($id, [], $pk, $kind);
$neventForRev = (string) Bech32::nevent(
id: $id,
relays: [],
author: $pk,
kind: $kind,
);
return new NostrShareMenuContext(
$npub,
@ -326,7 +356,12 @@ final class NostrShareMenuBuilder @@ -326,7 +356,12 @@ final class NostrShareMenuBuilder
$this->feedJumble($naddr),
);
}
$nevent = $this->nip19->encodeNevent($id, [], $pk, $kind);
$nevent = (string) Bech32::nevent(
id: $id,
relays: [],
author: $pk,
kind: $kind,
);
return new NostrShareMenuContext(
$npub,

450
src/Service/NostrWireEventMerge.php

@ -1,450 +0,0 @@ @@ -1,450 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\Event as PublicationEventEntity;
use App\Enum\KindsEnum;
/**
* NIP-33 / NIP-01 wire event merge, #d tags, npub→hex. See {@see NostrClient} call sites.
*/
final readonly class NostrWireEventMerge
{
private const NIP33_PARAMETERIZED_KIND_MIN = 30_000;
private const NIP33_PARAMETERIZED_KIND_MAX = 39_999;
public function __construct(
private NostrKeyHelper $keyHelper,
) {
}
public function isReplaceableByKindAndPubkeyNip(int $kind): bool
{
return $kind === 0
|| $kind === 3
|| ($kind >= 10_000 && $kind < 20_000);
}
public function isNip33ParameterizedKind(int $kind): bool
{
return $kind >= self::NIP33_PARAMETERIZED_KIND_MIN
&& $kind <= self::NIP33_PARAMETERIZED_KIND_MAX;
}
private function replaceableKindPubkeyAddressFromWire(mixed $e): ?string
{
if (!\is_object($e)) {
return null;
}
$k = (int) ($e->kind ?? 0);
if (!$this->isReplaceableByKindAndPubkeyNip($k)) {
return null;
}
$pk = (string) ($e->pubkey ?? '');
if (64 !== \strlen($pk) || !ctype_xdigit($pk)) {
return null;
}
return (string) $k.':'.strtolower($pk);
}
private function isValidNostrEventIdString(string $id): bool
{
return 64 === \strlen($id) && ctype_xdigit($id);
}
public function wireEventSupersedes(mixed $candidate, mixed $incumbent): bool
{
$c = $this->magazineEventCreatedAt($candidate);
$i = $this->magazineEventCreatedAt($incumbent);
if ($c !== $i) {
return $c > $i;
}
$idC = $this->magazineEventId($candidate);
$idI = $this->magazineEventId($incumbent);
$vC = $this->isValidNostrEventIdString($idC);
$vI = $this->isValidNostrEventIdString($idI);
if ($vC !== $vI) {
return $vC && !$vI;
}
if (!$vC) {
if ($idC === $idI) {
return false;
}
return $idC < $idI;
}
if ($idC === $idI) {
return false;
}
return $idC < $idI;
}
private function kind0Nip01ReplaceableAddress(mixed $ev): ?string
{
if (!\is_object($ev) || (int) ($ev->kind ?? -1) !== KindsEnum::METADATA->value) {
return null;
}
$pk = (string) ($ev->pubkey ?? '');
if (64 !== \strlen($pk) || !ctype_xdigit($pk)) {
return null;
}
return '0:'.strtolower($pk);
}
private function kind0ReplaceableIsNewer(mixed $candidate, mixed $incumbent): bool
{
return $this->wireEventSupersedes($candidate, $incumbent);
}
/**
* @param list<mixed> $events
*
* @return array<string, object>
*/
public function mergeKind0EventsByReplaceableAddress(array $events): array
{
$byAddress = [];
foreach ($events as $ev) {
$addr = $this->kind0Nip01ReplaceableAddress($ev);
if ($addr === null) {
continue;
}
if (!isset($byAddress[$addr]) || $this->kind0ReplaceableIsNewer($ev, $byAddress[$addr])) {
$byAddress[$addr] = $ev;
}
}
return $byAddress;
}
public function nip33ParameterizedReplaceableAddress(mixed $event): ?string
{
$k = $this->magazineEventKind($event);
if (!$this->isNip33ParameterizedKind($k)) {
return null;
}
$pk = $this->magazineEventPubkeyHex($event);
if ($pk === '' || 64 !== \strlen($pk) || !ctype_xdigit($pk)) {
return null;
}
$d = $this->eventDTagValue($event);
if ($d === null || $d === '') {
return null;
}
return (string) $k.':'.strtolower($pk).':'.$d;
}
/**
* @param list<mixed> $events
*
* @return list<object>
*/
public function mergeNip33ParameterizedWireEvents(array $events): array
{
$byNip33Address = [];
$byKindPubkey = [];
$byId = [];
foreach ($events as $e) {
if (!\is_object($e)) {
continue;
}
$k = (int) ($e->kind ?? 0);
if ($this->isNip33ParameterizedKind($k)) {
$a = $this->nip33ParameterizedReplaceableAddress($e);
if ($a === null) {
continue;
}
if (!isset($byNip33Address[$a]) || $this->wireEventSupersedes($e, $byNip33Address[$a])) {
$byNip33Address[$a] = $e;
}
} elseif ($this->isReplaceableByKindAndPubkeyNip($k)) {
$a = $this->replaceableKindPubkeyAddressFromWire($e);
if ($a === null) {
continue;
}
if (!isset($byKindPubkey[$a]) || $this->wireEventSupersedes($e, $byKindPubkey[$a])) {
$byKindPubkey[$a] = $e;
}
} else {
$id = (string) ($e->id ?? '');
if ($id === '') {
continue;
}
if (!isset($byId[$id]) || $this->wireEventSupersedes($e, $byId[$id])) {
$byId[$id] = $e;
}
}
}
return array_values(array_merge($byId, $byKindPubkey, $byNip33Address));
}
/**
* @param list<mixed> $events
*/
public function pickLatestNip33ParameterizedForQuery(
array $events,
int $expectedKind,
string $authorHexLower,
string $dTag
): mixed {
if (!$this->isNip33ParameterizedKind($expectedKind)) {
return null;
}
$wantD = trim($dTag);
$expectedAddr = (string) $expectedKind.':'.$authorHexLower.':'.$wantD;
$merged = $this->mergeNip33ParameterizedWireEvents($events);
foreach ($merged as $e) {
if ($this->magazineEventKind($e) !== $expectedKind) {
continue;
}
if (strtolower($this->magazineEventPubkeyHex($e)) !== $authorHexLower) {
continue;
}
$addr = $this->nip33ParameterizedReplaceableAddress($e);
if ($addr === $expectedAddr) {
return $e;
}
}
return null;
}
/**
* @param list<mixed> $events
*/
public function pickEventForNip33OrFirst(array $events, int $kind, string $authorIdent, string $dTag): ?object
{
if ($events === []) {
return null;
}
if ($this->isNip33ParameterizedKind($kind)) {
$h = $this->authorIdentToHexLower($authorIdent);
if ($h !== null) {
$picked = $this->pickLatestNip33ParameterizedForQuery($events, $kind, $h, $dTag);
if ($picked !== null && \is_object($picked)) {
return $picked;
}
}
$merged = $this->mergeNip33ParameterizedWireEvents($events);
$first = $merged[0] ?? null;
return \is_object($first) ? $first : null;
}
if ($this->isReplaceableByKindAndPubkeyNip($kind)) {
$h = $this->authorIdentToHexLower($authorIdent);
if ($h !== null) {
$best = null;
foreach ($events as $e) {
if (!\is_object($e) || (int) ($e->kind ?? 0) !== $kind) {
continue;
}
if (strtolower((string) ($e->pubkey ?? '')) !== $h) {
continue;
}
if ($best === null || $this->wireEventSupersedes($e, $best)) {
$best = $e;
}
}
if ($best !== null) {
return $best;
}
}
foreach ($this->mergeNip33ParameterizedWireEvents($events) as $e) {
if ((int) ($e->kind ?? 0) === $kind) {
return $e;
}
}
return null;
}
$e0 = $events[0] ?? null;
return \is_object($e0) ? $e0 : null;
}
public function authorIdentToHexLower(mixed $ident): ?string
{
return $this->npubToHexPubkey($ident);
}
public function npubToHexPubkey(mixed $npub): ?string
{
$s = trim((string) $npub);
if ($s === '') {
return null;
}
if (64 === \strlen($s) && ctype_xdigit($s)) {
return strtolower($s);
}
if (str_starts_with($s, 'npub')) {
$hex = $this->keyHelper->convertToHex($s);
return $hex !== '' && 64 === \strlen($hex) && ctype_xdigit($hex) ? strtolower($hex) : null;
}
return null;
}
public function eventDTagValue(mixed $event): ?string
{
$tags = null;
if ($event instanceof PublicationEventEntity) {
$tags = $event->getTags();
} elseif (\is_object($event) && isset($event->tags) && \is_array($event->tags)) {
$tags = $event->tags;
}
if (!\is_array($tags)) {
return null;
}
foreach ($tags as $t) {
$seq = $this->normalizeNostrTagRowToSequence($t);
if ($seq === null || ($seq[0] ?? '') !== 'd' || !isset($seq[1]) || (string) $seq[1] === '') {
continue;
}
return trim((string) $seq[1]);
}
return null;
}
/**
* @return list<string>|null
*/
private function normalizeNostrTagRowToSequence(mixed $row): ?array
{
if ($row === null) {
return null;
}
if (\is_object($row)) {
$row = get_object_vars($row);
}
if (!\is_array($row) || $row === []) {
return null;
}
$seq = array_values(
array_map(
static fn (mixed $v): string => (string) $v,
$row
)
);
if ($seq[0] === '') {
return null;
}
return $seq;
}
public function longformIngestShortSlug(string $slug, int $max = 100): string
{
$t = trim($slug);
if (strlen($t) > $max) {
return substr($t, 0, $max - 1).'…';
}
return $t;
}
/**
* @return array{kind: int, id: string, created_at: int, d: string, nip33: ?string}
*/
public function longformIngestEventWireSummary(object $e): array
{
$d = $this->eventDTagValue($e);
$nip = $this->nip33ParameterizedReplaceableAddress($e);
return [
'kind' => (int) ($e->kind ?? 0),
'id' => (string) ($e->id ?? ''),
'created_at' => (int) ($e->created_at ?? 0),
'd' => $d !== null && $d !== '' ? $this->longformIngestShortSlug($d, 80) : '',
'nip33' => $nip,
];
}
public function magazineEventToPublicationEntity(mixed $raw): ?PublicationEventEntity
{
if ($raw instanceof PublicationEventEntity) {
return $raw;
}
if (!\is_object($raw)) {
return null;
}
try {
$data = json_decode(json_encode($raw, \JSON_THROW_ON_ERROR), true, 512, \JSON_THROW_ON_ERROR);
} catch (\JsonException) {
return null;
}
if (!\is_array($data)) {
return null;
}
$entity = new PublicationEventEntity();
$entity->setId((string) ($data['id'] ?? ''));
$entity->setKind((int) ($data['kind'] ?? 0));
$entity->setPubkey((string) ($data['pubkey'] ?? ''));
$entity->setContent((string) ($data['content'] ?? ''));
$entity->setCreatedAt((int) ($data['created_at'] ?? 0));
$tags = $data['tags'] ?? [];
$entity->setTags(\is_array($tags) ? $tags : []);
$entity->setSig((string) ($data['sig'] ?? ''));
return $entity;
}
public function magazineEventCreatedAt(mixed $event): int
{
if ($event instanceof PublicationEventEntity) {
return $event->getCreatedAt();
}
if (\is_object($event) && isset($event->created_at)) {
return (int) $event->created_at;
}
return 0;
}
private function magazineEventId(mixed $event): string
{
if ($event instanceof PublicationEventEntity) {
return $event->getId();
}
if (\is_object($event) && isset($event->id)) {
return (string) $event->id;
}
return '';
}
private function magazineEventKind(mixed $event): int
{
if ($event instanceof PublicationEventEntity) {
return $event->getKind();
}
if (\is_object($event) && isset($event->kind)) {
return (int) $event->kind;
}
return 0;
}
private function magazineEventPubkeyHex(mixed $event): string
{
if ($event instanceof PublicationEventEntity) {
return (string) $event->getPubkey();
}
if (\is_object($event) && isset($event->pubkey)) {
return (string) $event->pubkey;
}
return '';
}
}

105
src/Service/TopicIndexService.php

@ -1,105 +0,0 @@ @@ -1,105 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Enum\EventStatusEnum;
use App\Repository\ArticleRepository;
use Doctrine\DBAL\ArrayParameterType;
/**
* Top topics for the sidebar and topic browse pages.
*/
final class TopicIndexService
{
public function __construct(
private readonly ArticleRepository $articleRepository,
private readonly MagazineContentService $magazineContent,
) {
}
/**
* Up to 25 most relevant topic strings, scored by count + 5× featured (magazine home cards).
*
* @return list<string> topic labels (lowercase, no #)
*/
public function getTopTopicLabels(int $limit = 25): array
{
$conn = $this->articleRepository->getEntityManager()->getConnection();
$slugs = $this->magazineContent->collectFeaturedArticleSlugsForHome(
$this->magazineContent->getHomeCategoryAIndexTagsFromStoreOnly(),
);
$featured = [];
foreach ($slugs as $s) {
$s = \strtolower(\trim($s));
if ($s !== '') {
$featured[$s] = true;
}
}
$rows = $conn->fetchAllAssociative(
'SELECT a.slug, a.topics FROM article a
WHERE a.topics IS NOT NULL
AND a.content IS NOT NULL
AND CHAR_LENGTH(a.content) > 250
AND a.event_status IN (:st)',
[
'st' => [EventStatusEnum::PUBLISHED->value, EventStatusEnum::ARCHIVED->value],
],
[
'st' => ArrayParameterType::INTEGER,
],
);
$acc = [];
foreach ($rows as $row) {
$raw = $row['topics'] ?? null;
if (\is_array($raw)) {
$dec = $raw;
} elseif (\is_string($raw) && $raw !== '') {
$dec = json_decode($raw, true);
} else {
continue;
}
if (!\is_array($dec)) {
continue;
}
$slug = \strtolower(\trim((string) ($row['slug'] ?? '')));
$isFeat = $slug !== '' && isset($featured[$slug]);
foreach ($dec as $t) {
if (!\is_string($t) || $t === '') {
continue;
}
$k = \str_replace('#', '', \strtolower(\trim($t)));
if ($k === '') {
continue;
}
if (!isset($acc[$k])) {
$acc[$k] = ['c' => 0, 'f' => 0];
}
++$acc[$k]['c'];
if ($isFeat) {
++$acc[$k]['f'];
}
}
}
uasort(
$acc,
static function (array $a, array $b): int {
$sa = $a['c'] + 5 * $a['f'];
$sb = $b['c'] + 5 * $b['f'];
if ($sa === $sb) {
return $b['c'] <=> $a['c'];
}
return $sb <=> $sa;
}
);
$keys = array_keys($acc);
return \array_slice($keys, 0, max(0, $limit));
}
}

96
src/Twig/ArticleCardCoverExtension.php

@ -1,96 +0,0 @@ @@ -1,96 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Twig;
use App\Service\CacheService;
use App\Service\NostrPathHelper;
use Symfony\Component\Asset\Packages;
use Throwable;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
/**
* Resolves a card hero image: article image, Nostr kind-0 profile {@see picture}, then site default image.
*/
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.
* Same asset as the header mark so empty hero slots read as the site, not a blank gray field.
*/
private const DEFAULT_PACKAGE_IMAGE = 'icons/favicon-96x96.png';
/**
* @var array<string, string> lowercase 64-hex pubkey → resolved cover URL (author picture or site default)
*/
private array $authorCoverMemo = [];
public function __construct(
private readonly CacheService $cacheService,
private readonly NostrPathHelper $nostrPathHelper,
private readonly Packages $packages,
) {
}
public function getFunctions(): array
{
return [
new TwigFunction('article_card_cover', $this->articleCardCover(...)),
];
}
/**
* @param string|null $articleImage Cover URL stored on the article, if any
* @param string|null $pubkeyHex 64-char hex (lowercase) Nostr public key, if any
*/
public function articleCardCover(?string $articleImage, ?string $pubkeyHex): string
{
if ($articleImage !== null && trim($articleImage) !== '') {
return trim($articleImage);
}
$pubkeyHex = $pubkeyHex !== null ? strtolower(trim($pubkeyHex)) : '';
if (64 !== strlen($pubkeyHex) || !ctype_xdigit($pubkeyHex)) {
return $this->defaultSiteImageUrl();
}
if (\array_key_exists($pubkeyHex, $this->authorCoverMemo)) {
return $this->authorCoverMemo[$pubkeyHex];
}
try {
$npub = $this->nostrPathHelper->npubFromPubkeyHex($pubkeyHex);
if ($npub === '') {
$url = $this->defaultSiteImageUrl();
$this->authorCoverMemo[$pubkeyHex] = $url;
return $url;
}
$meta = $this->cacheService->getMetadata($npub);
$pic = isset($meta->picture) ? trim((string) $meta->picture) : '';
if ($pic !== '') {
$this->authorCoverMemo[$pubkeyHex] = $pic;
return $pic;
}
} catch (Throwable) {
$out = $this->defaultSiteImageUrl();
$this->authorCoverMemo[$pubkeyHex] = $out;
return $out;
}
$out = $this->defaultSiteImageUrl();
$this->authorCoverMemo[$pubkeyHex] = $out;
return $out;
}
private function defaultSiteImageUrl(): string
{
return $this->packages->getUrl(self::DEFAULT_PACKAGE_IMAGE);
}
}

1
src/Twig/Components/IndexTabs.php

@ -36,6 +36,7 @@ class IndexTabs @@ -36,6 +36,7 @@ class IndexTabs
public function mount(EventEntity $index): void
{
$this->index = $index;
// TODO extract categories from index and feed into tabs
foreach ($index->getTags() as $tag) {
if (array_key_first($tag) === 'a') {
$ref = $tag[1];

13
src/Twig/Components/Molecules/UserFromNpub.php

@ -3,8 +3,8 @@ @@ -3,8 +3,8 @@
namespace App\Twig\Components\Molecules;
use App\Service\CacheService;
use App\Service\NostrKeyHelper;
use App\Util\PubkeyAvatarSvg;
use swentel\nostr\Key\Key;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[AsTwigComponent]
@ -18,20 +18,19 @@ final class UserFromNpub @@ -18,20 +18,19 @@ final class UserFromNpub
public string $fallbackSvg = '';
public function __construct(
private readonly CacheService $cacheService,
private readonly NostrKeyHelper $nostrKeyHelper,
) {
public function __construct(private readonly CacheService $cacheService)
{
}
public function mount(string $ident): void
{
$keys = new Key();
if (!str_starts_with($ident, 'npub')) {
$this->pubkey = $ident;
$this->npub = $this->nostrKeyHelper->convertPublicKeyToBech32($ident);
$this->npub = $keys->convertPublicKeyToBech32($ident);
} else {
$this->npub = $ident;
$this->pubkey = $this->nostrKeyHelper->convertToHex($ident);
$this->pubkey = $keys->convertToHex($ident);
}
$this->user = $this->cacheService->getMetadata($this->npub);

4
src/Twig/Components/Organisms/FeaturedList.php

@ -94,8 +94,8 @@ final class FeaturedList @@ -94,8 +94,8 @@ final class FeaturedList
private static function isNewer(FeaturedArticleCard $a, FeaturedArticleCard $b): bool
{
$ca = $a->getDisplayAt();
$cb = $b->getDisplayAt();
$ca = $a->getCreatedAt();
$cb = $b->getCreatedAt();
if ($ca === null) {
return false;
}

127
src/Twig/Components/SearchComponent.php

@ -0,0 +1,127 @@ @@ -0,0 +1,127 @@
<?php
namespace App\Twig\Components;
use App\Repository\ArticleRepository;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveAction;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\DefaultActionTrait;
use Symfony\Contracts\Cache\CacheInterface;
#[AsLiveComponent]
final class SearchComponent
{
use DefaultActionTrait;
#[LiveProp(writable: true, useSerializerForHydration: true)]
public string $query = '';
public array $results = [];
public bool $interactive = true;
#[LiveProp]
public int $vol = 0;
#[LiveProp(writable: true)]
public int $page = 1;
#[LiveProp]
public int $resultsPerPage = 12;
private const SESSION_KEY = 'last_search_results';
private const SESSION_QUERY_KEY = 'last_search_query';
public function __construct(
private readonly ArticleRepository $articleRepository,
private readonly TokenStorageInterface $tokenStorage,
private readonly LoggerInterface $logger,
private readonly CacheInterface $cache,
private readonly RequestStack $requestStack
)
{
}
public function mount(): void
{
// Restore search results from session if available and no query provided
if (empty($this->query)) {
$session = $this->requestStack->getSession();
if ($session->has(self::SESSION_QUERY_KEY)) {
$this->query = $session->get(self::SESSION_QUERY_KEY);
$this->results = $session->get(self::SESSION_KEY, []);
$this->logger->info('Restored search results from session for query: ' . $this->query);
}
}
}
#[LiveAction]
public function search(): void
{
$this->logger->info("Query: {$this->query}");
if (empty($this->query)) {
$this->results = [];
$this->clearSearchCache();
return;
}
// Check if the same query exists in session
$session = $this->requestStack->getSession();
if ($session->has(self::SESSION_QUERY_KEY) &&
$session->get(self::SESSION_QUERY_KEY) === $this->query) {
$this->results = $session->get(self::SESSION_KEY, []);
$this->logger->info('Using cached search results for query: ' . $this->query);
return;
}
try {
$this->results = [];
// Use database-based search instead of Elasticsearch
$offset = ($this->page - 1) * $this->resultsPerPage;
$results = $this->articleRepository->searchArticles(
$this->query,
$this->resultsPerPage,
$offset
);
$this->logger->info('Search results count: ' . count($results));
$this->logger->info('Search results: ', ['results' => $results]);
$this->results = $results;
// Cache the search results in session
$this->saveSearchToSession($this->query, $this->results);
} catch (\Exception $e) {
$this->logger->error('Search error: ' . $e->getMessage());
$this->results = [];
}
}
/**
* Save search results to session
*/
private function saveSearchToSession(string $query, array $results): void
{
$session = $this->requestStack->getSession();
$session->set(self::SESSION_QUERY_KEY, $query);
$session->set(self::SESSION_KEY, $results);
$this->logger->info('Saved search results to session for query: ' . $query);
}
/**
* Clear search cache from session
*/
private function clearSearchCache(): void
{
$session = $this->requestStack->getSession();
$session->remove(self::SESSION_QUERY_KEY);
$session->remove(self::SESSION_KEY);
$this->logger->info('Cleared search cache from session');
}
}

6
src/Twig/MagazineJumbleExtension.php

@ -6,7 +6,7 @@ namespace App\Twig; @@ -6,7 +6,7 @@ namespace App\Twig;
use App\Enum\KindsEnum;
use App\Nostr\Nip19Addressable;
use App\Service\NostrKeyHelper;
use swentel\nostr\Key\Key;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
@ -23,7 +23,6 @@ final class MagazineJumbleExtension extends AbstractExtension @@ -23,7 +23,6 @@ final class MagazineJumbleExtension extends AbstractExtension
private readonly string $rootMagazineDTag,
#[Autowire('%jumble_feed_notes_base%')]
private readonly string $jumbleFeedNotesBase,
private readonly NostrKeyHelper $nostrKeyHelper,
) {
}
@ -36,8 +35,9 @@ final class MagazineJumbleExtension extends AbstractExtension @@ -36,8 +35,9 @@ final class MagazineJumbleExtension extends AbstractExtension
public function magazineOnJumbleUrl(): string
{
$key = new Key();
try {
$pubkeyHex = $this->nostrKeyHelper->convertToHex($this->siteNpub);
$pubkeyHex = $key->convertToHex($this->siteNpub);
} catch (\Throwable) {
return '#';
}

27
src/Twig/SidebarFeaturedAuthorsExtension.php

@ -1,27 +0,0 @@ @@ -1,27 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Twig;
use App\Service\FeaturedAuthorListedRows;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
/** Twig function for the left nav featured-author avatars (avoids Symfony UX component context quirks). */
final class SidebarFeaturedAuthorsExtension extends AbstractExtension
{
public function __construct(
private readonly FeaturedAuthorListedRows $featuredAuthorListedRows,
) {
}
public function getFunctions(): array
{
return [
new TwigFunction('sidebar_featured_author_rows', function (int $limit = 12): array {
return $this->featuredAuthorListedRows->buildSidebarRows($limit);
}),
];
}
}

26
src/Twig/TopTopicsExtension.php

@ -1,26 +0,0 @@ @@ -1,26 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Twig;
use App\Service\TopicIndexService;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
final class TopTopicsExtension extends AbstractExtension
{
public function __construct(
private readonly TopicIndexService $topicIndexService,
) {
}
public function getFunctions(): array
{
return [
new TwigFunction('top_topic_labels', function (int $limit = 25): array {
return $this->topicIndexService->getTopTopicLabels($limit);
}),
];
}
}

9
src/Util/CommonMark/Converter.php

@ -2,7 +2,6 @@ @@ -2,7 +2,6 @@
namespace App\Util\CommonMark;
use App\Nostr\Nip19Codec;
use App\Service\CacheService;
use App\Util\CommonMark\ImagesExtension\RawImageLinkExtension;
use App\Util\CommonMark\NostrSchemeExtension\NostrSchemeExtension;
@ -26,9 +25,9 @@ use League\CommonMark\Renderer\HtmlDecorator; @@ -26,9 +25,9 @@ use League\CommonMark\Renderer\HtmlDecorator;
readonly class Converter
{
public function __construct(
private CacheService $cacheService,
private Nip19Codec $nip19Codec,
) {
private CacheService $cacheService
)
{
}
/**
@ -67,7 +66,7 @@ readonly class Converter @@ -67,7 +66,7 @@ readonly class Converter
$environment->addExtension(new TableExtension());
$environment->addExtension(new StrikethroughExtension());
// create a custom extension, that handles nostr mentions
$environment->addExtension(new NostrSchemeExtension($this->cacheService, $this->nip19Codec));
$environment->addExtension(new NostrSchemeExtension($this->cacheService));
$environment->addExtension(new SmartPunctExtension());
$environment->addExtension(new EmbedExtension());
$environment->addRenderer(Embed::class, new HtmlDecorator(new EmbedRenderer(), 'div', ['class' => 'embedded-content']));

15
src/Util/CommonMark/NostrSchemeExtension/NostrBareBech32Parser.php

@ -4,20 +4,18 @@ declare(strict_types=1); @@ -4,20 +4,18 @@ declare(strict_types=1);
namespace App\Util\CommonMark\NostrSchemeExtension;
use App\Nostr\Nip19Codec;
use League\CommonMark\Parser\Inline\InlineParserInterface;
use League\CommonMark\Parser\Inline\InlineParserMatch;
use League\CommonMark\Parser\InlineParserContext;
use nostriphant\NIP19\Bech32;
use nostriphant\NIP19\Data\NAddr;
use nostriphant\NIP19\Data\NEvent;
/**
* Matches bare or @-prefixed naddr1 / nevent1 (NIP-19), so they render like nostr:… links.
*/
final class NostrBareBech32Parser implements InlineParserInterface
{
public function __construct(
private readonly Nip19Codec $nip19,
) {
}
public function getMatchDefinition(): InlineParserMatch
{
return InlineParserMatch::regex('(?:@)?(?:naddr1|nevent1)[0-9a-z]+');
@ -34,14 +32,16 @@ final class NostrBareBech32Parser implements InlineParserInterface @@ -34,14 +32,16 @@ final class NostrBareBech32Parser implements InlineParserInterface
}
try {
$decoded = $this->nip19->decode($bech);
$decoded = new Bech32($bech);
} catch (\Throwable) {
return false;
}
if ($decoded->type === 'naddr') {
/** @var NAddr $data */
$data = $decoded->data;
$relays = $data->relays ?? [];
// NIP-19 naddr TLVs include author pubkey and kind; normalize like `nevent` if TLVs are missing.
$author = $data->pubkey ?? '';
$kind = (int) ($data->kind ?? 0);
$inlineContext->getContainer()->appendChild(new NostrSchemeData(
@ -52,9 +52,10 @@ final class NostrBareBech32Parser implements InlineParserInterface @@ -52,9 +52,10 @@ final class NostrBareBech32Parser implements InlineParserInterface
$kind
));
} elseif ($decoded->type === 'nevent') {
/** @var NEvent $data */
$data = $decoded->data;
$relays = $data->relays ?? [];
$author = (string) ($data->author ?? $data->pubkey ?? '');
$author = $data->author ?? $data->pubkey ?? '';
$inlineContext->getContainer()->appendChild(new NostrSchemeData(
'nevent',
$bech,

12
src/Util/CommonMark/NostrSchemeExtension/NostrEventRenderer.php

@ -2,19 +2,15 @@ @@ -2,19 +2,15 @@
namespace App\Util\CommonMark\NostrSchemeExtension;
use App\Nostr\Nip19Codec;
use League\CommonMark\Node\Node;
use League\CommonMark\Renderer\ChildNodeRendererInterface;
use League\CommonMark\Renderer\NodeRendererInterface;
use League\CommonMark\Util\HtmlElement;
use nostriphant\NIP19\Bech32;
class NostrEventRenderer implements NodeRendererInterface
{
public function __construct(
private readonly Nip19Codec $nip19,
) {
}
public function render(Node $node, ChildNodeRendererInterface $childRenderer): \Stringable|string|null
public function render(Node $node, ChildNodeRendererInterface $childRenderer)
{
if (!($node instanceof NostrSchemeData)) {
throw new \InvalidArgumentException('Incompatible inline node type: '.get_class($node));
@ -25,14 +21,14 @@ class NostrEventRenderer implements NodeRendererInterface @@ -25,14 +21,14 @@ class NostrEventRenderer implements NodeRendererInterface
return $this->renderPreviewOrFallback($node, $type);
}
return null;
return false;
}
private function renderPreviewOrFallback(NostrSchemeData $node, string $type): HtmlElement
{
$bech = $node->getSpecial();
try {
$decoded = $this->nip19->decode($bech);
$decoded = new Bech32($bech);
$payload = json_decode(json_encode($decoded->data), true, 512, JSON_THROW_ON_ERROR);
$decodedJson = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
} catch (\Throwable) {

13
src/Util/CommonMark/NostrSchemeExtension/NostrSchemeExtension.php

@ -2,7 +2,6 @@ @@ -2,7 +2,6 @@
namespace App\Util\CommonMark\NostrSchemeExtension;
use App\Nostr\Nip19Codec;
use App\Service\CacheService;
use League\CommonMark\Environment\EnvironmentBuilderInterface;
use League\CommonMark\Extension\ExtensionInterface;
@ -10,21 +9,19 @@ use League\CommonMark\Extension\ExtensionInterface; @@ -10,21 +9,19 @@ use League\CommonMark\Extension\ExtensionInterface;
class NostrSchemeExtension implements ExtensionInterface
{
public function __construct(
private readonly CacheService $cacheService,
private readonly Nip19Codec $nip19,
) {
public function __construct(private readonly CacheService $cacheService)
{
}
public function register(EnvironmentBuilderInterface $environment): void
{
$environment
->addInlineParser(new NostrBareBech32Parser($this->nip19), 202)
->addInlineParser(new NostrBareBech32Parser(), 202)
->addInlineParser(new NostrMentionParser($this->cacheService), 200)
->addInlineParser(new NostrSchemeParser($this->nip19), 199)
->addInlineParser(new NostrSchemeParser(), 199)
->addInlineParser(new NostrRawNpubParser($this->cacheService), 198)
->addRenderer(NostrSchemeData::class, new NostrEventRenderer($this->nip19), 2)
->addRenderer(NostrSchemeData::class, new NostrEventRenderer(), 2)
->addRenderer(NostrMentionLink::class, new NostrMentionRenderer(), 1)
;
}

29
src/Util/CommonMark/NostrSchemeExtension/NostrSchemeParser.php

@ -2,16 +2,19 @@ @@ -2,16 +2,19 @@
namespace App\Util\CommonMark\NostrSchemeExtension;
use App\Nostr\Nip19Codec;
use League\CommonMark\Parser\Inline\InlineParserInterface;
use League\CommonMark\Parser\Inline\InlineParserMatch;
use League\CommonMark\Parser\InlineParserContext;
use nostriphant\NIP19\Bech32;
use nostriphant\NIP19\Data\NAddr;
use nostriphant\NIP19\Data\NEvent;
use nostriphant\NIP19\Data\NProfile;
class NostrSchemeParser implements InlineParserInterface
{
public function __construct(
private readonly Nip19Codec $nip19,
) {
public function __construct()
{
}
public function getMatchDefinition(): InlineParserMatch
@ -29,29 +32,35 @@ class NostrSchemeParser implements InlineParserInterface @@ -29,29 +32,35 @@ class NostrSchemeParser implements InlineParserInterface
$bechEncoded = substr($fullMatch, 6); // Extract the part after "nostr:", i.e., "XXXX"
try {
$decoded = $this->nip19->decode($bechEncoded);
$decoded = new Bech32($bechEncoded);
switch ($decoded->type) {
case 'npub':
// Use the decoded bech32 (npub1…). NPub::$data is the hex pubkey; NostrMentionLink /author routes expect npub1…
$inlineContext->getContainer()->appendChild(new NostrMentionLink(null, $bechEncoded));
break;
case 'nprofile':
/** @var NProfile $decodedProfile */
$decodedProfile = $decoded->data;
$inlineContext->getContainer()->appendChild(new NostrMentionLink(null, $decodedProfile->pubkey));
break;
case 'nevent':
/** @var NEvent $decodedNpub */
$decodedEvent = $decoded->data;
$relays = $decodedEvent->relays ?? [];
$eventId = $decodedEvent->id;
$relays = $decodedEvent->relays;
$author = $decodedEvent->author;
$kind = $decodedEvent->kind;
$inlineContext->getContainer()->appendChild(new NostrSchemeData('nevent', $bechEncoded, \is_array($relays) ? $relays : [], (string) $author, (int) ($kind ?? 0)));
$inlineContext->getContainer()->appendChild(new NostrSchemeData('nevent', $bechEncoded, $relays, $author, $kind));
break;
case 'naddr':
/** @var NAddr $decodedNpub */
$decodedEvent = $decoded->data;
$relays = $decodedEvent->relays ?? [];
$identifier = $decodedEvent->identifier;
$pubkey = $decodedEvent->pubkey;
$kind = (int) ($decodedEvent->kind ?? 0);
$inlineContext->getContainer()->appendChild(new NostrSchemeData('naddr', $bechEncoded, \is_array($relays) ? $relays : [], (string) $pubkey, $kind));
$kind = $decodedEvent->kind;
$relays = $decodedEvent->relays;
$inlineContext->getContainer()->appendChild(new NostrSchemeData('naddr', $bechEncoded, $relays, $pubkey, $kind));
break;
case 'nrelay':
// deprecated

546
src/Util/HighlightEventTags.php

@ -1,546 +0,0 @@ @@ -1,546 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Util;
/**
* NIP-84 (kind 9802): `context` tag = full quote; the event’s `.content` = the highlighted part of
* that quote. If there is no `context` tag (or it is empty), the passage to display is the same
* as `.content` (entirely highlighted). In-article marks: {@see \App\Service\ArticleBodyHighlightInjector}.
*/
final class HighlightEventTags
{
public const HIGHLIGHT_MARK_CLASS = 'user-highlight__marker';
/**
* Turn one Nostr tag (array, associative array, or object from JSON) into an ordered list of
* string cells. Relay clients and json_decode vary; without this, `context` tags are often skipped.
*
* @return list<string>|null empty tag rows become null
*/
public static function nostrTagRowToList(mixed $tag): ?array
{
if (\is_object($tag)) {
$tag = (array) $tag;
}
if (!\is_array($tag)) {
return null;
}
\ksort($tag, \SORT_NUMERIC);
$tag = \array_values($tag);
$out = [];
foreach ($tag as $cell) {
$out[] = (string) $cell;
}
if ($out === []) {
return null;
}
return $out;
}
/**
* Canonical tag list for JSON storage (list of list of strings).
*
* @param list<mixed> $tags
*
* @return list<list<string>>
*/
public static function normalizeTagsForStorage(array $tags): array
{
$out = [];
foreach ($tags as $tag) {
$row = self::nostrTagRowToList($tag);
if (null !== $row && $row !== []) {
$out[] = $row;
}
}
return $out;
}
/**
* Trims Nostr/Unicode spacing (U+00A0, U+200B, U+00AD, other {@see \p{Z}}, etc.) from both ends
* after standard {@see \trim} — NIP-84 clients often differ from the rendered body on edge spaces.
*/
public static function trimNostrText(string $s): string
{
$s = \trim($s, " \t\n\r\0\x0B");
if ($s === '') {
return '';
}
$edge = '\p{Z}\x{200B}\x{200C}\x{200D}\x{FEFF}';
$s = (string) \preg_replace('/^['.$edge.']+/u', '', $s);
$s = (string) \preg_replace('/['.$edge.']+$/u', '', $s);
$s = \trim($s, " \t\n\r\0\x0B");
return $s;
}
/**
* The full passage from the `context` tag (one tag may split across many values in some clients).
*/
public static function contextFromTags(array $tags): string
{
return self::valuesFromNostrTagName($tags, 'context');
}
/**
* Same shape as the `context` tag: one or more `textquoteselector` rows (used for excerpts only).
*/
public static function textquoteselectorPassageFromTags(array $tags): string
{
return self::valuesFromNostrTagName($tags, 'textquoteselector');
}
/**
* Full “quote” passage for cards: the `context` tag when present and non-empty, otherwise
* the same string as the event’s `.content` (no surrounding quote beyond the highlight).
*/
public static function fullPassageForHighlightDisplay(string $eventContent, array $tags): string
{
$ctx = \trim(self::contextFromTags($tags));
if ($ctx !== '') {
return $ctx;
}
return \trim((string) $eventContent);
}
/**
* @param list<mixed> $tags
*/
private static function valuesFromNostrTagName(array $tags, string $nameLower): string
{
$parts = [];
foreach ($tags as $t) {
$row = self::nostrTagRowToList($t);
if (null === $row || \count($row) < 2) {
continue;
}
$k = self::normalizeNostrTagKey($row[0]);
if ($k !== $nameLower) {
continue;
}
for ($i = 1, $c = \count($row); $i < $c; ++$i) {
$p = $row[$i];
if ($p !== '') {
$parts[] = $p;
}
}
}
if ($parts === []) {
return '';
}
$joined = \implode(' ', $parts);
return \mb_substr($joined, 0, 8000);
}
private static function normalizeNostrTagKey(string $k): string
{
$k = (string) \preg_replace('/^\x{FEFF}/u', '', $k);
$k = \ltrim($k, "\0..\x1F");
return \strtolower(\trim($k));
}
/**
* Same character normalization as {@see \App\Service\ArticleBodyHighlightInjector} so
* `content` can match the `context` tag when Unicode (NBSP, soft hyphen, etc.) differs — NIP-84
* requires `content` to be a substring of the passage, but clients often diverge on code points.
*
* Newlines and Unicode line/paragraph separators are removed: Nostr `context` often contains
* `\\n` between sentences, while the article DOM’s flattened text has no line breaks at block
* boundaries, so they must not break matching.
*
* Smart punctuation (curly quotes, en/em dash, Unicode ellipsis) is folded to ASCII so the
* article HTML from {@see \League\CommonMark\Extension\SmartPunct\SmartPunctExtension} still
* matches highlight `content` copied with straight quotes from the source article.
*/
public static function stringForSearch(string $s): string
{
$L = \mb_strlen($s, 'UTF-8');
$out = '';
for ($i = 0; $i < $L; ++$i) {
$ch = \mb_substr($s, $i, 1, 'UTF-8');
$out .= self::searchCharacterNormalized($ch);
}
return $out;
}
/**
* @return list<int> length L+1; cuml[i] = "search" string length of prefix s[0..i) after per-char normalization
*/
public static function buildCumulativeSearchLens(string $s): array
{
$L = \mb_strlen($s, 'UTF-8');
$cuml = [0];
for ($i = 0; $i < $L; ++$i) {
$ch = \mb_substr($s, $i, 1, 'UTF-8');
$add = self::searchCharacterNormalized($ch);
$cuml[] = $cuml[$i] + \mb_strlen($add, 'UTF-8');
}
return $cuml;
}
/**
* @return array{0: int, 1: int} half-open [start, end) in mb char indices of $orig
*/
public static function mapSearchStringRangeToOrigStringRange(string $orig, int $nStart, int $nEnd): array
{
$L = \mb_strlen($orig, 'UTF-8');
$cuml = self::buildCumulativeSearchLens($orig);
if (0 > $nStart || $nStart > $cuml[$L] || $nEnd < $nStart || $nEnd > $cuml[$L]) {
return [0, 0];
}
$startO = -1;
for ($i = 0; $i < $L; ++$i) {
if ($cuml[$i + 1] > $nStart) {
$startO = $i;
break;
}
}
if ($startO < 0) {
return [0, 0];
}
$endO = $L;
for ($e = 0; $e <= $L; ++$e) {
if ($cuml[$e] >= $nEnd) {
$endO = $e;
break;
}
}
return [$startO, $endO];
}
/**
* Find `content` inside `context` (literal or after Unicode/Nostr normalization). Returns half-open
* mb indices into $context, or null.
*
* $context and $content must be the same strings used for final HTML (trim + line ending
* normalization) — see {@see buildHighlightedBodyHtml}.
*
* @return array{0: int, 1: int}|null
*/
public static function findContentSpanInContext(string $context, string $content): ?array
{
$q = $context;
if ($q === '' || $content === '') {
return null;
}
foreach (self::highlightContentSearchVariants($content) as $needle) {
$needle = self::normalizeLineEndingsForHighlight($needle);
if ($needle === '') {
continue;
}
$p = \mb_strpos($q, $needle, 0, 'UTF-8');
if (false !== $p) {
$len = \mb_strlen($needle, 'UTF-8');
return [$p, $p + $len];
}
}
$qR = self::replaceTypographicQuotesForSearch($q);
if ($qR !== $q) {
foreach (self::highlightContentSearchVariants($content) as $needle) {
$needle = self::normalizeLineEndingsForHighlight($needle);
if ($needle === '') {
continue;
}
foreach ([$needle, self::replaceTypographicQuotesForSearch($needle)] as $nTry) {
if ($nTry === '') {
continue;
}
$p = \mb_strpos($qR, $nTry, 0, 'UTF-8');
if (false !== $p) {
$len = \mb_strlen($nTry, 'UTF-8');
return [$p, $p + $len];
}
}
}
}
$hS = self::stringForSearch($q);
foreach (self::highlightContentSearchVariants($content) as $needle) {
$needle = self::normalizeLineEndingsForHighlight($needle);
if ($needle === '') {
continue;
}
$nS = self::stringForSearch($needle);
if ($nS === '') {
continue;
}
$pN = \mb_strpos($hS, $nS, 0, 'UTF-8');
if (false === $pN) {
continue;
}
$nEnd = $pN + \mb_strlen($nS, 'UTF-8');
[$a, $b] = self::mapSearchStringRangeToOrigStringRange($q, $pN, $nEnd);
if ($b > $a) {
return [$a, $b];
}
}
return null;
}
/**
* @return list<string>
*/
public static function highlightContentSearchVariants(string $content): array
{
if ($content === '') {
return [];
}
$candidates = [
$content,
self::replaceTypographicQuotesForSearch($content),
];
$t = \trim($content);
if ($t !== '' && $t !== $content) {
$candidates[] = $t;
}
if (\class_exists(\Normalizer::class)) {
$c = \Normalizer::normalize($content, \Normalizer::FORM_C);
if (\is_string($c) && $c !== '' && $c !== $content) {
$candidates[] = $c;
}
}
$out = [];
$seen = [];
foreach ($candidates as $n) {
if ($n === '' || isset($seen[$n])) {
continue;
}
$seen[$n] = true;
$out[] = $n;
}
return $out;
}
private static function replaceTypographicQuotesForSearch(string $s): string
{
return \strtr($s, [
"\xC2\xA0" => ' ', // nbsp
"\xE2\x80\x99" => "'",
"\xE2\x80\x98" => "'",
"\xE2\x80\x9C" => '"',
"\xE2\x80\x9D" => '"',
"\xE2\x80\x93" => '-',
"\xE2\x80\x94" => '-',
]);
}
private static function normalizeLineEndingsForHighlight(string $s): string
{
return \str_replace("\r\n", "\n", \str_replace("\r", "\n", $s));
}
private static function searchCharacterNormalized(string $ch): string
{
if ($ch === "\n" || $ch === "\r" || $ch === "\f" || $ch === "\v") {
return '';
}
if ($ch === "\xC2\x85") { // U+0085 (NEL)
return '';
}
if ($ch === "\xE2\x80\xA8" || $ch === "\xE2\x80\xA9") { // U+2028 LINE, U+2029 PARA separator
return '';
}
if ($ch === "\xC2\xAD") { // U+00AD soft hyphen
return '';
}
if ($ch === "\xE2\x80\x8B" // U+200B
|| $ch === "\xE2\x80\x8C" // U+200C
|| $ch === "\xE2\x80\x8D" // U+200D
|| $ch === "\xEF\xBB\xBF" // U+FEFF
) {
return '';
}
if ($ch === "\xC2\xA0" // U+00A0
|| $ch === "\xE2\x80\xAF" // U+202F narrow no-break
) {
return ' ';
}
// CommonMark SmartPunct / e-book typography → match Nostr `content` with ASCII punctuation
if ($ch === "\xE2\x80\x99" || $ch === "\xE2\x80\x98") { // U+2019, U+2018
return "'";
}
if ($ch === "\xE2\x80\x9C" || $ch === "\xE2\x80\x9D") { // U+201C, U+201D
return '"';
}
if ($ch === "\xE2\x80\x93" || $ch === "\xE2\x80\x94") { // en dash, em dash
return '-';
}
if ($ch === "\xE2\x80\xA6") { // U+2026 HORIZONTAL ELLIPSIS
return '...';
}
return $ch;
}
/**
* @param string $contextQuote Passage: `context` tag, or the same as `$contentField` when there
* is no `context` (caller should use {@see fullPassageForHighlightDisplay}).
* @param string $contentField The event’s `content` (highlighted substring of the passage).
*
* @return string safe HTML
*/
public static function buildHighlightedBodyHtml(string $contextQuote, string $contentField): string
{
$q = \trim(self::normalizeLineEndingsForHighlight((string) $contextQuote));
$hi = \trim(self::normalizeLineEndingsForHighlight((string) $contentField));
if ($q === '' && $hi === '') {
return '';
}
if ($q === '') {
return '<mark class="'.self::HIGHLIGHT_MARK_CLASS.'">'.self::escapeWithNl2br($hi).'</mark>';
}
if ($hi === '') {
return self::escapeWithNl2br($q);
}
if ($q === $hi) {
return '<mark class="'.self::HIGHLIGHT_MARK_CLASS.'">'.self::escapeWithNl2br($q).'</mark>';
}
$span = self::findContentSpanInContext($q, $hi);
if (null !== $span) {
[$start, $end] = $span;
$before = \mb_substr($q, 0, $start, 'UTF-8');
$match = \mb_substr($q, $start, $end - $start, 'UTF-8');
$after = \mb_substr($q, $end, null, 'UTF-8');
return self::escapeWithNl2br($before).self::markHtml($match).self::escapeWithNl2br($after);
}
// Substring not found after normalization / variants: show the full context quote, then the highlight so the card is not empty.
return self::escapeWithNl2br($q).'<p class="user-highlight__marker-orphan">'.self::markHtml($hi).'</p>';
}
/**
* For narrow list layouts (e.g. home aside with {@see buildHighlightedBodyHtml} + line-clamp): if the
* `content` is not at the start of the passage, drop the text before the highlight so the
* clamped block begins at (or a few characters before) the mark and the user actually sees
* the highlight.
*
* @param int $includeCharsOfContextBeforeHighlight Extra characters to keep before the
* highlight (0 = passage starts with `content`)
*/
public static function buildHighlightedBodyHtmlForNarrowList(
string $contextQuote,
string $contentField,
int $includeCharsOfContextBeforeHighlight = 0,
): string {
$q = \trim(self::normalizeLineEndingsForHighlight((string) $contextQuote));
$hi = \trim(self::normalizeLineEndingsForHighlight((string) $contentField));
if ($q === '' && $hi === '') {
return '';
}
if ($q === '' || $hi === '') {
return self::buildHighlightedBodyHtml($q, $hi);
}
if ($q === $hi) {
return self::buildHighlightedBodyHtml($q, $hi);
}
$span = self::findContentSpanInContext($q, $hi);
if (null === $span) {
return self::buildHighlightedBodyHtml($q, $hi);
}
[$st] = $span;
if (0 === $st) {
return self::buildHighlightedBodyHtml($q, $hi);
}
$lead = \max(0, $includeCharsOfContextBeforeHighlight);
$offset = \max(0, $st - $lead);
if (0 === $offset) {
return self::buildHighlightedBodyHtml($q, $hi);
}
$q2 = \mb_substr($q, $offset, null, 'UTF-8');
if ($q2 === '') {
return self::buildHighlightedBodyHtml($q, $hi);
}
$html = self::buildHighlightedBodyHtml($q2, $hi);
return self::omittedTextPrefixHtml().$html;
}
/**
* Safe “earlier text omitted” marker before a truncated passage in list cards.
*/
public static function omittedTextPrefixHtml(): string
{
return '<span class="user-highlight__elide" aria-hidden="true">&#8230;</span> ';
}
public static function escapeWithNl2br(string $s): string
{
return \nl2br(\htmlspecialchars($s, \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8'), false);
}
private static function markHtml(string $innerText): string
{
return self::markHighlightSpanHtml($innerText);
}
/**
* Single highlighted span (inner text escaped). Used in cards and by {@see buildHighlightedBodyHtml}.
*/
public static function markHighlightSpanHtml(string $innerText): string
{
$e = \htmlspecialchars($innerText, \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8');
return '<mark class="'.self::HIGHLIGHT_MARK_CLASS.'">'.$e.'</mark>';
}
/**
* Text from `textquoteselector` (legacy / client-specific), first non-empty segment.
*
* @param list<array<int, string>>|list<array> $tags
*/
public static function excerptFromTextquoteselectorTags(array $tags): string
{
foreach ($tags as $t) {
$row = self::nostrTagRowToList($t);
if (null === $row || \count($row) < 2) {
continue;
}
if (self::normalizeNostrTagKey($row[0]) !== 'textquoteselector') {
continue;
}
for ($i = 1, $c = \count($row); $i < $c; ++$i) {
$p = \trim($row[$i]);
if ($p !== '') {
return \mb_substr($p, 0, 400);
}
}
}
return '';
}
/**
* List preview: prefer the event `content` (the highlight / note body), else `context` quote, else tq.
*/
public static function excerptForFeed(string $content, array $tags): string
{
$raw = (string) $content;
if ($raw !== '') {
$c = self::trimNostrText($raw);
if ($c !== '') {
return \mb_substr($c, 0, 400);
}
}
$ctx = self::trimNostrText(self::contextFromTags($tags));
if ($ctx !== '') {
return \mb_substr($ctx, 0, 400);
}
$tq = self::trimNostrText(self::excerptFromTextquoteselectorTags($tags));
return $tq !== '' ? $tq : '';
}
}

45
symfony.lock

@ -35,27 +35,6 @@ @@ -35,27 +35,6 @@
"./migrations/.gitignore"
]
},
"nyholm/psr7": {
"version": "1.8",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "1.0",
"ref": "4a8c0345442dcca1d8a2c65633dcf0285dd5a5a2"
},
"files": [
"config/packages/nyholm_psr7.yaml"
]
},
"phpstan/phpstan": {
"version": "2.1",
"recipe": {
"repo": "github.com/symfony/recipes-contrib",
"branch": "main",
"version": "1.0",
"ref": "5e490cc197fb6bb1ae22e5abbc531ddc633b6767"
}
},
"phpunit/phpunit": {
"version": "9.6",
"recipe": {
@ -109,18 +88,6 @@ @@ -109,18 +88,6 @@
".env"
]
},
"symfony/form": {
"version": "7.3",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.2",
"ref": "7d86a6723f4a623f59e2bf966b6aad2fc461d36b"
},
"files": [
"config/packages/csrf.yaml"
]
},
"symfony/framework-bundle": {
"version": "7.1",
"recipe": {
@ -176,18 +143,6 @@ @@ -176,18 +143,6 @@
"tests/bootstrap.php"
]
},
"symfony/property-info": {
"version": "7.3",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.3",
"ref": "dae70df71978ae9226ae915ffd5fad817f5ca1f7"
},
"files": [
"config/packages/property_info.yaml"
]
},
"symfony/routing": {
"version": "7.1",
"recipe": {

8
templates/base.html.twig

@ -37,14 +37,6 @@ @@ -37,14 +37,6 @@
<div class="layout">
<nav>
<twig:UserMenu />
{% set _sidebar_fa = sidebar_featured_author_rows(12) %}
{% if _sidebar_fa is not empty %}
{% include 'components/Organisms/SidebarFeaturedAuthors.html.twig' with { rows: _sidebar_fa } only %}
{% endif %}
{% set _top_topics = top_topic_labels(25) %}
{% if _top_topics is not empty %}
{% include 'components/Organisms/SidebarTopTopics.html.twig' with { labels: _top_topics } only %}
{% endif %}
{% block nav %}{% endblock %}
</nav>
<main>

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save