Browse Source

bug-fixes

imwald
Silberengel 2 days ago
parent
commit
f34c6ed26b
  1. 6
      assets/bootstrap.js
  2. 63
      assets/controllers/nostr_preview_controller.js
  3. 239
      assets/controllers/user_highlight_tooltip_controller.js
  4. 85
      assets/styles/article.css
  5. 7
      assets/styles/layout.css
  6. 17
      src/Entity/ArticleHighlight.php
  7. 148
      src/Service/ArticleBodyHighlightInjector.php
  8. 148
      src/Util/HighlightEventTags.php
  9. 12
      templates/pages/article.html.twig

6
assets/bootstrap.js vendored

@ -2,6 +2,7 @@ import { startStimulusApp } from '@symfony/stimulus-bundle'; @@ -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 { @@ -23,3 +24,8 @@ try {
} catch {
/* already registered by the bundle */
}
try {
app.register('user-highlight-tooltip', UserHighlightTooltipController);
} catch {
/* already registered by the bundle */
}

63
assets/controllers/nostr_preview_controller.js

@ -3,6 +3,62 @@ import { Controller } from '@hotwired/stimulus'; @@ -3,6 +3,62 @@ import { Controller } from '@hotwired/stimulus';
const LOADING_HTML = `<div class="nostr-preview__loading text-center my-2"><span class="nostr-preview__spinner" role="status" aria-label="Loading"></span><span class="nostr-preview__loading-text ms-2">Loading preview…</span></div>`;
const UNAVAILABLE_HTML = `<div class="alert alert-warning my-2" role="status">Preview unavailable.</div>`;
/**
* @param {HTMLElement} el
* @param {string} type
* @param {string} decodedStr
* @returns {boolean}
*/
function isPreviewForSameArticleOnPage(el, type, decodedStr) {
const root = el.closest('[data-nostr-page-article-coordinate]');
if (!root) {
return false;
}
const pageCoord = root.getAttribute('data-nostr-page-article-coordinate') || '';
const pageEid = (root.getAttribute('data-nostr-page-article-event-id') || '').toLowerCase();
const pagePubHex = (root.getAttribute('data-nostr-page-article-pubkey-hex') || '').toLowerCase();
const pageNpub = root.getAttribute('data-nostr-page-article-npub') || '';
if (!pageCoord) {
return false;
}
let d;
try {
d = JSON.parse(decodedStr);
} catch {
return false;
}
if (type === 'naddr' && d && d.pubkey != null) {
const identRaw = d.identifier != null ? d.identifier : (d.specifier != null ? d.specifier : null);
if (identRaw == null) {
return false;
}
const k = d.kind != null ? parseInt(String(d.kind), 10) : 30023;
const ident = String(identRaw);
let pk = String(d.pubkey);
if (/^[0-9a-fA-F]{64}$/.test(pk)) {
pk = pk.toLowerCase();
} else if (pk.startsWith('npub1') && pageNpub) {
if (pk !== pageNpub) {
return false;
}
pk = pagePubHex;
} else {
return false;
}
if (!pk || pk.length !== 64) {
return false;
}
const candidate = `${k}:${pk}:${ident}`;
return candidate === pageCoord;
}
if (type === 'nevent' && d && d.id && pageEid) {
return String(d.id).toLowerCase() === pageEid;
}
return false;
}
export default class extends Controller {
static values = {
identifier: String,
@ -14,6 +70,13 @@ export default class extends Controller { @@ -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();
}

239
assets/controllers/user_highlight_tooltip_controller.js

@ -0,0 +1,239 @@ @@ -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`;
}
}

85
assets/styles/article.css

@ -459,3 +459,88 @@ @@ -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;
}

7
assets/styles/layout.css

@ -722,6 +722,13 @@ aside { @@ -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;

17
src/Entity/ArticleHighlight.php

@ -139,22 +139,25 @@ class ArticleHighlight @@ -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
);
}
}

148
src/Service/ArticleBodyHighlightInjector.php

@ -10,6 +10,7 @@ use DOMDocument; @@ -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 @@ -26,6 +27,11 @@ final class ArticleBodyHighlightInjector
private ?DOMElement $root = null;
public function __construct(
private readonly CacheService $cacheService,
) {
}
/**
* @param list<ArticleHighlight> $highlights
*
@ -48,12 +54,13 @@ final class ArticleBodyHighlightInjector @@ -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 @@ -153,19 +160,132 @@ final class ArticleBodyHighlightInjector
return null;
}
private function tryInjectOneHighlight(DOMElement $root, ArticleHighlight $h, string $eid): bool
/**
* @param list<ArticleHighlight> $group same highlight text; oldest first
*
* @return list<string> event ids that were applied
*/
private function tryInjectHighlightGroup(DOMElement $root, array $group): array
{
$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<ArticleHighlight> $sorted by created_at asc
*
* @return list<list<ArticleHighlight>>
*/
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<ArticleHighlight> $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 @@ -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 @@ -303,7 +423,8 @@ final class ArticleBodyHighlightInjector
$off,
$nLen,
$eventId,
0 === $i
0 === $i,
$authorJson
)) {
return false;
}
@ -375,7 +496,7 @@ final class ArticleBodyHighlightInjector @@ -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 @@ -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 === '') {

148
src/Util/HighlightEventTags.php

@ -5,9 +5,9 @@ declare(strict_types=1); @@ -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 @@ -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 @@ -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<mixed> $tags
*/
private static function valuesFromNostrTagName(array $tags, string $nameLower): string
{
$parts = [];
foreach ($tags as $t) {
@ -69,7 +101,8 @@ final class HighlightEventTags @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -295,6 +357,9 @@ final class HighlightEventTags
if ($hi === '') {
return self::escapeWithNl2br($q);
}
if ($q === $hi) {
return '<mark class="'.self::HIGHLIGHT_MARK_CLASS.'">'.self::escapeWithNl2br($q).'</mark>';
}
$span = self::findContentSpanInContext($q, $hi);
if (null !== $span) {
[$start, $end] = $span;
@ -309,6 +374,61 @@ final class HighlightEventTags @@ -309,6 +374,61 @@ final class HighlightEventTags
return self::escapeWithNl2br($q).'<p class="user-highlight__marker-orphan">'.self::markHtml($hi).'</p>';
}
/**
* For narrow list layouts (e.g. home aside with {@see buildHighlightedBodyHtml} + line-clamp): if the
* `content` is not at the start of the passage, drop the text before the highlight so the
* clamped block begins at (or a few characters before) the mark and the user actually sees
* the highlight.
*
* @param int $includeCharsOfContextBeforeHighlight Extra characters to keep before the
* highlight (0 = passage starts with `content`)
*/
public static function buildHighlightedBodyHtmlForNarrowList(
string $contextQuote,
string $contentField,
int $includeCharsOfContextBeforeHighlight = 0,
): string {
$q = \trim(self::normalizeLineEndingsForHighlight((string) $contextQuote));
$hi = \trim(self::normalizeLineEndingsForHighlight((string) $contentField));
if ($q === '' && $hi === '') {
return '';
}
if ($q === '' || $hi === '') {
return self::buildHighlightedBodyHtml($q, $hi);
}
if ($q === $hi) {
return self::buildHighlightedBodyHtml($q, $hi);
}
$span = self::findContentSpanInContext($q, $hi);
if (null === $span) {
return self::buildHighlightedBodyHtml($q, $hi);
}
[$st] = $span;
if (0 === $st) {
return self::buildHighlightedBodyHtml($q, $hi);
}
$lead = \max(0, $includeCharsOfContextBeforeHighlight);
$offset = \max(0, $st - $lead);
if (0 === $offset) {
return self::buildHighlightedBodyHtml($q, $hi);
}
$q2 = \mb_substr($q, $offset, null, 'UTF-8');
if ($q2 === '') {
return self::buildHighlightedBodyHtml($q, $hi);
}
$html = self::buildHighlightedBodyHtml($q2, $hi);
return self::omittedTextPrefixHtml().$html;
}
/**
* Safe “earlier text omitted” marker before a truncated passage in list cards.
*/
public static function omittedTextPrefixHtml(): string
{
return '<span class="user-highlight__elide" aria-hidden="true">&#8230;</span> ';
}
public static function escapeWithNl2br(string $s): string
{
return \nl2br(\htmlspecialchars($s, \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8'), false);
@ -341,7 +461,7 @@ final class HighlightEventTags @@ -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) {

12
templates/pages/article.html.twig

@ -59,7 +59,14 @@ @@ -59,7 +59,14 @@
{% endblock %}
{% block body %}
<div class="article-page-root">
{% set article_coordinate = (article.kind ? article.kind.value : 30023) ~ ':' ~ (article.pubkey|lower) ~ ':' ~ article.slug %}
<div
class="article-page-root"
data-nostr-page-article-coordinate="{{ article_coordinate|e('html_attr') }}"
data-nostr-page-article-pubkey-hex="{{ article.pubkey|lower|e('html_attr') }}"
data-nostr-page-article-npub="{{ (npub|default(''))|e('html_attr') }}"
data-nostr-page-article-event-id="{{ article.eventId|default('')|e('html_attr') }}"
>
{% if is_granted('ROLE_ADMIN') %}
<button class="btn btn-primary" onclick="navigator.clipboard.writeText('30023:{{ article.pubkey }}:{{ article.slug }}')">
@ -98,7 +105,7 @@ @@ -98,7 +105,7 @@
</div>
{% endif %}
<div class="article-main">
<div class="article-main" data-controller="user-highlight-tooltip">
{{ content|raw }}
</div>
@ -118,7 +125,6 @@ @@ -118,7 +125,6 @@
{# <pre>#}
{# {{ article.content }}#}
{# </pre>#}
{% 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 %}

Loading…
Cancel
Save