Browse Source

bug-fixes

imwald
Silberengel 4 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 {
} else { } else {
this._clearHoverLeaveTimer(); 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._placePopover(mark);
this.popoverTarget.hidden = false; this.popoverTarget.hidden = false;
} }

32
assets/styles/article.css

@ -429,9 +429,12 @@
} }
.article-body-highlight--target { .article-body-highlight--target {
box-shadow: inset 0 -2px 0 0 var(--color-secondary); box-shadow: none;
background: color-mix(in srgb, var(--color-secondary) 10%, transparent); background: transparent;
transition: background 0.35s ease, box-shadow 0.35s ease; 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 { .article-body-highlight:focus-visible {
@ -493,7 +496,7 @@
font-size: 0.86rem; 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 { .user-highlight__body {
margin: 0.35rem 0 0; margin: 0.35rem 0 0;
font-size: 0.95rem; font-size: 0.95rem;
@ -502,10 +505,23 @@
font-family: var(--main-body-font), serif; font-family: var(--main-body-font), serif;
} }
/* In-flow highlighter marker (NIP-84: `content` inside `context` quote) */ /* In-flow article body: interactive but no highlighter fill (cards below own the color) */
.article-main mark.user-highlight__marker, .article-main mark.user-highlight__marker {
.user-highlight__body mark.user-highlight__marker, margin: 0;
mark.user-highlight__marker { 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; margin: 0;
padding: 0.08em 0.1em 0.12em; padding: 0.08em 0.1em 0.12em;
border-radius: 0.12em; border-radius: 0.12em;

2
config/unfold.yaml

@ -21,6 +21,8 @@ parameters:
'wss://nostr.einundzwei.space' 'wss://nostr.einundzwei.space'
] ]
# Kind-0 / profile fetches (author metadata, prewarm). Tried first, then default + article_relays (deduped). # 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: [ profile_relays: [
'wss://relay.damus.io', 'wss://relay.damus.io',
'wss://nos.lol', 'wss://nos.lol',

1
src/Controller/ArticleController.php

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

6
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 * Card body HTML: the optional `context` tag is the full passage; the event `content` is
* shown with `content` marked inside it; otherwise the event `content` only in a <mark>. The * highlighted (marked) where it appears inside that text. If there is no `context` tag, only
* rendered article body still only wraps the `content` passage (see ArticleBodyHighlightInjector). * `content` is wrapped in a mark.
*/ */
public function getBodyHtml(): string public function getBodyHtml(): string
{ {

1
src/Service/HighlightSyncService.php

@ -65,6 +65,7 @@ final class HighlightSyncService
if (!\is_array($tags)) { if (!\is_array($tags)) {
$tags = []; $tags = [];
} }
$tags = HighlightEventTags::normalizeTagsForStorage($tags);
$content = (string) ($ev->content ?? ''); $content = (string) ($ev->content ?? '');
$ca = (int) ($ev->created_at ?? 0); $ca = (int) ($ev->created_at ?? 0);
if ($ca < 0) { if ($ca < 0) {

210
src/Service/NostrClient.php

@ -43,6 +43,12 @@ class NostrClient
*/ */
private const MAX_DISCUSSION_RELAY_URLS = 10; 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 * {@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. * 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; 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 * 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 * 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<object> unique wire events by id * @return list<object> unique wire events by id
*/ */
@ -1420,13 +1467,19 @@ class NostrClient
'author_relay_count' => \count($authorRelays), 'author_relay_count' => \count($authorRelays),
]); ]);
$baseForDiscussion = $this->configuredArticleRelayUrlList(); $baseArticle = $this->configuredArticleRelayUrlList();
$profileConfigured = $this->profileRelayUrlList();
$mergedForDiscussion = $this->withAggrNostrLandIfUserSubscribesNostrLand( $mergedForDiscussion = $this->withAggrNostrLandIfUserSubscribesNostrLand(
array_merge($baseForDiscussion, $authorRelays) array_merge($baseArticle, $profileConfigured, $authorRelays)
); );
$plannedRelayUrls = array_values(array_unique($mergedForDiscussion, \SORT_REGULAR)); $plannedRelayUrls = array_values(array_unique($mergedForDiscussion, \SORT_REGULAR));
if (\count($plannedRelayUrls) > self::MAX_DISCUSSION_RELAY_URLS) { $relayCountBeforeCap = \count($plannedRelayUrls);
$plannedRelayUrls = \array_slice($plannedRelayUrls, 0, self::MAX_DISCUSSION_RELAY_URLS); 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; $limH = 200;
$filters = []; $filters = [];
@ -2763,13 +2816,26 @@ class NostrClient
* The magazine root uses the site d_tag from config. Each category uses the full child d * 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 * (third segment of the root "a" address). A category 30040 lists 30023 article "a" tags, not
* further nested 30040 indices. * 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 public function getMagazineIndex(mixed $npub, mixed $dTag): ?PublicationEventEntity
{ {
$urls = $this->configuredArticleRelayUrlList(); $urls = $this->configuredArticleRelayUrlList();
$relaysForLog = implode(', ', array_map(self::relayLogLabel(...), $urls)); $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 private function queryMagazineIndex(mixed $npub, mixed $dTag, RelaySet $relaySet, string $relaysForLog): ?PublicationEventEntity
@ -2820,12 +2886,82 @@ class NostrClient
return self::magazineEventToPublicationEntity($raw); 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) * 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 * 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} * NIP-33 results are not stuck on a single relay’s copy. {@see saveEachArticleToTheDatabase}
* upserts by NIP-33 address. * 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 * @param list<string> $addresses kind:pubkey:identifier
*/ */
public function ingestLongformForCategoryCoordinates(array $addresses): void public function ingestLongformForCategoryCoordinates(array $addresses): void
@ -2955,6 +3091,63 @@ class NostrClient
$rawCount = \count($events); $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); $merged = self::mergeNip33ParameterizedWireEvents($events);
$mergedDetail = []; $mergedDetail = [];
foreach ($merged as $ev) { foreach ($merged as $ev) {
@ -2995,7 +3188,10 @@ class NostrClient
$byCoord = $this->getArticlesByCoordinates([$coordinate]); $byCoord = $this->getArticlesByCoordinates([$coordinate]);
$evExtra = $byCoord[$coordinate] ?? null; $evExtra = $byCoord[$coordinate] ?? null;
if ($evExtra === 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, 'coordinate' => $coordinate,
]); ]);

81
src/Util/HighlightEventTags.php

@ -5,14 +5,59 @@ declare(strict_types=1);
namespace App\Util; namespace App\Util;
/** /**
* NIP-84 (kind 9802): {@see buildHighlightedBodyHtml} drives list/hover-card HTML (full `context` * NIP-84 (kind 9802): optional `context` = full visible passage; `content` = highlighted range
* with `content` marked when both exist). In-article marks are applied separately and only wrap * (marked inside that passage when `context` exists, otherwise only `content` in a mark).
* the `content` substring in the article body ({@see \App\Service\ArticleBodyHighlightInjector}). * In-article marks: {@see \App\Service\ArticleBodyHighlightInjector}.
*/ */
final class HighlightEventTags final class HighlightEventTags
{ {
public const HIGHLIGHT_MARK_CLASS = 'user-highlight__marker'; 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). * 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 = []; $parts = [];
foreach ($tags as $t) { foreach ($tags as $t) {
if (!\is_array($t) || \count($t) < 2) { $row = self::nostrTagRowToList($t);
if (null === $row || \count($row) < 2) {
continue; continue;
} }
if (strtolower((string) ($t[0] ?? '')) !== 'context') { if (strtolower($row[0]) !== 'context') {
continue; continue;
} }
for ($i = 1, $c = \count($t); $i < $c; ++$i) { for ($i = 1, $c = \count($row); $i < $c; ++$i) {
$p = (string) ($t[$i] ?? ''); $p = $row[$i];
if ($p !== '') { if ($p !== '') {
$parts[] = $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 * With `context`, show the full quote and mark the `content` substring. With no `context`, wrap
* empty `context`, wrap all of `content` in one <mark>. * all of `content` in one mark.
* *
* @param string $contextQuote Text from the `context` tag. Empty means no surrounding quote. * @param string $contextQuote Text from the `context` tag. Empty means no surrounding quote.
* @param string $contentField The event’s `content` (highlighted phrase). * @param string $contentField The event’s `content` (highlighted phrase).
@ -83,6 +129,14 @@ final class HighlightEventTags
} }
private static function markHtml(string $innerText): string 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'); $e = \htmlspecialchars($innerText, \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8');
@ -97,14 +151,15 @@ final class HighlightEventTags
public static function excerptFromTextquoteselectorTags(array $tags): string public static function excerptFromTextquoteselectorTags(array $tags): string
{ {
foreach ($tags as $t) { foreach ($tags as $t) {
if (!\is_array($t) || \count($t) < 2) { $row = self::nostrTagRowToList($t);
if (null === $row || \count($row) < 2) {
continue; continue;
} }
if (strtolower((string) ($t[0] ?? '')) !== 'textquoteselector') { if (strtolower($row[0]) !== 'textquoteselector') {
continue; continue;
} }
for ($i = 1, $c = \count($t); $i < $c; ++$i) { for ($i = 1, $c = \count($row); $i < $c; ++$i) {
$p = \trim((string) ($t[$i] ?? '')); $p = \trim($row[$i]);
if ($p !== '') { if ($p !== '') {
return \mb_substr($p, 0, 400); return \mb_substr($p, 0, 400);
} }

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

@ -12,7 +12,7 @@
<div class="home-aside-highlights__item-inner"> <div class="home-aside-highlights__item-inner">
<a <a
class="home-aside-highlights__hit" 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') }}" 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> <span class="visually-hidden">{{ 'sidebar.highlight_view'|trans({ '%title%': art.title|default('') }) }}</span>

2
translations/messages.en.yaml

@ -8,7 +8,7 @@ topic:
empty: 'No published articles with this tag yet.' empty: 'No published articles with this tag yet.'
highlight: highlight:
section_title: 'Highlights' 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' popover: 'Highlight'
close: 'Close' close: 'Close'
text: text:

Loading…
Cancel
Save