Browse Source

bug-fixes

imwald
Silberengel 2 days ago
parent
commit
e9edb98555
  1. 5
      assets/controllers/article_highlight_controller.js
  2. 32
      assets/styles/article.css
  3. 2
      config/unfold.yaml
  4. 1
      src/Controller/ArticleController.php
  5. 6
      src/Entity/ArticleHighlight.php
  6. 1
      src/Service/HighlightSyncService.php
  7. 210
      src/Service/NostrClient.php
  8. 81
      src/Util/HighlightEventTags.php
  9. 2
      templates/components/Organisms/HomeHighlightsAside.html.twig
  10. 2
      translations/messages.en.yaml

5
assets/controllers/article_highlight_controller.js

@ -208,7 +208,10 @@ export default class extends Controller { @@ -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 !== '' ? `<div class="article-body-highlight__body user-highlight__body">${body}</div>` : '');
this._placePopover(mark);
this.popoverTarget.hidden = false;
}

32
assets/styles/article.css

@ -429,9 +429,12 @@ @@ -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 @@ @@ -493,7 +496,7 @@
font-size: 0.86rem;
}
/* Full `context` quote + optional <mark> on the `content` substring (highlighter, not a box) */
/* 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;
@ -502,10 +505,23 @@ @@ -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;

2
config/unfold.yaml

@ -21,6 +21,8 @@ parameters: @@ -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',

1
src/Controller/ArticleController.php

@ -449,6 +449,7 @@ class ArticleController extends AbstractController @@ -449,6 +449,7 @@ class ArticleController extends AbstractController
'authorPubkey' => $h->getAuthorPubkey(),
'dateLabel' => $this->formatHighlightListDate($h->getEventCreatedAt()),
]),
'bodyHtml' => $h->getBodyHtml(),
];
}
if ($out === []) {

6
src/Entity/ArticleHighlight.php

@ -146,9 +146,9 @@ class ArticleHighlight @@ -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 <mark>. 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
{

1
src/Service/HighlightSyncService.php

@ -65,6 +65,7 @@ final class HighlightSyncService @@ -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) {

210
src/Service/NostrClient.php

@ -43,6 +43,12 @@ class NostrClient @@ -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 @@ -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<string>
*/
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 @@ -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<object> unique wire events by id
*/
@ -1420,13 +1467,19 @@ class NostrClient @@ -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 @@ -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 @@ -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<string> $addresses kind:pubkey:identifier
*/
public function ingestLongformForCategoryCoordinates(array $addresses): void
@ -2955,6 +3091,63 @@ class NostrClient @@ -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 @@ -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,
]);

81
src/Util/HighlightEventTags.php

@ -5,14 +5,59 @@ declare(strict_types=1); @@ -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<string>|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<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;
}
/**
* The full passage from the `context` tag (one tag may split across many values in some clients).
*/
@ -20,14 +65,15 @@ final class HighlightEventTags @@ -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 @@ -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 <mark>.
* 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 @@ -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 @@ -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);
}

2
templates/components/Organisms/HomeHighlightsAside.html.twig

@ -12,7 +12,7 @@ @@ -12,7 +12,7 @@
<div class="home-aside-highlights__item-inner">
<a
class="home-aside-highlights__hit"
href="{{ path('article', { npub: _np, slug: art.slug }) ~ '#h-' ~ h.eventId }}"
href="{{ path('article', { npub: _np, slug: art.slug }) }}"
aria-label="{{ 'sidebar.highlight_view'|trans({ '%title%': art.title|default('') })|e('html_attr') }}"
>
<span class="visually-hidden">{{ 'sidebar.highlight_view'|trans({ '%title%': art.title|default('') }) }}</span>

2
translations/messages.en.yaml

@ -8,7 +8,7 @@ topic: @@ -8,7 +8,7 @@ topic:
empty: 'No published articles with this tag yet.'
highlight:
section_title: 'Highlights'
section_lede: 'NIP-84 (kind 9802). The note’s `content` is the highlighted phrase. If a `context` tag is set, that tag is the full quote and the `content` text is marked inside it; otherwise only `content` is shown.'
section_lede: 'NIP-84 (kind 9802). The optional `context` tag is the full passage; `content` is the part marked inside it. If `context` is missing, only `content` is shown in a mark.'
popover: 'Highlight'
close: 'Close'
text:

Loading…
Cancel
Save