diff --git a/assets/controllers/comment_reply_controller.js b/assets/controllers/comment_reply_controller.js index 8843b2f..7d0b62f 100644 --- a/assets/controllers/comment_reply_controller.js +++ b/assets/controllers/comment_reply_controller.js @@ -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 { 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 { 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; diff --git a/src/Controller/ArticleController.php b/src/Controller/ArticleController.php index 17e2c7b..d021e3e 100644 --- a/src/Controller/ArticleController.php +++ b/src/Controller/ArticleController.php @@ -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 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 $snippet = 'Comment'; } try { - $expectedTags = Nip22CommentTags::forReplyToComment($cid, $cpk, $k, $rawTags); + if ($k === KindsEnum::COMMENTS->value) { + $expectedTags = Nip22CommentTags::forReplyToComment($cid, $cpk, $k, $rawTags); + } else { + $expectedTags = Nip10Kind1ArticleReplyTags::forReplyToKind1( + $cid, + $cpk, + $rawTags, + $coordinate, + $articleEventId + ); + } } catch (\Throwable) { continue; } diff --git a/src/Controller/CommentReplyController.php b/src/Controller/CommentReplyController.php index bddedfb..9858d34 100644 --- a/src/Controller/CommentReplyController.php +++ b/src/Controller/CommentReplyController.php @@ -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 */ diff --git a/src/Nostr/Nip10Kind1ArticleReplyTags.php b/src/Nostr/Nip10Kind1ArticleReplyTags.php new file mode 100644 index 0000000..49402cb --- /dev/null +++ b/src/Nostr/Nip10Kind1ArticleReplyTags.php @@ -0,0 +1,268 @@ +>|array $parentRawTags + * + * @return list> + */ + 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> $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> $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> $rawTags + * + * @return list + */ + 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> $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); + } +} diff --git a/src/Service/CommentReplyService.php b/src/Service/CommentReplyService.php index 931ca22..b32c9d2 100644 --- a/src/Service/CommentReplyService.php +++ b/src/Service/CommentReplyService.php @@ -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 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 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 $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 ]; } + 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 $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 } } } + 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 $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,7 +224,21 @@ 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; } } @@ -193,6 +246,6 @@ final readonly class CommentReplyService return false; } - return true; + return false; } } diff --git a/templates/components/Molecules/ArticleReplyComposer.html.twig b/templates/components/Molecules/ArticleReplyComposer.html.twig index cd8bacc..a7aa1f9 100644 --- a/templates/components/Molecules/ArticleReplyComposer.html.twig +++ b/templates/components/Molecules/ArticleReplyComposer.html.twig @@ -18,7 +18,7 @@ >
-

Reply to this note on Nostr (kind 1111).

+

Reply to this article on Nostr (NIP-22 kind 1111).

diff --git a/templates/components/Organisms/Comments.html.twig b/templates/components/Organisms/Comments.html.twig index 2c3d329..ad46111 100644 --- a/templates/components/Organisms/Comments.html.twig +++ b/templates/components/Organisms/Comments.html.twig @@ -47,7 +47,7 @@
{% 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 %}
diff --git a/tests/Nostr/Nip10Kind1ArticleReplyTagsTest.php b/tests/Nostr/Nip10Kind1ArticleReplyTagsTest.php new file mode 100644 index 0000000..9008134 --- /dev/null +++ b/tests/Nostr/Nip10Kind1ArticleReplyTagsTest.php @@ -0,0 +1,106 @@ +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 + ); + } +}