Browse Source

implement replies

imwald
Silberengel 1 week ago
parent
commit
0094939adc
  1. 6
      assets/bootstrap.js
  2. 160
      assets/controllers/comment_reply_controller.js
  3. 63
      assets/styles/article.css
  4. 3
      importmap.php
  5. 93
      src/Controller/ArticleController.php
  6. 56
      src/Controller/CommentReplyController.php
  7. 137
      src/Nostr/Nip22CommentTags.php
  8. 3
      src/Service/ArticleCommentThreadLoader.php
  9. 3
      src/Service/CacheService.php
  10. 174
      src/Service/CommentReplyService.php
  11. 121
      src/Service/NostrClient.php
  12. 88
      templates/components/Organisms/Comments.html.twig
  13. 6
      templates/pages/article.html.twig

6
assets/bootstrap.js vendored

@ -1,5 +1,6 @@
import { startStimulusApp } from '@symfony/stimulus-bundle'; import { startStimulusApp } from '@symfony/stimulus-bundle';
import ArticleCommentsController from './controllers/article_comments_controller.js'; import ArticleCommentsController from './controllers/article_comments_controller.js';
import CommentReplyController from './controllers/comment_reply_controller.js';
import MagazineSyncController from './controllers/magazine_sync_controller.js'; import MagazineSyncController from './controllers/magazine_sync_controller.js';
const app = startStimulusApp(); const app = startStimulusApp();
@ -15,3 +16,8 @@ try {
} catch { } catch {
/* already registered by the bundle */ /* already registered by the bundle */
} }
try {
app.register('comment-reply', CommentReplyController);
} catch {
/* already registered by the bundle */
}

160
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;
}
}
}

63
assets/styles/article.css

@ -127,3 +127,66 @@ blockquote p {
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.35rem; 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;
}

3
importmap.php

@ -49,6 +49,9 @@ return [
'lodash.isequal' => [ 'lodash.isequal' => [
'version' => '4.5.0', 'version' => '4.5.0',
], ],
'nostr-tools' => [
'version' => '2.10.4',
],
'quill/dist/quill.core.css' => [ 'quill/dist/quill.core.css' => [
'version' => '2.0.3', 'version' => '2.0.3',
'type' => 'css', 'type' => 'css',

93
src/Controller/ArticleController.php

@ -4,6 +4,7 @@ namespace App\Controller;
use App\Entity\Article; use App\Entity\Article;
use App\Enum\KindsEnum; use App\Enum\KindsEnum;
use App\Nostr\Nip22CommentTags;
use App\Form\EditorType; use App\Form\EditorType;
use App\Service\ArticleCommentThreadLoader; use App\Service\ArticleCommentThreadLoader;
use App\Service\NostrClient; use App\Service\NostrClient;
@ -49,6 +50,40 @@ class ArticleController extends AbstractController
$articleEventId = null; $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', [ $logger->info('http.fragment.comments_start', [
'coordinate' => $coordinate, 'coordinate' => $coordinate,
'article_event_hex' => $articleEventId, 'article_event_hex' => $articleEventId,
@ -61,11 +96,69 @@ class ArticleController extends AbstractController
try { try {
$data = $loader->load($coordinate, $articleEventId); $data = $loader->load($coordinate, $articleEventId);
if ($userMayReply && $articleReplyTags !== null) {
/** @var array<int, object> $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', [ $logger->info('http.fragment.comments_after_load', [
'elapsed_ms' => (int) round((microtime(true) - $t0) * 1000), 'elapsed_ms' => (int) round((microtime(true) - $t0) * 1000),
]); ]);
$tRender = microtime(true); $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 = $this->render('components/Organisms/Comments.html.twig', $data, new Response(
'', '',
Response::HTTP_OK, Response::HTTP_OK,

56
src/Controller/CommentReplyController.php

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\User;
use App\Service\CommentReplyService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
final class CommentReplyController extends AbstractController
{
/**
* Accepts a NIP-07–signed kind-1111 event (JSON) and publishes it to configured article relays.
*
* @see \App\Service\CommentReplyService
*/
#[Route('/comment/publish', name: 'comment_reply_publish', methods: ['POST'])]
#[IsGranted('ROLE_USER')]
public function publish(Request $request, CommentReplyService $commentReply): JsonResponse
{
$raw = $request->getContent();
if ($raw === '') {
return $this->json(['ok' => false, 'error' => 'Empty body'], Response::HTTP_BAD_REQUEST);
}
try {
/** @var array<string, mixed> $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']);
}
}

137
src/Nostr/Nip22CommentTags.php

@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
namespace App\Nostr;
/**
* Kind 1111 (NIP-22) tag rows for article threads — aligned with Jumble's
* createCommentDraftEvent (a/A + e root + e parent for nested replies).
*/
final class Nip22CommentTags
{
/**
* Top-level comment under a kind 30023 (or 30024) article address.
*
* @return list<list<string>>
*/
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<int, string|int|float>>|array<array> $rawTags
* @return list<list<string>>
*/
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<array<array-key, mixed>> $rawTags
* @return list<string>|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<string> $tag
* @return list<string>
*/
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);
}
}

3
src/Service/ArticleCommentThreadLoader.php

@ -33,7 +33,8 @@ final readonly class ArticleCommentThreadLoader
public function load(string $coordinate, ?string $articleEventHexId = null): array public function load(string $coordinate, ?string $articleEventHexId = null): array
{ {
$t0 = microtime(true); $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', [ $this->logger->info('comments.loader.start', [
'cache_key_suffix' => substr($cacheKey, -16), 'cache_key_suffix' => substr($cacheKey, -16),
'coordinate' => $coordinate, 'coordinate' => $coordinate,

3
src/Service/CacheService.php

@ -25,7 +25,8 @@ readonly class CacheService
*/ */
public function getMetadata(string $npub): \stdClass public function getMetadata(string $npub): \stdClass
{ {
$cacheKey = '0_' . $npub; $aggr = $this->nostrClient->getNostrLandAggrReaderCacheSuffix();
$cacheKey = $aggr === '' ? '0_'.$npub : '0_'.$aggr.'_'.$npub;
try { try {
return $this->cache->get($cacheKey, function (ItemInterface $item) use ($npub) { return $this->cache->get($cacheKey, function (ItemInterface $item) use ($npub) {
$item->expiresAfter(3600); // 1 hour, adjust as needed $item->expiresAfter(3600); // 1 hour, adjust as needed

174
src/Service/CommentReplyService.php

@ -0,0 +1,174 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\User;
use App\Enum\KindsEnum;
use nostriphant\NIP19\Bech32;
use Psr\Log\LoggerInterface;
use swentel\nostr\Event\Event as NostrWireEvent;
use swentel\nostr\Key\Key;
/**
* Validates NIP-22 kind-1111 comment events from logged-in users and publishes to article relays.
*/
final readonly class CommentReplyService
{
private const STALE_EVENT_MAX_AGE_SEC = 600;
public function __construct(
private NostrClient $nostrClient,
private LoggerInterface $logger,
) {
}
/**
* @param array<string, mixed> $payload Decoded JSON body
*
* @return array{ok: true, id: string, relays: array<string, mixed>}|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<int, mixed> $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;
}
}

121
src/Service/NostrClient.php

@ -3,6 +3,7 @@
namespace App\Service; namespace App\Service;
use App\Entity\Article; use App\Entity\Article;
use App\Entity\User;
use App\Entity\Event as PublicationEventEntity; use App\Entity\Event as PublicationEventEntity;
use App\Enum\KindsEnum; use App\Enum\KindsEnum;
use App\Factory\ArticleFactory; 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}. */ /** Per-relay WebSocket I/O cap (seconds), applied on each relay’s {@see \WebSocket\Client}. */
private const RELAY_REQUEST_TIMEOUT_SEC = 15; 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; private RelaySet $defaultRelaySet;
/** /**
@ -47,6 +57,16 @@ class NostrClient
$this->defaultRelaySet = $this->buildArticleRelaySet(); $this->defaultRelaySet = $this->buildArticleRelaySet();
} }
/**
* default_relay + article_relays (deduplicated) for publishing user comments.
*
* @return list<string>
*/
public function getArticleWriteRelayUrls(): array
{
return $this->configuredArticleRelayUrlList();
}
/** /**
* default_relay + article_relays from config, in order, deduplicated. Used for the static * 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()}. * default set and as the base when merging author/extra relay URLs in {@see createRelaySet()}.
@ -152,6 +172,98 @@ class NostrClient
return $relaySet; 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{0?: string, 1?: string, 2?: string}>|array<array-key, mixed>|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<string> $urls
* @return list<string>
*/
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<string> $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). * 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; $ordered[] = $this->defaultRelayUrl;
} }
return $ordered; return $this->withAggrNostrLandIfUserSubscribesNostrLand($ordered);
} }
/** /**
@ -733,8 +845,11 @@ class NostrClient
'author_relays' => $authorRelays, 'author_relays' => $authorRelays,
]); ]);
$relaySet = $this->createRelaySet($authorRelays); $mergedForDiscussion = $this->withAggrNostrLandIfUserSubscribesNostrLand(
$plannedRelayUrls = $this->plannedRelayUrlsForSet($authorRelays); array_merge($this->configuredArticleRelayUrlList(), $authorRelays)
);
$relaySet = $this->relaySetFromDistinctUrlList($mergedForDiscussion);
$plannedRelayUrls = $mergedForDiscussion;
$filters = $this->createArticleDiscussionFilters($coordinate, $rootEventHexId); $filters = $this->createArticleDiscussionFilters($coordinate, $rootEventHexId);
$subscription = new Subscription(); $subscription = new Subscription();

88
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' %}
<div class="comment-reply comment-reply--article card">
<div class="card-body">
<h3 class="comment-reply__heading">Reply to this article</h3>
<form
class="comment-reply__form"
data-controller="comment-reply"
data-action="submit->comment-reply#publish"
data-comment-reply-publish-url-value="{{ path('comment_reply_publish')|e('html_attr') }}"
data-comment-reply-csrf-value="{{ csrf_token('comment_reply')|e('html_attr') }}"
data-comment-reply-expected-coordinate-value="{{ ctx.coordinate|e('html_attr') }}"
data-comment-reply-article-event-id-value="{{ ctx.article_event_id|default('')|e('html_attr') }}"
data-comment-reply-fragment-url-value="{{ ctx.fragment_url|default('')|e('html_attr') }}"
data-comment-reply-refresh-after-value="1"
data-comment-reply-blurb-label-value="{{ row.blurbLabel|default('Article')|e('html_attr') }}"
data-comment-reply-expected-tags-value='{{ row.expectedTags|default([])|json_encode|e('html_attr') }}'
data-comment-reply-parent-kind-value="{{ row.parentKind|default(0) }}"
data-comment-reply-parent-id-value="{{ row.parentId|default('')|e('html_attr') }}"
data-comment-reply-author-pubkey-value="{{ row.authorPubkey|default('')|e('html_attr') }}"
>
<div class="comment-reply__body">
<label class="visually-hidden" for="comment-reply-article-body">Your reply</label>
<textarea
class="form-control"
id="comment-reply-article-body"
name="body"
rows="4"
required
minlength="1"
placeholder="Write a Nostr comment (kind 1111). A quoted parent line is added automatically."
></textarea>
</div>
<div class="comment-reply__actions">
<button class="btn btn-primary" type="submit">Sign &amp; publish</button>
</div>
<p class="comment-reply__hint text-subtle" data-comment-reply-target="hint" aria-live="polite"></p>
</form>
</div>
</div>
{% endif %}
{% endfor %}
{% endif %}
<div class="comments"> <div class="comments">
{% for item in list %} {% for item in list %}
{% set cid = item.id|default('') %} {% set cid = item.id|default('') %}
@ -29,6 +75,48 @@
</div> </div>
</div> </div>
{% endif %} {% 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 %}
<div class="comment-reply comment-reply--nested">
<form
class="comment-reply__form"
data-controller="comment-reply"
data-action="submit->comment-reply#publish"
data-comment-reply-publish-url-value="{{ path('comment_reply_publish')|e('html_attr') }}"
data-comment-reply-csrf-value="{{ csrf_token('comment_reply')|e('html_attr') }}"
data-comment-reply-expected-coordinate-value="{{ ctx.coordinate|e('html_attr') }}"
data-comment-reply-article-event-id-value="{{ ctx.article_event_id|default('')|e('html_attr') }}"
data-comment-reply-fragment-url-value="{{ ctx.fragment_url|default('')|e('html_attr') }}"
data-comment-reply-refresh-after-value="1"
data-comment-reply-blurb-label-value="{{ row.blurbLabel|default('Comment')|e('html_attr') }}"
data-comment-reply-expected-tags-value='{{ row.expectedTags|default([])|json_encode|e('html_attr') }}'
data-comment-reply-parent-kind-value="{{ row.parentKind|default(0) }}"
data-comment-reply-parent-id-value="{{ row.parentId|default('')|e('html_attr') }}"
data-comment-reply-author-pubkey-value="{{ row.authorPubkey|default('')|e('html_attr') }}"
>
<div class="comment-reply__head text-subtle">Reply to this note</div>
<div class="comment-reply__body">
<label class="visually-hidden" for="comment-reply-{{ cid }}">Your reply</label>
<textarea
class="form-control"
id="comment-reply-{{ cid }}"
name="body"
rows="3"
required
minlength="1"
placeholder="Sign with your Nostr extension (kind 1111)…"
></textarea>
</div>
<div class="comment-reply__actions">
<button class="btn btn-primary" type="submit">Sign &amp; publish</button>
</div>
<p class="comment-reply__hint text-subtle" data-comment-reply-target="hint" aria-live="polite"></p>
</form>
</div>
{% endif %}
{% endfor %}
{% endif %}
</div> </div>
{% endfor %} {% endfor %}
</div> </div>

6
templates/pages/article.html.twig

@ -120,9 +120,9 @@
{# <pre>#} {# <pre>#}
{# {{ article.content }}#} {# {{ article.content }}#}
{# </pre>#} {# </pre>#}
{% set article_coordinate = '30023:' ~ article.pubkey ~ ':' ~ article.slug %} {% set article_coordinate = (article.kind ? article.kind.value : 30023) ~ ':' ~ article.pubkey ~ ':' ~ article.slug %}
{% set comments_query = article.eventId ? { coordinate: article_coordinate, e: article.eventId } : { coordinate: article_coordinate } %} {% set comments_query = { coordinate: article_coordinate, title: article.title|default('') }|merge(article.eventId ? { e: article.eventId } : {}) %}
<section class="article-comments-async" aria-label="Comments"> <section class="article-comments-async" id="article-comments" aria-label="Comments">
<div <div
data-controller="article-comments" data-controller="article-comments"
data-article-comments-url-value="{{ path('article_comments_fragment', comments_query)|e('html_attr') }}" data-article-comments-url-value="{{ path('article_comments_fragment', comments_query)|e('html_attr') }}"

Loading…
Cancel
Save