From e9edb98555329aac3ee6c95fb72ec6188e6a1ee3 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Sun, 26 Apr 2026 07:37:49 +0200 Subject: [PATCH] bug-fixes --- .../article_highlight_controller.js | 5 +- assets/styles/article.css | 32 ++- config/unfold.yaml | 2 + src/Controller/ArticleController.php | 1 + src/Entity/ArticleHighlight.php | 6 +- src/Service/HighlightSyncService.php | 1 + src/Service/NostrClient.php | 210 +++++++++++++++++- src/Util/HighlightEventTags.php | 81 +++++-- .../Organisms/HomeHighlightsAside.html.twig | 2 +- translations/messages.en.yaml | 2 +- 10 files changed, 308 insertions(+), 34 deletions(-) diff --git a/assets/controllers/article_highlight_controller.js b/assets/controllers/article_highlight_controller.js index d2eeee6..b5c4090 100644 --- a/assets/controllers/article_highlight_controller.js +++ b/assets/controllers/article_highlight_controller.js @@ -208,7 +208,10 @@ export default class extends Controller { } else { this._clearHoverLeaveTimer(); } - this.popoverInnerTarget.innerHTML = meta.headHtml || ''; + const head = meta.headHtml || ''; + const body = (meta.bodyHtml || '').trim(); + this.popoverInnerTarget.innerHTML = + head + (body !== '' ? `
${body}
` : ''); this._placePopover(mark); this.popoverTarget.hidden = false; } diff --git a/assets/styles/article.css b/assets/styles/article.css index 07debbe..c8af856 100644 --- a/assets/styles/article.css +++ b/assets/styles/article.css @@ -429,9 +429,12 @@ } .article-body-highlight--target { - box-shadow: inset 0 -2px 0 0 var(--color-secondary); - background: color-mix(in srgb, var(--color-secondary) 10%, transparent); - transition: background 0.35s ease, box-shadow 0.35s ease; + box-shadow: none; + background: transparent; + text-decoration: underline; + text-decoration-color: color-mix(in srgb, var(--color-text) 35%, transparent); + text-underline-offset: 0.12em; + transition: text-decoration-color 0.25s ease; } .article-body-highlight:focus-visible { @@ -493,7 +496,7 @@ font-size: 0.86rem; } -/* Full `context` quote + optional on the `content` substring (highlighter, not a box) */ +/* Full `context` quote + optional on the `content` substring (body copy, not a box) */ .user-highlight__body { margin: 0.35rem 0 0; font-size: 0.95rem; @@ -502,10 +505,23 @@ font-family: var(--main-body-font), serif; } -/* In-flow highlighter marker (NIP-84: `content` inside `context` quote) */ -.article-main mark.user-highlight__marker, -.user-highlight__body mark.user-highlight__marker, -mark.user-highlight__marker { +/* In-flow article body: interactive but no highlighter fill (cards below own the color) */ +.article-main mark.user-highlight__marker { + margin: 0; + padding: 0; + border-radius: 0; + font: inherit; + line-height: inherit; + color: inherit; + background: transparent; + box-shadow: none; + box-decoration-break: clone; + -webkit-box-decoration-break: clone; +} + +/* Popover + home aside: full context where present, `content` visibly marked */ +.article-body-highlight__body 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; diff --git a/config/unfold.yaml b/config/unfold.yaml index 89b1ccd..f6b3130 100644 --- a/config/unfold.yaml +++ b/config/unfold.yaml @@ -21,6 +21,8 @@ parameters: 'wss://nostr.einundzwei.space' ] # 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', diff --git a/src/Controller/ArticleController.php b/src/Controller/ArticleController.php index e442055..07799b0 100644 --- a/src/Controller/ArticleController.php +++ b/src/Controller/ArticleController.php @@ -449,6 +449,7 @@ class ArticleController extends AbstractController 'authorPubkey' => $h->getAuthorPubkey(), 'dateLabel' => $this->formatHighlightListDate($h->getEventCreatedAt()), ]), + 'bodyHtml' => $h->getBodyHtml(), ]; } if ($out === []) { diff --git a/src/Entity/ArticleHighlight.php b/src/Entity/ArticleHighlight.php index 088b5e4..63a3798 100644 --- a/src/Entity/ArticleHighlight.php +++ b/src/Entity/ArticleHighlight.php @@ -146,9 +146,9 @@ class ArticleHighlight } /** - * HTML for the home aside and article hover cards: when a `context` tag exists, the full quote is - * shown with `content` marked inside it; otherwise the event `content` only in a . The - * rendered article body still only wraps the `content` passage (see ArticleBodyHighlightInjector). + * Card body HTML: the optional `context` tag is the full passage; the event `content` is + * highlighted (marked) where it appears inside that text. If there is no `context` tag, only + * `content` is wrapped in a mark. */ public function getBodyHtml(): string { diff --git a/src/Service/HighlightSyncService.php b/src/Service/HighlightSyncService.php index aa8060a..55a701c 100644 --- a/src/Service/HighlightSyncService.php +++ b/src/Service/HighlightSyncService.php @@ -65,6 +65,7 @@ final class HighlightSyncService if (!\is_array($tags)) { $tags = []; } + $tags = HighlightEventTags::normalizeTagsForStorage($tags); $content = (string) ($ev->content ?? ''); $ca = (int) ($ev->created_at ?? 0); if ($ca < 0) { diff --git a/src/Service/NostrClient.php b/src/Service/NostrClient.php index 36476c6..1a1dc56 100644 --- a/src/Service/NostrClient.php +++ b/src/Service/NostrClient.php @@ -43,6 +43,12 @@ class NostrClient */ private const MAX_DISCUSSION_RELAY_URLS = 10; + /** + * Kind-9802 highlight ingest ({@see fetchHighlightEventsForArticle} / prewarm): main + article + profile + * + author NIP-65, deduped. Higher than {@see MAX_DISCUSSION_RELAY_URLS} so profile relays are not dropped. + */ + private const MAX_HIGHLIGHT_RELAY_URLS = 32; + /** * {@see 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. @@ -174,6 +180,44 @@ class NostrClient 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 createRelaySet()} prepends article relays. + * + * @return list + */ + private function profileRelayUrlsExcludedFromArticleRelays(): array + { + $article = array_fill_keys($this->configuredArticleRelayUrlList(), true); + $out = []; + foreach ($this->profileRelayUrlList() as $u) { + if (!isset($article[$u])) { + $out[] = $u; + } + } + + return $out; + } + + /** + * Relay set built only from the given URLs (no implicit article-relay merge). + */ + private 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; + } + /** * Single-relay set for I/O that intentionally hits one wss (e.g. longform ingest). Magazine * 30040 resolution uses the full article relay set so all relays can contribute the latest @@ -1401,7 +1445,10 @@ class NostrClient } /** - * Fetches kind 9802 (highlights) that reference the long-form address. Used for DB ingest only. + * Fetches kind 9802 (highlights) that reference the long-form address. Used for DB ingest only + * ({@see HighlightSyncService} / prewarm). Relays: {@see configuredArticleRelayUrlList} (main + + * article_relays), then config {@see profileRelayUrlList}, then author NIP-65, deduped (cap + * {@see MAX_HIGHLIGHT_RELAY_URLS}). * * @return list unique wire events by id */ @@ -1420,13 +1467,19 @@ class NostrClient 'author_relay_count' => \count($authorRelays), ]); - $baseForDiscussion = $this->configuredArticleRelayUrlList(); + $baseArticle = $this->configuredArticleRelayUrlList(); + $profileConfigured = $this->profileRelayUrlList(); $mergedForDiscussion = $this->withAggrNostrLandIfUserSubscribesNostrLand( - array_merge($baseForDiscussion, $authorRelays) + array_merge($baseArticle, $profileConfigured, $authorRelays) ); $plannedRelayUrls = array_values(array_unique($mergedForDiscussion, \SORT_REGULAR)); - if (\count($plannedRelayUrls) > self::MAX_DISCUSSION_RELAY_URLS) { - $plannedRelayUrls = \array_slice($plannedRelayUrls, 0, self::MAX_DISCUSSION_RELAY_URLS); + $relayCountBeforeCap = \count($plannedRelayUrls); + if ($relayCountBeforeCap > self::MAX_HIGHLIGHT_RELAY_URLS) { + $this->logger->notice('nostr.highlight_relay_cap', [ + 'max' => self::MAX_HIGHLIGHT_RELAY_URLS, + 'had' => $relayCountBeforeCap, + ]); + $plannedRelayUrls = \array_slice($plannedRelayUrls, 0, self::MAX_HIGHLIGHT_RELAY_URLS); } $limH = 200; $filters = []; @@ -2763,13 +2816,26 @@ class NostrClient * The magazine root uses the site d_tag from config. Each category uses the full child d * (third segment of the root "a" address). A category 30040 lists 30023 article "a" tags, not * further nested 30040 indices. + * + * Tries article relays first; if no 30040 is found, retries on config `profile_relays` not + * already listed in `article_relays` (see prewarm / category discovery). */ public function getMagazineIndex(mixed $npub, mixed $dTag): ?PublicationEventEntity { $urls = $this->configuredArticleRelayUrlList(); $relaysForLog = implode(', ', array_map(self::relayLogLabel(...), $urls)); + $result = $this->queryMagazineIndex($npub, $dTag, $this->defaultRelaySet, $relaysForLog); + if ($result !== null) { + return $result; + } + $profileExtra = $this->profileRelayUrlsExcludedFromArticleRelays(); + if ($profileExtra === []) { + return null; + } + $pfSet = $this->createRelaySetFromUrlsOnly($profileExtra); + $relaysForLog2 = implode(', ', array_map(self::relayLogLabel(...), $profileExtra)).' (profile_relays)'; - return $this->queryMagazineIndex($npub, $dTag, $this->defaultRelaySet, $relaysForLog); + return $this->queryMagazineIndex($npub, $dTag, $pfSet, $relaysForLog2); } private function queryMagazineIndex(mixed $npub, mixed $dTag, RelaySet $relaySet, string $relaysForLog): ?PublicationEventEntity @@ -2820,12 +2886,82 @@ class NostrClient return self::magazineEventToPublicationEntity($raw); } + /** + * Single long-form coordinate on config profile relays only (not already in article_relays). + */ + private function tryFetchLongformCoordinateOnProfileRelays(string $coordinate): ?object + { + $extra = $this->profileRelayUrlsExcludedFromArticleRelays(); + if ($extra === []) { + return null; + } + $parts = explode(':', $coordinate, 3); + if (\count($parts) !== 3) { + return null; + } + $kind = (int) $parts[0]; + $pubkey = strtolower($parts[1]); + $slug = trim((string) $parts[2]); + $kindEnum = KindsEnum::tryFrom($kind); + if ($kindEnum === null || $pubkey === '' || $slug === '') { + return null; + } + $pfSet = $this->createRelaySetFromUrlsOnly($extra); + try { + $request = $this->createNostrRequest( + [$kindEnum], + ['authors' => [$pubkey], 'tag' => ['#d', [$slug]]], + $pfSet, + ); + $events = $this->processResponse( + $request->send(), + static fn (object $event) => $event, + ); + $ev = $this->pickEventForNip33OrFirst($events, $kind, $pubkey, $slug); + if ($ev !== null) { + return $ev; + } + $fallbackReq = $this->createNostrRequest( + [$kindEnum], + ['tag' => ['#d', [$slug]]], + $pfSet, + ); + $fallbackEvents = $this->processResponse( + $fallbackReq->send(), + static fn (object $event) => $event, + ); + $matched = []; + foreach ($fallbackEvents as $ev2) { + if (!\is_object($ev2)) { + continue; + } + if (strtolower((string) ($ev2->pubkey ?? '')) !== $pubkey) { + continue; + } + $d = self::eventDTagValue($ev2); + if ($d === null || trim((string) $d) !== $slug) { + continue; + } + $matched[] = $ev2; + } + + return $matched === [] ? null : $this->pickEventForNip33OrFirst($matched, $kind, $pubkey, $slug); + } catch (\Throwable) { + } + + return null; + } + /** * Batch-fetch latest longform for category `a` coordinates; one Nostr call per (author × kind) * group. Uses the same full article {@see $defaultRelaySet} as kind 30040 index queries so merged * NIP-33 results are not stuck on a single relay’s copy. {@see saveEachArticleToTheDatabase} * upserts by NIP-33 address. * + * After article relays return nothing (or some addresses stay missing), retries use config + * `profile_relays` not already in `article_relays`. The generic community listing at `/articles` + * is DB-only and does not add a profile-relay pass; {@see getArticles} stays article-relays-only. + * * @param list $addresses kind:pubkey:identifier */ public function ingestLongformForCategoryCoordinates(array $addresses): void @@ -2955,6 +3091,63 @@ class NostrClient $rawCount = \count($events); } } + if ($rawCount === 0) { + $profileExtra = $this->profileRelayUrlsExcludedFromArticleRelays(); + if ($profileExtra !== []) { + $pfSet = $this->createRelaySetFromUrlsOnly($profileExtra); + $this->logger->info('[longform_ingest] ingestLongform: no rows on article relays; trying profile_relays', [ + 'group_key' => $gkey, + 'relays' => implode(', ', array_map(self::relayLogLabel(...), $profileExtra)), + ]); + $requestPf = $this->createNostrRequest( + [$kindEnum], + ['authors' => [(string) $g['pubkey']], 'tag' => ['#d', $dTags]], + $pfSet, + ); + $events = $this->processResponse( + $requestPf->send(), + static fn (object $event) => $event, + ); + $rawCount = \count($events); + if ($rawCount === 0) { + $fallbackPf = $this->createNostrRequest( + [$kindEnum], + ['tag' => ['#d', $dTags]], + $pfSet, + ); + $fallbackEventsPf = $this->processResponse( + $fallbackPf->send(), + static fn (object $event) => $event, + ); + $fallbackMatchedPf = []; + $expectedPubkeyPf = strtolower((string) $g['pubkey']); + $expectedDPf = array_fill_keys($dTags, true); + foreach ($fallbackEventsPf as $ev) { + if (!\is_object($ev)) { + continue; + } + $evPubkey = strtolower((string) ($ev->pubkey ?? '')); + if ($evPubkey !== $expectedPubkeyPf) { + continue; + } + $evD = self::eventDTagValue($ev); + if ($evD === null || !isset($expectedDPf[$evD])) { + continue; + } + $fallbackMatchedPf[] = $ev; + } + $this->logger->info('[longform_ingest] ingestLongform: profile_relays #d-only fallback', [ + 'group_key' => $gkey, + 'fallback_raw_wire_count' => \count($fallbackEventsPf), + 'fallback_matched_count' => \count($fallbackMatchedPf), + ]); + if ($fallbackMatchedPf !== []) { + $events = $fallbackMatchedPf; + $rawCount = \count($events); + } + } + } + } $merged = self::mergeNip33ParameterizedWireEvents($events); $mergedDetail = []; foreach ($merged as $ev) { @@ -2995,7 +3188,10 @@ class NostrClient $byCoord = $this->getArticlesByCoordinates([$coordinate]); $evExtra = $byCoord[$coordinate] ?? null; if ($evExtra === null) { - $this->logger->warning('[longform_ingest] ingestLongform: still no event for coordinate (not on default or author relays)', [ + $evExtra = $this->tryFetchLongformCoordinateOnProfileRelays($coordinate); + } + if ($evExtra === null) { + $this->logger->warning('[longform_ingest] ingestLongform: still no event for coordinate (not on article, author, or profile relays)', [ 'coordinate' => $coordinate, ]); diff --git a/src/Util/HighlightEventTags.php b/src/Util/HighlightEventTags.php index bb3a513..32541ce 100644 --- a/src/Util/HighlightEventTags.php +++ b/src/Util/HighlightEventTags.php @@ -5,14 +5,59 @@ declare(strict_types=1); namespace App\Util; /** - * NIP-84 (kind 9802): {@see buildHighlightedBodyHtml} drives list/hover-card HTML (full `context` - * with `content` marked when both exist). In-article marks are applied separately and only wrap - * the `content` substring in the article body ({@see \App\Service\ArticleBodyHighlightInjector}). + * NIP-84 (kind 9802): optional `context` = full visible passage; `content` = highlighted range + * (marked inside that passage when `context` exists, otherwise only `content` in a mark). + * 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|null empty tag rows become null + */ + public static function nostrTagRowToList(mixed $tag): ?array + { + if (\is_object($tag)) { + $tag = \array_values((array) $tag); + } + if (!\is_array($tag)) { + return null; + } + $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 $tags + * + * @return list> + */ + public static function normalizeTagsForStorage(array $tags): array + { + $out = []; + foreach ($tags as $tag) { + $row = self::nostrTagRowToList($tag); + if (null !== $row && $row !== []) { + $out[] = $row; + } + } + + return $out; + } + /** * The full passage from the `context` tag (one tag may split across many values in some clients). */ @@ -20,14 +65,15 @@ final class HighlightEventTags { $parts = []; foreach ($tags as $t) { - if (!\is_array($t) || \count($t) < 2) { + $row = self::nostrTagRowToList($t); + if (null === $row || \count($row) < 2) { continue; } - if (strtolower((string) ($t[0] ?? '')) !== 'context') { + if (strtolower($row[0]) !== 'context') { continue; } - for ($i = 1, $c = \count($t); $i < $c; ++$i) { - $p = (string) ($t[$i] ?? ''); + for ($i = 1, $c = \count($row); $i < $c; ++$i) { + $p = $row[$i]; if ($p !== '') { $parts[] = $p; } @@ -42,8 +88,8 @@ final class HighlightEventTags } /** - * Card / aside body: with `context`, show the full quote and mark the `content` substring; with - * empty `context`, wrap all of `content` in one . + * With `context`, show the full quote and mark the `content` substring. With no `context`, wrap + * all of `content` in one mark. * * @param string $contextQuote Text from the `context` tag. Empty means no surrounding quote. * @param string $contentField The event’s `content` (highlighted phrase). @@ -83,6 +129,14 @@ final class HighlightEventTags } 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'); @@ -97,14 +151,15 @@ final class HighlightEventTags public static function excerptFromTextquoteselectorTags(array $tags): string { foreach ($tags as $t) { - if (!\is_array($t) || \count($t) < 2) { + $row = self::nostrTagRowToList($t); + if (null === $row || \count($row) < 2) { continue; } - if (strtolower((string) ($t[0] ?? '')) !== 'textquoteselector') { + if (strtolower($row[0]) !== 'textquoteselector') { continue; } - for ($i = 1, $c = \count($t); $i < $c; ++$i) { - $p = \trim((string) ($t[$i] ?? '')); + for ($i = 1, $c = \count($row); $i < $c; ++$i) { + $p = \trim($row[$i]); if ($p !== '') { return \mb_substr($p, 0, 400); } diff --git a/templates/components/Organisms/HomeHighlightsAside.html.twig b/templates/components/Organisms/HomeHighlightsAside.html.twig index fe787c4..2e04ffc 100644 --- a/templates/components/Organisms/HomeHighlightsAside.html.twig +++ b/templates/components/Organisms/HomeHighlightsAside.html.twig @@ -12,7 +12,7 @@