From 0094939adcd19140b179a23fb0b32b040cb5bd0c Mon Sep 17 00:00:00 2001 From: Silberengel Date: Thu, 23 Apr 2026 10:20:23 +0200 Subject: [PATCH] implement replies --- assets/bootstrap.js | 6 + .../controllers/comment_reply_controller.js | 160 ++++++++++++++++ assets/styles/article.css | 63 +++++++ importmap.php | 3 + src/Controller/ArticleController.php | 93 ++++++++++ src/Controller/CommentReplyController.php | 56 ++++++ src/Nostr/Nip22CommentTags.php | 137 ++++++++++++++ src/Service/ArticleCommentThreadLoader.php | 3 +- src/Service/CacheService.php | 3 +- src/Service/CommentReplyService.php | 174 ++++++++++++++++++ src/Service/NostrClient.php | 121 +++++++++++- .../components/Organisms/Comments.html.twig | 88 +++++++++ templates/pages/article.html.twig | 6 +- 13 files changed, 905 insertions(+), 8 deletions(-) create mode 100644 assets/controllers/comment_reply_controller.js create mode 100644 src/Controller/CommentReplyController.php create mode 100644 src/Nostr/Nip22CommentTags.php create mode 100644 src/Service/CommentReplyService.php diff --git a/assets/bootstrap.js b/assets/bootstrap.js index 524e49c..b85d1d0 100644 --- a/assets/bootstrap.js +++ b/assets/bootstrap.js @@ -1,5 +1,6 @@ import { startStimulusApp } from '@symfony/stimulus-bundle'; import ArticleCommentsController from './controllers/article_comments_controller.js'; +import CommentReplyController from './controllers/comment_reply_controller.js'; import MagazineSyncController from './controllers/magazine_sync_controller.js'; const app = startStimulusApp(); @@ -15,3 +16,8 @@ try { } catch { /* already registered by the bundle */ } +try { + app.register('comment-reply', CommentReplyController); +} catch { + /* already registered by the bundle */ +} diff --git a/assets/controllers/comment_reply_controller.js b/assets/controllers/comment_reply_controller.js new file mode 100644 index 0000000..24856f0 --- /dev/null +++ b/assets/controllers/comment_reply_controller.js @@ -0,0 +1,160 @@ +import { Controller } from '@hotwired/stimulus'; + +/** + * Builds a NIP-22 kind-1111 event (blurb + body), signs with NIP-07, POSTs to /comment/publish. + */ +export default class extends Controller { + static targets = ['hint']; + + static values = { + publishUrl: String, + csrf: String, + expectedCoordinate: String, + articleEventId: String, + fragmentUrl: String, + refreshAfter: { type: Boolean, default: true }, + blurbLabel: String, + expectedTags: Array, + parentKind: Number, + parentId: String, + authorPubkey: String, + }; + + connect() { + this._tags = this.expectedTagsValue; + if (!Array.isArray(this._tags)) { + const raw = this.element.getAttribute('data-comment-reply-expected-tags-value'); + try { + this._tags = raw ? JSON.parse(raw) : []; + } catch { + this._tags = []; + } + } + } + + /** + * @param {Event} ev + */ + async publish(ev) { + ev.preventDefault(); + if (!this.hasNip07()) { + this.setHint('Install a Nostr extension (NIP-07) to sign comments.'); + return; + } + const ta = this.element.querySelector('textarea[name="body"]'); + const text = (ta?.value ?? '').trim(); + if (!text) { + this.setHint('Write something first.'); + return; + } + if (this._tags.length === 0) { + this.setHint('Missing NIP-22 tag template.'); + return; + } + this.setHint('Preparing event…'); + const { nip19 } = await import('nostr-tools'); + const link = this.buildParentBech32(nip19); + const blurb = `> Replying to **${this.blurbLabelValue}** — [view parent](nostr:${link})\n\n`; + const unsigned = { + kind: 1111, + created_at: Math.floor(Date.now() / 1000), + tags: this._tags, + content: blurb + text, + }; + let signed; + try { + signed = await window.nostr.signEvent(unsigned); + } catch (err) { + this.setHint(`Signing failed: ${err instanceof Error ? err.message : String(err)}`); + return; + } + this.setHint('Publishing…'); + const payload = { + event: signed, + expected_coordinate: this.expectedCoordinateValue, + parent_kind: parseInt(String(this.parentKindValue), 10), + parent_id: this.parentIdValue, + article_event_id: this.articleEventIdValue || null, + csrf: this.csrfValue, + }; + let res; + try { + res = await fetch(this.publishUrlValue, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'X-CSRF-TOKEN': this.csrfValue, + }, + credentials: 'same-origin', + body: JSON.stringify(payload), + }); + } catch (err) { + this.setHint(`Network error: ${err instanceof Error ? err.message : String(err)}`); + return; + } + const data = await res.json().catch(() => ({})); + if (!res.ok) { + this.setHint(data.error || `HTTP ${res.status}`); + return; + } + this.setHint('Published. It may take a short time to show on all relays.'); + if (ta) { + ta.value = ''; + } + if (this.refreshAfterValue && this.fragmentUrlValue) { + this.refreshThread(); + } + } + + hasNip07() { + return typeof window.nostr !== 'undefined' && typeof window.nostr.signEvent === 'function'; + } + + /** + * @param {import('nostr-tools').nip19} nip19 + */ + buildParentBech32(nip19) { + const allZero = /^0{64}$/.test(this.parentIdValue); + const parts = (this.expectedCoordinateValue || '').split(':'); + const k = parts[0] ? parseInt(parts[0], 10) : 30023; + const pub = parts[1] || this.authorPubkeyValue; + const d = parts[2] || ''; + if (allZero && d !== '') { + return nip19.naddrEncode({ kind: k, pubkey: pub, identifier: d, relays: [] }); + } + return nip19.neventEncode({ + id: this.parentIdValue, + kind: this.parentKindValue, + pubkey: this.authorPubkeyValue, + relays: [], + }); + } + + refreshThread() { + const el = document.querySelector('[data-article-comments-url-value]'); + const u = el?.getAttribute('data-article-comments-url-value'); + const container = document.querySelector('[data-article-comments-target="container"]'); + if (!u || !container) { + window.location.reload(); + return; + } + void fetch(u, { headers: { Accept: 'text/html', 'X-Requested-With': 'XMLHttpRequest' } }) + .then((r) => (r.ok ? r.text() : Promise.reject(new Error(String(r.status))))) + .then((html) => { + container.innerHTML = html; + }) + .catch(() => { + window.location.reload(); + }); + } + + /** + * @param {string} msg + */ + setHint(msg) { + if (this.hasHintTarget) { + this.hintTarget.textContent = msg; + } + } +} diff --git a/assets/styles/article.css b/assets/styles/article.css index bbe57f7..c208f79 100644 --- a/assets/styles/article.css +++ b/assets/styles/article.css @@ -127,3 +127,66 @@ blockquote p { flex-wrap: wrap; gap: 0.35rem; } + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + border: 0; + white-space: nowrap; +} + +.comment-reply { + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid var(--color-border); +} + +.comment-reply--article { + margin-bottom: 1.5rem; + border: 1px solid var(--color-border); + border-radius: 6px; + border-top: 1px solid var(--color-border); +} + +.comment-reply__heading { + font-size: 1.05rem; + margin: 0 0 0.75rem; +} + +.comment-reply--nested { + margin-top: 1rem; +} + +.comment-reply__head { + font-size: 0.9rem; + margin-bottom: 0.35rem; +} + +.comment-reply__body .form-control { + width: 100%; + max-width: 100%; + box-sizing: border-box; + padding: 0.5rem 0.65rem; + border: 1px solid var(--color-border); + border-radius: 6px; + background: var(--color-bg); + color: var(--color-text); + font: inherit; + line-height: 1.45; + min-height: 4.5rem; + resize: vertical; +} + +.comment-reply__actions { + margin-top: 0.5rem; +} + +.comment-reply__hint { + font-size: 0.9rem; + margin: 0.5rem 0 0; +} diff --git a/importmap.php b/importmap.php index 0dd06cc..45613a9 100644 --- a/importmap.php +++ b/importmap.php @@ -49,6 +49,9 @@ return [ 'lodash.isequal' => [ 'version' => '4.5.0', ], + 'nostr-tools' => [ + 'version' => '2.10.4', + ], 'quill/dist/quill.core.css' => [ 'version' => '2.0.3', 'type' => 'css', diff --git a/src/Controller/ArticleController.php b/src/Controller/ArticleController.php index 799cef7..9bc7599 100644 --- a/src/Controller/ArticleController.php +++ b/src/Controller/ArticleController.php @@ -4,6 +4,7 @@ namespace App\Controller; use App\Entity\Article; use App\Enum\KindsEnum; +use App\Nostr\Nip22CommentTags; use App\Form\EditorType; use App\Service\ArticleCommentThreadLoader; use App\Service\NostrClient; @@ -49,6 +50,40 @@ class ArticleController extends AbstractController $articleEventId = null; } + $articleTitle = $request->query->getString('title'); + if (strlen($articleTitle) > 200) { + $articleTitle = substr($articleTitle, 0, 200); + } + $coordparts = explode(':', $coordinate, 3); + $articleKind = isset($coordparts[0]) && ctype_digit($coordparts[0]) ? (int) $coordparts[0] : 30023; + $articleAuthorPubkey = $coordparts[1] ?? ''; + + $articleReplyTags = null; + if ($articleAuthorPubkey !== '' && 64 === \strlen($articleAuthorPubkey) && ctype_xdigit($articleAuthorPubkey)) { + $articleReplyTags = Nip22CommentTags::forReplyToArticle($coordinate, $articleAuthorPubkey); + } + + $parentIdForNaddr = str_repeat('0', 64); + $articleParentId = $articleEventId ?? $parentIdForNaddr; + if ($articleEventId !== null && 64 === \strlen($articleEventId) && ctype_xdigit($articleEventId)) { + $articleParentId = $articleEventId; + } else { + $articleParentId = $parentIdForNaddr; + } + + $threadReplyRows = []; + $userMayReply = $this->isGranted('ROLE_USER'); + if ($userMayReply && $articleReplyTags !== null) { + $threadReplyRows[] = [ + 'mode' => 'article', + 'blurbLabel' => $articleTitle !== '' ? $articleTitle : 'Article', + 'parentKind' => $articleKind, + 'parentId' => $articleParentId, + 'authorPubkey' => $articleAuthorPubkey, + 'expectedTags' => $articleReplyTags, + ]; + } + $logger->info('http.fragment.comments_start', [ 'coordinate' => $coordinate, 'article_event_hex' => $articleEventId, @@ -61,11 +96,69 @@ class ArticleController extends AbstractController try { $data = $loader->load($coordinate, $articleEventId); + if ($userMayReply && $articleReplyTags !== null) { + /** @var array $list */ + $list = $data['list'] ?? []; + foreach ($list as $row) { + if (!\is_object($row)) { + continue; + } + $k = (int) ($row->kind ?? 0); + if ($k !== KindsEnum::COMMENTS->value) { + continue; + } + $cid = (string) ($row->id ?? ''); + $cpk = (string) ($row->pubkey ?? ''); + if ($cid === '' || 64 !== \strlen($cid) || !ctype_xdigit($cid)) { + continue; + } + if ($cpk === '' || 64 !== \strlen($cpk) || !ctype_xdigit($cpk)) { + continue; + } + $rawTags = json_decode(json_encode($row->tags ?? []), true); + if (!\is_array($rawTags)) { + $rawTags = []; + } + $snippet = trim((string) ($row->content ?? '')); + if (strlen($snippet) > 120) { + $snippet = substr($snippet, 0, 117).'…'; + } + if ($snippet === '') { + $snippet = 'Comment'; + } + try { + $expectedTags = Nip22CommentTags::forReplyToComment($cid, $cpk, $k, $rawTags); + } catch (\Throwable) { + continue; + } + $threadReplyRows[] = [ + 'mode' => 'comment', + 'blurbLabel' => $snippet, + 'parentKind' => $k, + 'parentId' => $cid, + 'authorPubkey' => $cpk, + 'expectedTags' => $expectedTags, + ]; + } + } $logger->info('http.fragment.comments_after_load', [ 'elapsed_ms' => (int) round((microtime(true) - $t0) * 1000), ]); $tRender = microtime(true); + $fragmentQuery = ['coordinate' => $coordinate, 'title' => $articleTitle]; + if ($articleEventId !== null) { + $fragmentQuery['e'] = $articleEventId; + } + $data['comment_reply_context'] = [ + 'can_publish' => $userMayReply, + 'coordinate' => $coordinate, + 'article_event_id' => $articleEventId, + 'parent_kind' => $articleKind, + 'rows' => $threadReplyRows, + 'fragment_url' => $this->generateUrl('article_comments_fragment', $fragmentQuery), + ]; + $response = $this->render('components/Organisms/Comments.html.twig', $data, new Response( '', Response::HTTP_OK, diff --git a/src/Controller/CommentReplyController.php b/src/Controller/CommentReplyController.php new file mode 100644 index 0000000..d8ff27b --- /dev/null +++ b/src/Controller/CommentReplyController.php @@ -0,0 +1,56 @@ +getContent(); + if ($raw === '') { + return $this->json(['ok' => false, 'error' => 'Empty body'], Response::HTTP_BAD_REQUEST); + } + try { + /** @var array $data */ + $data = json_decode($raw, true, 512, \JSON_THROW_ON_ERROR); + } catch (\JsonException) { + return $this->json(['ok' => false, 'error' => 'Invalid JSON'], Response::HTTP_BAD_REQUEST); + } + + $token = $data['csrf'] ?? $request->headers->get('X-CSRF-TOKEN') ?? ''; + if (!\is_string($token) || !$this->isCsrfTokenValid('comment_reply', $token)) { + return $this->json(['ok' => false, 'error' => 'Invalid CSRF token'], Response::HTTP_BAD_REQUEST); + } + + $user = $this->getUser(); + if (!$user instanceof User) { + return $this->json(['ok' => false, 'error' => 'Not logged in'], Response::HTTP_UNAUTHORIZED); + } + + $out = $commentReply->publishFromRequestPayload($user, $data); + if ($out['ok'] === true) { + return $this->json(['ok' => true, 'id' => $out['id']]); + } + + /** @var array{ok: false, error: string, code: int} $out */ + return $this->json(['ok' => false, 'error' => $out['error']], $out['code']); + } +} diff --git a/src/Nostr/Nip22CommentTags.php b/src/Nostr/Nip22CommentTags.php new file mode 100644 index 0000000..c8ca60f --- /dev/null +++ b/src/Nostr/Nip22CommentTags.php @@ -0,0 +1,137 @@ +> + */ + public static function forReplyToArticle(string $coordinate, string $articleAuthorPubkeyHex): array + { + $parts = explode(':', $coordinate, 2); + $k = \count($parts) >= 1 && ctype_digit((string) $parts[0]) ? (string) (int) $parts[0] : '30023'; + + return [ + ['A', $coordinate, ''], + ['P', $articleAuthorPubkeyHex], + ['K', $k], + ['a', $coordinate, ''], + ['k', $k], + ['p', $articleAuthorPubkeyHex], + ]; + } + + /** + *_reply under another kind-1111 comment. Root A/E/P/K (and i/I) are taken from the parent + * event's tags; the final e/k/p point at the immediate parent comment. + * + * @param list>|array $rawTags + * @return list> + */ + public static function forReplyToComment( + string $parentCommentIdHex, + string $parentCommentAuthorPubkeyHex, + int $parentCommentKind, + array $rawTags + ): array { + if ($parentCommentKind !== 1111) { + throw new \InvalidArgumentException('Parent must be NIP-22 kind 1111'); + } + if (self::isInvalidHexId($parentCommentIdHex) || self::isInvalidHexPubkey($parentCommentAuthorPubkeyHex)) { + throw new \InvalidArgumentException('Invalid parent id or pubkey'); + } + + $A = self::firstTag($rawTags, 'A', 'a'); + $E = self::firstTag($rawTags, 'E', 'e'); + $P = self::firstTag($rawTags, 'P', 'p'); + $K = self::firstTag($rawTags, 'K', 'k'); + $Iu = self::firstTag($rawTags, 'I', 'i'); + + $out = []; + if ($A !== null) { + $out[] = $A; + } + if ($E !== null) { + $out[] = self::ensureTagName($E, 'E'); + } + if ($P !== null) { + $out[] = self::ensureTagName($P, 'P'); + } + if ($K !== null) { + $out[] = self::ensureTagName($K, 'K'); + } + if ($Iu !== null) { + $out[] = self::ensureTagName($Iu, 'I'); + $out[] = self::ensureTagName($Iu, 'i'); + } + + $out[] = ['e', $parentCommentIdHex, '', $parentCommentAuthorPubkeyHex]; + $out[] = ['k', (string) $parentCommentKind]; + $out[] = ['p', $parentCommentAuthorPubkeyHex]; + + return $out; + } + + /** + * @param list> $rawTags + * @return list|null + */ + private static function firstTag(array $rawTags, string $upper, string $lower): ?array + { + foreach ([$upper, $lower] as $n) { + foreach ($rawTags as $row) { + if (!\is_array($row) || ($row[0] ?? null) === null) { + continue; + } + if ((string) $row[0] === $n) { + $norm = array_map( + static fn (mixed $c): string => \is_string($c) || \is_int($c) || \is_float($c) ? (string) $c : '', + $row + ); + if ($n === $lower) { + $norm[0] = $lower; + } + if ($n === $upper) { + $norm[0] = $upper; + } + + return $norm; + } + } + } + + return null; + } + + /** + * @param list $tag + * @return list + */ + private static function ensureTagName(array $tag, string $name): array + { + $out = $tag; + $out[0] = $name; + + return $out; + } + + private static function isInvalidHexId(string $h): bool + { + return 64 !== \strlen($h) || !ctype_xdigit($h); + } + + private static function isInvalidHexPubkey(string $h): bool + { + return 64 !== \strlen($h) || !ctype_xdigit($h); + } +} diff --git a/src/Service/ArticleCommentThreadLoader.php b/src/Service/ArticleCommentThreadLoader.php index 529ddd6..ee09227 100644 --- a/src/Service/ArticleCommentThreadLoader.php +++ b/src/Service/ArticleCommentThreadLoader.php @@ -33,7 +33,8 @@ final readonly class ArticleCommentThreadLoader public function load(string $coordinate, ?string $articleEventHexId = null): array { $t0 = microtime(true); - $cacheKey = 'comments_v4_'.hash('sha256', $coordinate."\0".($articleEventHexId ?? '')); + $aggrSuffix = $this->nostrClient->getNostrLandAggrReaderCacheSuffix(); + $cacheKey = 'comments_v5_'.hash('sha256', $coordinate."\0".($articleEventHexId ?? '')."\0".$aggrSuffix); $this->logger->info('comments.loader.start', [ 'cache_key_suffix' => substr($cacheKey, -16), 'coordinate' => $coordinate, diff --git a/src/Service/CacheService.php b/src/Service/CacheService.php index 522f823..f2250d1 100644 --- a/src/Service/CacheService.php +++ b/src/Service/CacheService.php @@ -25,7 +25,8 @@ readonly class CacheService */ public function getMetadata(string $npub): \stdClass { - $cacheKey = '0_' . $npub; + $aggr = $this->nostrClient->getNostrLandAggrReaderCacheSuffix(); + $cacheKey = $aggr === '' ? '0_'.$npub : '0_'.$aggr.'_'.$npub; try { return $this->cache->get($cacheKey, function (ItemInterface $item) use ($npub) { $item->expiresAfter(3600); // 1 hour, adjust as needed diff --git a/src/Service/CommentReplyService.php b/src/Service/CommentReplyService.php new file mode 100644 index 0000000..9867fac --- /dev/null +++ b/src/Service/CommentReplyService.php @@ -0,0 +1,174 @@ + $payload Decoded JSON body + * + * @return array{ok: true, id: string, relays: array}|array{ok: false, error: string, code: int} + */ + public function publishFromRequestPayload(User $user, array $payload): array + { + $raw = $payload['event'] ?? null; + if (!\is_array($raw)) { + return ['ok' => false, 'error' => 'Missing event', 'code' => 400]; + } + $expectedCoordinate = isset($payload['expected_coordinate']) && \is_string($payload['expected_coordinate']) + ? $payload['expected_coordinate'] + : ''; + if ($expectedCoordinate === '' || 3 !== \count(explode(':', $expectedCoordinate, 3))) { + return ['ok' => false, 'error' => 'Invalid expected_coordinate', 'code' => 400]; + } + + $parentKind = $payload['parent_kind'] ?? null; + $parentId = isset($payload['parent_id']) && \is_string($payload['parent_id']) ? $payload['parent_id'] : ''; + if (!\is_int($parentKind) && !\is_string($parentKind)) { + return ['ok' => false, 'error' => 'Invalid parent_kind', 'code' => 400]; + } + $parentKind = (int) $parentKind; + if ($parentId === '' || 64 !== \strlen($parentId) || !ctype_xdigit($parentId)) { + return ['ok' => false, 'error' => 'Invalid parent_id', 'code' => 400]; + } + + if (isset($payload['article_event_id']) && \is_string($payload['article_event_id']) && $payload['article_event_id'] !== '') { + $g = $payload['article_event_id']; + if (64 !== \strlen($g) || !ctype_xdigit($g)) { + return ['ok' => false, 'error' => 'Invalid article_event_id', 'code' => 400]; + } + } + + $wire = NostrWireEvent::fromVerified(\json_encode($raw, \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE)); + if ($wire === null) { + return ['ok' => false, 'error' => 'Invalid or unverifiable event', 'code' => 400]; + } + + if ($wire->getKind() !== KindsEnum::COMMENTS->value) { + return ['ok' => false, 'error' => 'Event must be kind 1111', 'code' => 400]; + } + + $now = time(); + if ($now - $wire->getCreatedAt() > self::STALE_EVENT_MAX_AGE_SEC || $wire->getCreatedAt() > $now + 60) { + return ['ok' => false, 'error' => 'Event created_at out of range', 'code' => 400]; + } + + $key = new Key(); + $userHex = $key->convertToHex($user->getNpub() ?? ''); + if ($userHex === '' || !hash_equals($userHex, $wire->getPublicKey())) { + return ['ok' => false, 'error' => 'Pubkey does not match logged-in user', 'code' => 403]; + } + + if (!$this->tagsReferenceCoordinate($wire->getTags(), $expectedCoordinate)) { + return ['ok' => false, 'error' => 'Tags must include a/A for this article', 'code' => 400]; + } + + if (!$this->contentBlurbReferencesParent( + $wire->getContent(), + $expectedCoordinate, + $parentKind, + $parentId + )) { + return ['ok' => false, 'error' => 'Reply must start with a quote line (>) linking the parent via nostr:nevent1 / naddr1 (reply blurb)', 'code' => 400]; + } + + $relays = $this->nostrClient->getArticleWriteRelayUrls(); + $result = $this->nostrClient->publishEvent($wire, $relays); + $this->logger->info('comment_reply.published', [ + 'id' => $wire->getId(), + 'relays' => \array_keys($result), + ]); + + return ['ok' => true, 'id' => $wire->getId(), 'relays' => $result]; + } + + /** + * @param array $tags + */ + private function tagsReferenceCoordinate(array $tags, string $coordinate): bool + { + foreach ($tags as $row) { + if (!\is_array($row) || ($row[0] ?? null) === null) { + continue; + } + $n = (string) $row[0]; + if ($n === 'a' || $n === 'A') { + if (($row[1] ?? '') === $coordinate) { + return true; + } + } + } + + return false; + } + + private function contentBlurbReferencesParent( + string $content, + string $articleCoordinate, + int $parentKind, + string $parentIdHex + ): bool { + $head = \strlen($content) > 800 ? substr($content, 0, 800) : $content; + if (!str_contains($head, "\n\n")) { + return false; + } + [$blurb] = explode("\n\n", $head, 2); + $blurb = trim($blurb); + if ($blurb === '' || !str_starts_with($blurb, '>')) { + return false; + } + if (!preg_match('/nostr:(nevent1[0-9a-z]+|naddr1[0-9a-z]+|note1[0-9a-z]+)/i', $blurb, $m)) { + return false; + } + try { + $decoded = new Bech32($m[1]); + } catch (\Throwable) { + return false; + } + if ($decoded->type === 'nevent') { + $id = $decoded->data->id ?? null; + + return \is_string($id) && 64 === \strlen($id) && ctype_xdigit($id) && hash_equals($parentIdHex, $id); + } + if ($decoded->type === 'note') { + $id = $decoded->data->identifier ?? null; + + return \is_string($id) && 64 === \strlen($id) && ctype_xdigit($id) && hash_equals($parentIdHex, $id); + } + if ($decoded->type === 'naddr') { + $d = $decoded->data; + $coord = $d->kind.':'.$d->pubkey.':'.$d->identifier; + if (!\in_array($parentKind, [KindsEnum::LONGFORM->value, KindsEnum::LONGFORM_DRAFT->value], true)) { + return false; + } + if (!hash_equals($articleCoordinate, $coord)) { + return false; + } + $zero = str_repeat('0', 64); + + return hash_equals($parentIdHex, $zero); + } + + return false; + } +} diff --git a/src/Service/NostrClient.php b/src/Service/NostrClient.php index 96bfb06..36a76cc 100644 --- a/src/Service/NostrClient.php +++ b/src/Service/NostrClient.php @@ -3,6 +3,7 @@ namespace App\Service; use App\Entity\Article; +use App\Entity\User; use App\Entity\Event as PublicationEventEntity; use App\Enum\KindsEnum; use App\Factory\ArticleFactory; @@ -27,6 +28,15 @@ class NostrClient /** Per-relay WebSocket I/O cap (seconds), applied on each relay’s {@see \WebSocket\Client}. */ private const RELAY_REQUEST_TIMEOUT_SEC = 15; + /** When a logged-in user lists this relay, also use {@see self::AGGR_NOSTR_LAND} for comment + profile reads. */ + private const NOSTR_LAND = 'wss://nostr.land'; + + /** + * Aggregated / subscription relay (not for anonymous visitors). Only added when the session user + * has {@see self::NOSTR_LAND} in their NIP-65-style relay list. + */ + private const AGGR_NOSTR_LAND = 'wss://aggr.nostr.land'; + private RelaySet $defaultRelaySet; /** @@ -47,6 +57,16 @@ class NostrClient $this->defaultRelaySet = $this->buildArticleRelaySet(); } + /** + * default_relay + article_relays (deduplicated) for publishing user comments. + * + * @return list + */ + public function getArticleWriteRelayUrls(): array + { + return $this->configuredArticleRelayUrlList(); + } + /** * default_relay + article_relays from config, in order, deduplicated. Used for the static * default set and as the base when merging author/extra relay URLs in {@see createRelaySet()}. @@ -152,6 +172,98 @@ class NostrClient return $relaySet; } + /** + * Suffix to segregate HTTP caches: aggr is only used for some logged-in readers, so results differ. + * + * @return string empty when aggr is not used, else a short token + */ + public function getNostrLandAggrReaderCacheSuffix(): string + { + return $this->loggedInUserHasNostrLandInRelayList() ? 'a1' : ''; + } + + private function loggedInUserHasNostrLandInRelayList(): bool + { + $token = $this->tokenStorage->getToken(); + if ($token === null) { + return false; + } + $user = $token->getUser(); + if (!$user instanceof User) { + return false; + } + + return $this->userRelayListContainsNostrLand($user->getRelays()); + } + + /** + * @param list|array|null $relays + */ + private function userRelayListContainsNostrLand(?array $relays): bool + { + if ($relays === null || $relays === []) { + return false; + } + $target = $this->normalizeWssUrlForNostrLandMatch(self::NOSTR_LAND); + foreach ($relays as $row) { + if (!\is_array($row) || !isset($row[1]) || !\is_string($row[1])) { + continue; + } + if ($this->normalizeWssUrlForNostrLandMatch($row[1]) === $target) { + return true; + } + } + + return false; + } + + private function normalizeWssUrlForNostrLandMatch(string $url): string + { + return rtrim(trim($url), '/'); + } + + /** + * Appends wss://aggr.nostr.land when the current user listed wss://nostr.land (session). + * + * @param list $urls + * @return list + */ + private function withAggrNostrLandIfUserSubscribesNostrLand(array $urls): array + { + if (!$this->loggedInUserHasNostrLandInRelayList()) { + return $urls; + } + $seen = array_fill_keys($urls, true); + if (isset($seen[self::AGGR_NOSTR_LAND])) { + return $urls; + } + $this->logger->debug('nostr.relay.append_aggr_nostr_land', [ + 'user_has_nostr_land' => true, + ]); + $out = $urls; + $out[] = self::AGGR_NOSTR_LAND; + + return $out; + } + + /** + * @param list $urls + */ + private function relaySetFromDistinctUrlList(array $urls): RelaySet + { + $relaySet = new RelaySet(); + $seen = []; + foreach ($urls as $relayUrl) { + if (!\is_string($relayUrl) || $relayUrl === '' || isset($seen[$relayUrl])) { + continue; + } + $seen[$relayUrl] = true; + $relaySet->addRelay(new Relay($relayUrl)); + } + + return $relaySet; + } + /** * Get top 3 reputable relays from an author's relay list (cached; avoids a kind-10002 round trip per page view). */ @@ -229,7 +341,7 @@ class NostrClient $ordered[] = $this->defaultRelayUrl; } - return $ordered; + return $this->withAggrNostrLandIfUserSubscribesNostrLand($ordered); } /** @@ -733,8 +845,11 @@ class NostrClient 'author_relays' => $authorRelays, ]); - $relaySet = $this->createRelaySet($authorRelays); - $plannedRelayUrls = $this->plannedRelayUrlsForSet($authorRelays); + $mergedForDiscussion = $this->withAggrNostrLandIfUserSubscribesNostrLand( + array_merge($this->configuredArticleRelayUrlList(), $authorRelays) + ); + $relaySet = $this->relaySetFromDistinctUrlList($mergedForDiscussion); + $plannedRelayUrls = $mergedForDiscussion; $filters = $this->createArticleDiscussionFilters($coordinate, $rootEventHexId); $subscription = new Subscription(); diff --git a/templates/components/Organisms/Comments.html.twig b/templates/components/Organisms/Comments.html.twig index dc0d1fa..51308c8 100644 --- a/templates/components/Organisms/Comments.html.twig +++ b/templates/components/Organisms/Comments.html.twig @@ -1,3 +1,49 @@ +{% set ctx = comment_reply_context|default(null) %} +{% if ctx and ctx.can_publish|default(false) and ctx.rows|default([])|length > 0 %} + {% for row in ctx.rows %} + {% if row.mode|default('') == 'article' %} +
+
+

Reply to this article

+
+
+ + +
+
+ +
+

+
+
+
+ {% endif %} + {% endfor %} +{% endif %} +
{% for item in list %} {% set cid = item.id|default('') %} @@ -29,6 +75,48 @@
{% endif %} + {% if ctx and ctx.can_publish|default(false) and item.kind|default(0) == 1111 %} + {% for row in ctx.rows|default([]) %} + {% if row.mode|default('') == 'comment' and row.parentId|default('') == cid %} +
+
+
Reply to this note
+
+ + +
+
+ +
+

+
+
+ {% endif %} + {% endfor %} + {% endif %} {% endfor %} diff --git a/templates/pages/article.html.twig b/templates/pages/article.html.twig index d8fc091..7ae2d53 100644 --- a/templates/pages/article.html.twig +++ b/templates/pages/article.html.twig @@ -120,9 +120,9 @@ {#
#}
 {#        {{ article.content }}#}
 {#    
#} - {% set article_coordinate = '30023:' ~ article.pubkey ~ ':' ~ article.slug %} - {% set comments_query = article.eventId ? { coordinate: article_coordinate, e: article.eventId } : { coordinate: article_coordinate } %} -
+ {% 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 } : {}) %} +