Browse Source

add kind 1 replies-to-replies

imwald
Silberengel 4 days ago
parent
commit
7736c9d8ae
  1. 10
      assets/controllers/comment_reply_controller.js
  2. 13
      src/Controller/ArticleController.php
  3. 2
      src/Controller/CommentReplyController.php
  4. 268
      src/Nostr/Nip10Kind1ArticleReplyTags.php
  5. 73
      src/Service/CommentReplyService.php
  6. 4
      templates/components/Molecules/ArticleReplyComposer.html.twig
  7. 4
      templates/components/Organisms/Comments.html.twig
  8. 106
      tests/Nostr/Nip10Kind1ArticleReplyTagsTest.php

10
assets/controllers/comment_reply_controller.js

@ -1,7 +1,7 @@ @@ -1,7 +1,7 @@
import { Controller } from '@hotwired/stimulus';
/**
* NIP-22 kind-1111 reply: optional collapsed panel (Reply button), sign with NIP-07, POST, refresh thread.
* Article-thread reply: NIP-22 kind 1111 (default) or legacy kind 1 when the parent is kind 1. Sign with NIP-07, POST, refresh thread.
*/
export default class extends Controller {
static targets = ['hint', 'panel', 'toggleBtn'];
@ -63,7 +63,7 @@ export default class extends Controller { @@ -63,7 +63,7 @@ export default class extends Controller {
return;
}
if (this._tags.length === 0) {
this.setHint('Missing NIP-22 tag template.');
this.setHint('Missing tag template for this reply.');
return;
}
this.setHint('Preparing event…');
@ -71,11 +71,13 @@ export default class extends Controller { @@ -71,11 +71,13 @@ export default class extends Controller {
if (!tags.some((t) => Array.isArray(t) && t[0] === 'client')) {
tags.push(['client', 'Decent Newsroom']);
}
const parentKindNum = parseInt(String(this.parentKindValue), 10);
const eventKind = parentKindNum === 1 ? 1 : 1111;
const unsigned = {
kind: 1111,
kind: eventKind,
created_at: Math.floor(Date.now() / 1000),
tags,
// Keep user-authored content clean; reply context is encoded in NIP-22 tags.
// Reply context is encoded in tags (NIP-22 or NIP-10).
content: text,
};
let signed;

13
src/Controller/ArticleController.php

@ -7,6 +7,7 @@ use App\Repository\ArticleHighlightRepository; @@ -7,6 +7,7 @@ use App\Repository\ArticleHighlightRepository;
use App\Repository\ArticleRepository;
use App\Service\ArticleBodyHighlightInjector;
use App\Enum\KindsEnum;
use App\Nostr\Nip10Kind1ArticleReplyTags;
use App\Nostr\Nip22CommentTags;
use App\Form\EditorType;
use App\Service\ArticleCommentThreadLoader;
@ -174,7 +175,7 @@ class ArticleController extends AbstractController @@ -174,7 +175,7 @@ class ArticleController extends AbstractController
continue;
}
$k = (int) ($row->kind ?? 0);
if ($k !== KindsEnum::COMMENTS->value) {
if ($k !== KindsEnum::COMMENTS->value && $k !== KindsEnum::TEXT_NOTE->value) {
continue;
}
$cid = strtolower(trim((string) ($row->id ?? '')));
@ -198,7 +199,17 @@ class ArticleController extends AbstractController @@ -198,7 +199,17 @@ class ArticleController extends AbstractController
$snippet = 'Comment';
}
try {
if ($k === KindsEnum::COMMENTS->value) {
$expectedTags = Nip22CommentTags::forReplyToComment($cid, $cpk, $k, $rawTags);
} else {
$expectedTags = Nip10Kind1ArticleReplyTags::forReplyToKind1(
$cid,
$cpk,
$rawTags,
$coordinate,
$articleEventId
);
}
} catch (\Throwable) {
continue;
}

2
src/Controller/CommentReplyController.php

@ -17,7 +17,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted; @@ -17,7 +17,7 @@ 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.
* Accepts a NIP-07–signed kind-1111 (NIP-22) or kind-1 (NIP-10) article-thread event (JSON) and publishes it to configured relays.
*
* @see \App\Service\CommentReplyService
*/

268
src/Nostr/Nip10Kind1ArticleReplyTags.php

@ -0,0 +1,268 @@ @@ -0,0 +1,268 @@
<?php
declare(strict_types=1);
namespace App\Nostr;
/**
* Kind 1 (NIP-01 + NIP-10): only {@code e} (thread references) and {@code p} (thread participants) tags.
* Marked {@code e} use {@code ["e", id, relay, marker, pubkey]}; relay may be "".
* If the thread root id cannot be inferred, a single marked {@code e} with "reply" references the direct parent.
*/
final class Nip10Kind1ArticleReplyTags
{
/**
* @param list<array<int, string|int|float>>|array<array> $parentRawTags
*
* @return list<list<string>>
*/
public static function forReplyToKind1(
string $parentEventIdHex,
string $parentAuthorPubkeyHex,
array $parentRawTags,
string $articleCoordinate,
?string $articleEventHexId
): array {
$parentEventIdHex = strtolower($parentEventIdHex);
$parentAuthorPubkeyHex = strtolower($parentAuthorPubkeyHex);
if (self::isInvalidHexId($parentEventIdHex) || self::isInvalidHexPubkey($parentAuthorPubkeyHex)) {
throw new \InvalidArgumentException('Invalid parent id or pubkey');
}
$rootId = self::inferRootEventId($parentRawTags, $articleEventHexId);
$parts = explode(':', $articleCoordinate, 3);
$articleAuthorHex = \count($parts) >= 2 ? strtolower((string) $parts[1]) : '';
if ($articleAuthorHex !== '' && (64 !== \strlen($articleAuthorHex) || !ctype_xdigit($articleAuthorHex))) {
$articleAuthorHex = '';
}
$articleEventNorm = self::normEventId($articleEventHexId);
$tags = [];
if (self::isInvalidHexId($rootId)) {
$tags[] = ['e', $parentEventIdHex, '', 'reply', $parentAuthorPubkeyHex];
} else {
$rootAuthorPk = self::pubkeyForRootEvent(
$rootId,
$parentEventIdHex,
$parentAuthorPubkeyHex,
$parentRawTags,
$articleAuthorHex,
$articleEventNorm
);
// NIP-10: direct reply to the thread root uses only a single "root" e tag, not root+reply to the same id.
if (hash_equals($rootId, $parentEventIdHex)) {
$tags[] = ['e', $rootId, '', 'root', $rootAuthorPk];
} else {
$tags[] = ['e', $rootId, '', 'root', $rootAuthorPk];
$tags[] = ['e', $parentEventIdHex, '', 'reply', $parentAuthorPubkeyHex];
}
}
$seenP = [];
$addP = static function (string $pk) use (&$tags, &$seenP): void {
if (64 !== \strlen($pk) || !ctype_xdigit($pk) || isset($seenP[$pk])) {
return;
}
$seenP[$pk] = true;
$tags[] = ['p', $pk];
};
// NIP-10: p tags = author of E being replied to, then all p tags from E (in no particular order; we use author first).
$addP($parentAuthorPubkeyHex);
foreach (self::collectPFromParent($parentRawTags) as $pk) {
$addP($pk);
}
return $tags;
}
/**
* @param list<array<array-key, mixed>> $rawTags
*/
private static function pubkeyForRootEvent(
string $rootId,
string $parentEventIdHex,
string $parentAuthorPubkeyHex,
array $parentRawTags,
string $articleAuthorHex,
?string $articleEventNorm
): string {
if (hash_equals($rootId, $parentEventIdHex)) {
return $parentAuthorPubkeyHex;
}
if ($articleEventNorm !== null && hash_equals($rootId, $articleEventNorm) && $articleAuthorHex !== '') {
return $articleAuthorHex;
}
$fromParent = self::eTagPubkeyForEventId($parentRawTags, $rootId);
if ($fromParent !== '') {
return $fromParent;
}
return '';
}
/**
* Optional 5th field on marked e tags: pubkey of the author of the referenced event (NIP-10).
*
* @param list<array<array-key, mixed>> $rawTags
*/
private static function eTagPubkeyForEventId(array $rawTags, string $eventIdHex): string
{
$want = strtolower($eventIdHex);
foreach ($rawTags as $row) {
if (!\is_array($row) || \count($row) < 2) {
continue;
}
if (strtolower((string) ($row[0] ?? '')) !== 'e') {
continue;
}
$id = self::normEventId($row[1] ?? null);
if ($id === null || !hash_equals($want, $id)) {
continue;
}
if (\count($row) >= 5) {
$pk = self::normPubkey($row[4] ?? null);
if ($pk !== '') {
return $pk;
}
}
}
return '';
}
/**
* @param list<array<array-key, mixed>> $rawTags
*
* @return list<string>
*/
private static function collectPFromParent(array $rawTags): array
{
$out = [];
foreach ($rawTags as $row) {
if (!\is_array($row) || \count($row) < 2) {
continue;
}
if (strtolower((string) ($row[0] ?? '')) !== 'p') {
continue;
}
$pk = self::normPubkey($row[1] ?? null);
if ($pk !== '') {
$out[] = $pk;
}
}
return $out;
}
private static function normPubkey(mixed $v): string
{
if (!\is_string($v) && !\is_int($v) && !\is_float($v)) {
return '';
}
$h = strtolower(trim((string) $v));
if (64 !== \strlen($h) || !ctype_xdigit($h)) {
return '';
}
return $h;
}
/**
* @param list<array<array-key, mixed>> $rawTags
*/
private static function inferRootEventId(array $rawTags, ?string $articleEventHexId): string
{
$articleHex = self::normEventId($articleEventHexId);
$isE = static function (mixed $row): bool {
if (!\is_array($row) || ($row[0] ?? null) === null) {
return false;
}
$n = strtolower((string) $row[0]);
return $n === 'e';
};
foreach ($rawTags as $row) {
if (!\is_array($row) || !$isE($row) || \count($row) < 2) {
continue;
}
if (($row[3] ?? '') === 'root') {
$id = self::normEventId($row[1] ?? null);
if ($id !== null) {
return $id;
}
}
}
if ($articleHex !== null) {
foreach ($rawTags as $row) {
if (!\is_array($row) || !$isE($row) || \count($row) < 2) {
continue;
}
$id = self::normEventId($row[1] ?? null);
if ($id !== null && hash_equals($id, $articleHex)) {
return $id;
}
}
}
$eIds = [];
foreach ($rawTags as $row) {
if (!\is_array($row) || !$isE($row) || \count($row) < 2) {
continue;
}
$id = self::normEventId($row[1] ?? null);
if ($id !== null) {
$eIds[] = $id;
}
}
if ($eIds === [] && $articleHex !== null) {
return $articleHex;
}
if (\count($eIds) === 1) {
return $eIds[0];
}
if ($eIds !== []) {
if ($articleHex !== null) {
foreach ($eIds as $id) {
if (hash_equals($id, $articleHex)) {
return $id;
}
}
}
return $eIds[0];
}
if ($articleHex !== null) {
return $articleHex;
}
return '';
}
private static function normEventId(mixed $v): ?string
{
if (!\is_string($v) && !\is_int($v) && !\is_float($v)) {
return null;
}
$h = strtolower(trim((string) $v));
if (64 !== \strlen($h) || !ctype_xdigit($h)) {
return null;
}
return $h;
}
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);
}
}

73
src/Service/CommentReplyService.php

@ -10,7 +10,7 @@ use Psr\Log\LoggerInterface; @@ -10,7 +10,7 @@ use Psr\Log\LoggerInterface;
use swentel\nostr\Event\Event as NostrWireEvent;
/**
* Validates NIP-22 kind-1111 comment events from logged-in users and publishes to article relays.
* Validates NIP-22 kind-1111 and legacy kind-1 article-thread replies from logged-in users and publishes to article relays.
*/
final readonly class CommentReplyService
{
@ -63,8 +63,9 @@ final readonly class CommentReplyService @@ -63,8 +63,9 @@ final readonly class CommentReplyService
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];
$expectedKind = $this->expectedReplyEventKindForParent($parentKind);
if ($wire->getKind() !== $expectedKind) {
return ['ok' => false, 'error' => 'Event kind does not match parent context (expected '.$expectedKind.')', 'code' => 400];
}
$now = time();
@ -77,8 +78,8 @@ final readonly class CommentReplyService @@ -77,8 +78,8 @@ final readonly class CommentReplyService
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->tagsReferenceCoordinate($wire->getTags(), $expectedCoordinate, $wire->getKind())) {
return ['ok' => false, 'error' => 'Tags must reference this article (a/A for NIP-22, or e for NIP-10 kind 1)', 'code' => 400];
}
if (!$this->tagsReferenceParent($wire->getTags(), $expectedCoordinate, $parentKind, $parentId)) {
@ -93,9 +94,9 @@ final readonly class CommentReplyService @@ -93,9 +94,9 @@ final readonly class CommentReplyService
$articleAuthor = \count($coordBits) >= 2 ? strtolower((string) $coordBits[1]) : '';
$articleAuthorOk = 64 === \strlen($articleAuthor) && ctype_xdigit($articleAuthor);
if ((int) $parentKind === KindsEnum::COMMENTS->value) {
if (\in_array((int) $parentKind, [KindsEnum::COMMENTS->value, KindsEnum::TEXT_NOTE->value], true)) {
if (!$clientParentOk) {
return ['ok' => false, 'error' => 'parent_author_pubkey (64 hex) is required when replying to a comment', 'code' => 400];
return ['ok' => false, 'error' => 'parent_author_pubkey (64 hex) is required when replying to a note', 'code' => 400];
}
$parentAuthorHex = $rawParentAuthor;
} else {
@ -137,10 +138,21 @@ final readonly class CommentReplyService @@ -137,10 +138,21 @@ final readonly class CommentReplyService
];
}
private function expectedReplyEventKindForParent(int $parentKind): int
{
if ($parentKind === KindsEnum::TEXT_NOTE->value) {
return KindsEnum::TEXT_NOTE->value;
}
return KindsEnum::COMMENTS->value;
}
/**
* NIP-22 (kind 1111) uses a/A; NIP-10 kind 1 uses e/p only (no address tag) — accept at least one valid e.
*
* @param array<int, mixed> $tags
*/
private function tagsReferenceCoordinate(array $tags, string $coordinate): bool
private function tagsReferenceCoordinate(array $tags, string $coordinate, int $eventKind): bool
{
foreach ($tags as $row) {
if (!\is_array($row) || ($row[0] ?? null) === null) {
@ -153,6 +165,33 @@ final readonly class CommentReplyService @@ -153,6 +165,33 @@ final readonly class CommentReplyService
}
}
}
if ($eventKind === KindsEnum::TEXT_NOTE->value) {
return $this->hasValidEThreadRef($tags);
}
return false;
}
/**
* NIP-10: kind-1 thread replies use e tags (not a).
*
* @param array<int, mixed> $tags
*/
private function hasValidEThreadRef(array $tags): bool
{
foreach ($tags as $row) {
if (!\is_array($row) || ($row[0] ?? null) === null) {
continue;
}
$n = strtolower((string) $row[0]);
if ($n !== 'e') {
continue;
}
$id = isset($row[1]) && \is_string($row[1]) ? strtolower(trim($row[1])) : '';
if (64 === \strlen($id) && ctype_xdigit($id)) {
return true;
}
}
return false;
}
@ -185,14 +224,28 @@ final readonly class CommentReplyService @@ -185,14 +224,28 @@ final readonly class CommentReplyService
continue;
}
$n = (string) $row[0];
if (($n === 'e' || $n === 'E') && \is_string($row[1] ?? null) && hash_equals($parentIdHex, (string) $row[1])) {
if (($n === 'e' || $n === 'E') && \is_string($row[1] ?? null) && hash_equals($parentIdHex, strtolower((string) $row[1]))) {
return true;
}
}
return false;
}
if ($parentKind === KindsEnum::TEXT_NOTE->value) {
$want = strtolower($parentIdHex);
foreach ($tags as $row) {
if (!\is_array($row) || ($row[0] ?? null) === null) {
continue;
}
$n = (string) $row[0];
if (($n === 'e' || $n === 'E') && \is_string($row[1] ?? null) && hash_equals($want, strtolower((string) $row[1]))) {
return true;
}
}
return false;
}
return false;
}
}

4
templates/components/Molecules/ArticleReplyComposer.html.twig

@ -18,7 +18,7 @@ @@ -18,7 +18,7 @@
>
<div class="card-body comment-reply--article__inner">
<div class="comment-reply__toolbar">
<p class="comment-reply__lede text-subtle">Reply to this note on Nostr (kind 1111).</p>
<p class="comment-reply__lede text-subtle">Reply to this article on Nostr (NIP-22 kind 1111).</p>
<button
type="button"
class="btn btn-secondary btn-sm comment-reply__toggle"
@ -41,7 +41,7 @@ @@ -41,7 +41,7 @@
rows="4"
required
minlength="1"
placeholder="Write a NIP-22 comment (kind 1111)."
placeholder="Write a NIP-22 kind-1111 comment (plain text)."
></textarea>
</div>
<div class="comment-reply__actions">

4
templates/components/Organisms/Comments.html.twig

@ -47,7 +47,7 @@ @@ -47,7 +47,7 @@
</div>
</div>
{% endif %}
{% if ctx and ctx.can_publish|default(false) and item.kind|default(0) == 1111 %}
{% if ctx and ctx.can_publish|default(false) and (item.kind|default(0) == 1111 or item.kind|default(0) == 1) %}
{% for row in ctx.rows|default([]) %}
{% if row.mode|default('') == 'comment' and row.parentId|default('') == cid %}
<div
@ -89,7 +89,7 @@ @@ -89,7 +89,7 @@
rows="3"
required
minlength="1"
placeholder="NIP-22 comment; parent quote line is added on publish…"
placeholder="Plain-text reply (kind 1111 or 1); tags are built for you…"
></textarea>
</div>
<div class="comment-reply__actions">

106
tests/Nostr/Nip10Kind1ArticleReplyTagsTest.php

@ -0,0 +1,106 @@ @@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
namespace App\Tests\Nostr;
use App\Nostr\Nip10Kind1ArticleReplyTags;
use PHPUnit\Framework\TestCase;
final class Nip10Kind1ArticleReplyTagsTest extends TestCase
{
public function testReplyUnderArticleUsesMarkedEWithPubkeys(): void
{
$pk = str_repeat('a', 64);
$aid = str_repeat('b', 64);
$cid = str_repeat('c', 64);
$coord = '30023:'.$pk.':slug';
$tags = Nip10Kind1ArticleReplyTags::forReplyToKind1(
$cid,
$pk,
[['a', $coord]],
$coord,
$aid
);
$this->assertSame(
[
['e', $aid, '', 'root', $pk],
['e', $cid, '', 'reply', $pk],
['p', $pk],
],
$tags
);
}
public function testDirectReplyToRootUsesSingleMarkedRootOnly(): void
{
$author = str_repeat('f', 64);
$rootId = str_repeat('e', 64);
$coord = '30023:'.$author.':slug';
$tags = Nip10Kind1ArticleReplyTags::forReplyToKind1(
$rootId,
$author,
[
['a', $coord],
['e', $rootId, '', 'root', $author],
],
$coord,
$rootId
);
$this->assertCount(2, $tags);
$this->assertSame(['e', $rootId, '', 'root', $author], $tags[0]);
$this->assertSame(['p', $author], $tags[1]);
}
public function testNestedCopiesParentPTagsAfterAuthor(): void
{
$articlePk = str_repeat('1', 64);
$root = str_repeat('2', 64);
$parentNote = str_repeat('3', 64);
$child = str_repeat('4', 64);
$pExtra = str_repeat('6', 64);
$coord = '30023:'.$articlePk.':x';
$parentTags = [
['a', $coord],
['e', $root, '', 'root', str_repeat('5', 64)],
['e', $parentNote, '', 'reply', $articlePk],
['p', $articlePk],
['p', $pExtra],
];
$tags = Nip10Kind1ArticleReplyTags::forReplyToKind1(
$child,
$articlePk,
$parentTags,
$coord,
$root
);
$this->assertSame($root, $tags[0][1]);
$this->assertSame($articlePk, $tags[0][4]);
$this->assertSame($child, $tags[1][1]);
$this->assertSame($articlePk, $tags[1][4]);
$this->assertSame(['p', $articlePk], $tags[2]);
$this->assertSame(['p', $pExtra], $tags[3]);
$this->assertCount(4, $tags);
}
public function testWhenRootInferenceFailsOnlyDirectParentE(): void
{
$author = str_repeat('9', 64);
$parentId = str_repeat('8', 64);
$coord = '30023:'.$author.':x';
$tags = Nip10Kind1ArticleReplyTags::forReplyToKind1(
$parentId,
$author,
[],
$coord,
null
);
$this->assertSame(
[
['e', $parentId, '', 'reply', $author],
['p', $author],
],
$tags
);
}
}
Loading…
Cancel
Save