From f34c6ed26be4700837c4264011d3d4d3289a8de5 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Sun, 26 Apr 2026 13:05:36 +0200 Subject: [PATCH] bug-fixes --- assets/bootstrap.js | 6 + .../controllers/nostr_preview_controller.js | 63 +++++ .../user_highlight_tooltip_controller.js | 239 ++++++++++++++++++ assets/styles/article.css | 85 +++++++ assets/styles/layout.css | 7 + src/Entity/ArticleHighlight.php | 17 +- src/Service/ArticleBodyHighlightInjector.php | 148 ++++++++++- src/Util/HighlightEventTags.php | 148 ++++++++++- templates/pages/article.html.twig | 12 +- 9 files changed, 689 insertions(+), 36 deletions(-) create mode 100644 assets/controllers/user_highlight_tooltip_controller.js diff --git a/assets/bootstrap.js b/assets/bootstrap.js index 71f0f99..ea1a1c0 100644 --- a/assets/bootstrap.js +++ b/assets/bootstrap.js @@ -2,6 +2,7 @@ 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; @@ -23,3 +24,8 @@ try { } catch { /* already registered by the bundle */ } +try { + app.register('user-highlight-tooltip', UserHighlightTooltipController); +} catch { + /* already registered by the bundle */ +} diff --git a/assets/controllers/nostr_preview_controller.js b/assets/controllers/nostr_preview_controller.js index b5b5f24..757ccc6 100644 --- a/assets/controllers/nostr_preview_controller.js +++ b/assets/controllers/nostr_preview_controller.js @@ -3,6 +3,62 @@ import { Controller } from '@hotwired/stimulus'; const LOADING_HTML = `
Loading preview…
`; const UNAVAILABLE_HTML = `
Preview unavailable.
`; +/** + * @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, @@ -14,6 +70,13 @@ export default class extends Controller { 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(); } diff --git a/assets/controllers/user_highlight_tooltip_controller.js b/assets/controllers/user_highlight_tooltip_controller.js new file mode 100644 index 0000000..3ae32e5 --- /dev/null +++ b/assets/controllers/user_highlight_tooltip_controller.js @@ -0,0 +1,239 @@ +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); + } + + 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); + 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`; + } +} diff --git a/assets/styles/article.css b/assets/styles/article.css index c9c259c..9b1b080 100644 --- a/assets/styles/article.css +++ b/assets/styles/article.css @@ -459,3 +459,88 @@ 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; +} diff --git a/assets/styles/layout.css b/assets/styles/layout.css index ec07c22..972a2a1 100644 --- a/assets/styles/layout.css +++ b/assets/styles/layout.css @@ -722,6 +722,13 @@ aside { 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; diff --git a/src/Entity/ArticleHighlight.php b/src/Entity/ArticleHighlight.php index 63a3798..001f0b3 100644 --- a/src/Entity/ArticleHighlight.php +++ b/src/Entity/ArticleHighlight.php @@ -139,22 +139,25 @@ class ArticleHighlight return $this; } - /** The full quote from the optional `context` tag. Event `content` is highlighted *inside* this when present. */ + /** The full quote from the `context` tag (empty if absent). */ public function getContextText(): string { return HighlightEventTags::contextFromTags($this->tags); } /** - * 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. + * 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 { - return HighlightEventTags::buildHighlightedBodyHtml( - $this->getContextText(), - (string) $this->getContent() + $c = (string) $this->getContent(); + + return HighlightEventTags::buildHighlightedBodyHtmlForNarrowList( + HighlightEventTags::fullPassageForHighlightDisplay($c, $this->tags), + $c, + 0 ); } } diff --git a/src/Service/ArticleBodyHighlightInjector.php b/src/Service/ArticleBodyHighlightInjector.php index 8420fe6..8865ff6 100644 --- a/src/Service/ArticleBodyHighlightInjector.php +++ b/src/Service/ArticleBodyHighlightInjector.php @@ -10,6 +10,7 @@ use DOMDocument; use DOMElement; use DOMText; use DOMXPath; +use swentel\nostr\Key\Key; /** * Injects kind-9802 highlight ranges into the rendered article body by finding each event’s @@ -26,6 +27,11 @@ final class ArticleBodyHighlightInjector private ?DOMElement $root = null; + public function __construct( + private readonly CacheService $cacheService, + ) { + } + /** * @param list $highlights * @@ -48,12 +54,13 @@ final class ArticleBodyHighlightInjector } $injected = []; - foreach ($sorted as $h) { - $eid = \strtolower($h->getEventId()); - if (64 !== \strlen($eid) || !ctype_xdigit($eid)) { + $groups = $this->groupHighlightsForInjection($sorted); + foreach ($groups as $group) { + if ($group === []) { continue; } - if ($this->tryInjectOneHighlight($this->root, $h, $eid)) { + $added = $this->tryInjectHighlightGroup($this->root, $group); + foreach ($added as $eid) { $injected[] = $eid; } } @@ -153,19 +160,132 @@ final class ArticleBodyHighlightInjector return null; } - private function tryInjectOneHighlight(DOMElement $root, ArticleHighlight $h, string $eid): bool + /** + * @param list $group same highlight text; oldest first + * + * @return list event ids that were applied + */ + private function tryInjectHighlightGroup(DOMElement $root, array $group): array { - $resolved = $this->resolveInjectionNeedle($h); + 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); + $resolved = $this->resolveInjectionNeedle($first); foreach ($this->needleSearchVariants($resolved) as $needle) { if ($needle === '') { continue; } - if ($this->tryWrapInDocument($root, $needle, $eid)) { - return true; + if ($this->tryWrapInDocument($root, $needle, $eid, $authorJson)) { + return $outEids; + } + } + + return []; + } + + /** + * @param list $sorted by created_at asc + * + * @return list> + */ + private function groupHighlightsForInjection(array $sorted): array + { + $buckets = []; + foreach ($sorted as $h) { + $resolved = $this->resolveInjectionNeedle($h); + if ($resolved === '') { + continue; + } + $key = HighlightEventTags::stringForSearch(\trim($resolved)); + if ($key === '') { + $key = 'x'.\md5($resolved); + } + 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 false; + return $ta <=> $tb; + } + ); + + return $groups; + } + + /** + * NIP-84: same highlighted passage → one mark, dedupe authors by npub, profile from cache. + * + * @param list $group + */ + private function buildHighlightAuthorsJson(array $group): string + { + $key = new Key(); + $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 = $key->convertPublicKeyToBech32($pk); + } catch (\Throwable) { + continue; + } + if (isset($byNpub[$npub])) { + continue; + } + $name = ''; + $pic = ''; + try { + $meta = $this->cacheService->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); } private function resolveInjectionNeedle(ArticleHighlight $h): string @@ -224,7 +344,7 @@ final class ArticleBodyHighlightInjector ]); } - private function tryWrapInDocument(DOMElement $root, string $needle, string $eventId): bool + private function tryWrapInDocument(DOMElement $root, string $needle, string $eventId, string $authorJson = ''): bool { $textNodes = $this->collectTextNodes($root); if ($textNodes === []) { @@ -303,7 +423,8 @@ final class ArticleBodyHighlightInjector $off, $nLen, $eventId, - 0 === $i + 0 === $i, + $authorJson )) { return false; } @@ -375,7 +496,7 @@ final class ArticleBodyHighlightInjector return true; } - private function wrapTextSlice(DOMText $textNode, int $uOffset, int $uLength, string $eventId, bool $firstInReadingOrder): bool + private function wrapTextSlice(DOMText $textNode, int $uOffset, int $uLength, string $eventId, bool $firstInReadingOrder, string $authorJson = ''): bool { if ($uLength < 1) { return false; @@ -407,6 +528,9 @@ final class ArticleBodyHighlightInjector 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 === '') { diff --git a/src/Util/HighlightEventTags.php b/src/Util/HighlightEventTags.php index 71daaf7..29de33e 100644 --- a/src/Util/HighlightEventTags.php +++ b/src/Util/HighlightEventTags.php @@ -5,9 +5,9 @@ declare(strict_types=1); namespace App\Util; /** - * 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}. + * 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 { @@ -22,11 +22,13 @@ final class HighlightEventTags public static function nostrTagRowToList(mixed $tag): ?array { if (\is_object($tag)) { - $tag = \array_values((array) $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; @@ -62,6 +64,36 @@ final class HighlightEventTags * 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 $tags + */ + private static function valuesFromNostrTagName(array $tags, string $nameLower): string { $parts = []; foreach ($tags as $t) { @@ -69,7 +101,8 @@ final class HighlightEventTags if (null === $row || \count($row) < 2) { continue; } - if (strtolower($row[0]) !== 'context') { + $k = self::normalizeNostrTagKey($row[0]); + if ($k !== $nameLower) { continue; } for ($i = 1, $c = \count($row); $i < $c; ++$i) { @@ -87,6 +120,14 @@ final class HighlightEventTags 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 @@ -155,11 +196,14 @@ final class HighlightEventTags * 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 = self::normalizeLineEndingsForHighlight($context); + $q = $context; if ($q === '' || $content === '') { return null; } @@ -175,6 +219,26 @@ final class HighlightEventTags 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); @@ -274,18 +338,16 @@ final class HighlightEventTags } /** - * 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). + * @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 = self::normalizeLineEndingsForHighlight((string) $contextQuote); - $hi = self::normalizeLineEndingsForHighlight((string) $contentField); + $q = \trim(self::normalizeLineEndingsForHighlight((string) $contextQuote)); + $hi = \trim(self::normalizeLineEndingsForHighlight((string) $contentField)); if ($q === '' && $hi === '') { return ''; } @@ -295,6 +357,9 @@ final class HighlightEventTags if ($hi === '') { return self::escapeWithNl2br($q); } + if ($q === $hi) { + return ''.self::escapeWithNl2br($q).''; + } $span = self::findContentSpanInContext($q, $hi); if (null !== $span) { [$start, $end] = $span; @@ -309,6 +374,61 @@ final class HighlightEventTags return self::escapeWithNl2br($q).'

'.self::markHtml($hi).'

'; } + /** + * 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 ' '; + } + public static function escapeWithNl2br(string $s): string { return \nl2br(\htmlspecialchars($s, \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8'), false); @@ -341,7 +461,7 @@ final class HighlightEventTags if (null === $row || \count($row) < 2) { continue; } - if (strtolower($row[0]) !== 'textquoteselector') { + if (self::normalizeNostrTagKey($row[0]) !== 'textquoteselector') { continue; } for ($i = 1, $c = \count($row); $i < $c; ++$i) { diff --git a/templates/pages/article.html.twig b/templates/pages/article.html.twig index 737b2ad..9aaeb9a 100644 --- a/templates/pages/article.html.twig +++ b/templates/pages/article.html.twig @@ -59,7 +59,14 @@ {% endblock %} {% block body %} -
+{% set article_coordinate = (article.kind ? article.kind.value : 30023) ~ ':' ~ (article.pubkey|lower) ~ ':' ~ article.slug %} +
{% if is_granted('ROLE_ADMIN') %}
{% endif %} -
+
{{ content|raw }}
@@ -118,7 +125,6 @@ {#
#}
 {#        {{ article.content }}#}
 {#    
#} - {% set article_coordinate = (article.kind ? article.kind.value : 30023) ~ ':' ~ article.pubkey ~ ':' ~ article.slug %} {% set comments_query = { coordinate: article_coordinate, title: article.title|default('') }|merge(article.eventId ? { e: article.eventId } : {}) %} {% set _reply_ctx = comments_data.comment_reply_context|default(comment_reply_context|default(null)) %} {% include 'components/Molecules/ArticleReplyComposer.html.twig' with { comment_reply_context: _reply_ctx } only %}