Browse Source

move embedded references to quotes

imwald
Silberengel 2 days ago
parent
commit
b27710018c
  1. 6
      assets/bootstrap.js
  2. 16
      assets/controllers/nostr_share_menu_controller.js
  3. 75
      src/Service/NostrArticleDiscussionSupport.php
  4. 4
      templates/components/Molecules/ArticleReplyComposer.html.twig
  5. 13
      templates/components/Molecules/NostrPreview.html.twig
  6. 6
      templates/components/Molecules/NostrShareMenu.html.twig
  7. 32
      templates/components/Organisms/Comments.html.twig
  8. 40
      tests/Service/NostrArticleDiscussionSupportTest.php

6
assets/bootstrap.js vendored

@ -3,6 +3,7 @@ import ArticleCommentsController from './controllers/article_comments_controller @@ -3,6 +3,7 @@ import ArticleCommentsController from './controllers/article_comments_controller
import CommentReplyController from './controllers/comment_reply_controller.js';
import CopyTextController from './controllers/copy_text_controller.js';
import UserHighlightTooltipController from './controllers/user_highlight_tooltip_controller.js';
import NostrShareMenuController from './controllers/nostr_share_menu_controller.js';
const app = startStimulusApp();
if (typeof app.debug === 'boolean') {
app.debug = false;
@ -29,3 +30,8 @@ try { @@ -29,3 +30,8 @@ try {
} catch {
/* already registered by the bundle */
}
try {
app.register('nostr-share-menu', NostrShareMenuController);
} catch {
/* already registered by the bundle */
}

16
assets/controllers/nostr_share_menu_controller.js

@ -0,0 +1,16 @@ @@ -0,0 +1,16 @@
import { Controller } from '@hotwired/stimulus';
/**
* Closes the <details> dropdown after a list action (link or button) so the panel does not stay open.
*/
export default class extends Controller {
closeAfterMenuAction(event) {
const action = event.target.closest('.nostr-share-menu__list .nostr-share-menu__action');
if (!action || !this.element.contains(action)) {
return;
}
queueMicrotask(() => {
this.element.removeAttribute('open');
});
}
}

75
src/Service/NostrArticleDiscussionSupport.php

@ -5,6 +5,7 @@ declare(strict_types=1); @@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Service;
use App\Enum\KindsEnum;
use App\Nostr\Nip19Codec;
use swentel\nostr\Filter\Filter;
/**
@ -13,6 +14,10 @@ use swentel\nostr\Filter\Filter; @@ -13,6 +14,10 @@ use swentel\nostr\Filter\Filter;
*/
final class NostrArticleDiscussionSupport
{
public function __construct(
private readonly Nip19Codec $nip19Codec,
) {
}
/**
* @return array<int, Filter>
*/
@ -103,6 +108,10 @@ final class NostrArticleDiscussionSupport @@ -103,6 +108,10 @@ final class NostrArticleDiscussionSupport
if ((int) ($event->kind ?? 0) !== KindsEnum::TEXT_NOTE->value) {
return false;
}
// Kind 1 “quotes” often lack `q`; clients embed nostr:naddr… in content instead. Those are not thread replies.
if ($this->isKind1NaddrBodyQuote($event, $coordinate)) {
return false;
}
foreach ($event->tags ?? [] as $tag) {
if (!\is_array($tag) || \count($tag) < 2) {
continue;
@ -163,6 +172,72 @@ final class NostrArticleDiscussionSupport @@ -163,6 +172,72 @@ final class NostrArticleDiscussionSupport
}
}
}
if ($kind === KindsEnum::TEXT_NOTE->value && $this->isKind1NaddrBodyQuote($event, $coordinate)) {
return true;
}
return false;
}
/**
* Kind-1 note that cites this article via nostr:naddr… in .content (no `q` tag) — not a threaded reply.
* Requires no {@code e} tags; if {@code e} tags are present, NIP-10 treats it as a thread reply.
*/
private function isKind1NaddrBodyQuote(object $event, string $coordinate): bool
{
if ($this->kind1HasThreadETag($event)) {
return false;
}
$content = (string) ($event->content ?? '');
return $this->contentNaddrReferencesCoordinate($content, $coordinate);
}
private function kind1HasThreadETag(object $event): bool
{
foreach ($event->tags ?? [] as $tag) {
if (!\is_array($tag) || \count($tag) < 2) {
continue;
}
if (strtolower((string) ($tag[0] ?? '')) !== 'e') {
continue;
}
$id = strtolower(trim((string) ($tag[1] ?? '')));
if (64 === \strlen($id) && ctype_xdigit($id)) {
return true;
}
}
return false;
}
private function contentNaddrReferencesCoordinate(string $content, string $coordinate): bool
{
if ($content === '' || !preg_match_all('/(?:nostr:)?(naddr1[a-z0-9]+)/i', $content, $matches)) {
return false;
}
$want = strtolower($coordinate);
foreach ($matches[1] as $bech) {
try {
$decoded = $this->nip19Codec->decode((string) $bech);
} catch (\Throwable) {
continue;
}
if ($decoded->type !== 'naddr' || !isset($decoded->data)) {
continue;
}
$d = $decoded->data;
$kind = (int) ($d->kind ?? 0);
$pk = strtolower((string) ($d->pubkey ?? ''));
$identifier = (string) ($d->identifier ?? '');
if ($pk === '' || $identifier === '' || (64 !== \strlen($pk) || !ctype_xdigit($pk))) {
continue;
}
$built = $kind.':'.$pk.':'.$identifier;
if ($built === $want) {
return true;
}
}
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 article on Nostr (NIP-22 kind 1111).</p>
<p class="comment-reply__lede text-subtle">Comment on this article.</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 kind-1111 comment (plain text)."
placeholder="Write a comment for this article."
></textarea>
</div>
<div class="comment-reply__actions">

13
templates/components/Molecules/NostrPreview.html.twig

@ -17,7 +17,18 @@ @@ -17,7 +17,18 @@
{% if preview.data is not null %}
<div class="nostr-preview-details mt-2">
{% if preview.data.kind is defined %}
<span class="ui-badge ui-badge--neutral">Kind: {{ preview.data.kind }}</span>
{% set _pk = preview.data.kind %}
<span class="ui-badge ui-badge--neutral">
{%- if _pk == 0 -%}Profile
{%- elseif _pk == 1 -%}Note
{%- elseif _pk == 6 or _pk == 16 -%}Repost
{%- elseif _pk == 7 -%}Reaction
{%- elseif _pk == 1111 -%}Comment
{%- elseif _pk == 9802 -%}Highlight
{%- elseif _pk == 30023 or _pk == 30024 -%}Article
{%- else -%}Event
{%- endif -%}
</span>
{% endif %}
</div>
{% endif %}

6
templates/components/Molecules/NostrShareMenu.html.twig

@ -2,7 +2,11 @@ @@ -2,7 +2,11 @@
{% set share = nostr_share_menu() %}
{% endif %}
{% if share is not null %}
<details class="nostr-share-menu{{ event_menu|default(false) ? ' nostr-share-menu--event' : '' }}">
<details
class="nostr-share-menu{{ event_menu|default(false) ? ' nostr-share-menu--event' : '' }}"
data-controller="nostr-share-menu"
data-action="click->nostr-share-menu#closeAfterMenuAction"
>
<summary class="nostr-share-menu__trigger btn btn-secondary btn-sm" title="Nostr options" aria-label="Nostr options">
{% if event_menu|default(false) %}
<span class="nostr-share-menu__glyph" aria-hidden="true">⋯</span>

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

@ -10,11 +10,11 @@ @@ -10,11 +10,11 @@
<div class="metadata comment-card__head">
<p>
{% if item.kind is defined and item.kind == 1 %}
<span class="ui-badge ui-badge--neutral" title="Legacy text-note reply (pre–NIP-22)">kind 1</span>
<span class="ui-badge ui-badge--neutral" title="Text-note reply (thread)">reply</span>
{% elseif item.kind is defined and item.kind == 1111 %}
<span class="ui-badge ui-badge--secondary" title="NIP-22 comment">1111</span>
<span class="ui-badge ui-badge--secondary" title="Scoped comment">comment</span>
{% elseif is_nip18_repost %}
<span class="ui-badge ui-badge--neutral" title="NIP-18 repost (body omitted)">repost</span>
<span class="ui-badge ui-badge--neutral" title="Shared from elsewhere">repost</span>
{% endif %}
{% if cpk != '' %}<twig:Molecules:UserFromNpub ident="{{ cpk }}" />{% else %}<span class="text-subtle">Unknown</span>{% endif %}
</p>
@ -29,13 +29,11 @@ @@ -29,13 +29,11 @@
<twig:Atoms:Content content="{{ item.unfold_reply_blurb }}" />
</div>
{% endif %}
{% if not is_nip18_repost %}
<div class="card-body">
{% if is_nip18_repost %}
<p class="text-subtle">Repost</p>
{% else %}
<twig:Atoms:Content content="{{ item.unfold_body|default(item.content|default('')) }}" />
{% endif %}
</div>
{% endif %}
{% if not is_nip18_repost and cid != '' and commentLinks[cid] is defined and commentLinks[cid]|length > 0 %}
<div class="card-footer nostr-previews mt-3">
<div class="preview-container">
@ -89,7 +87,7 @@ @@ -89,7 +87,7 @@
rows="3"
required
minlength="1"
placeholder="Plain-text reply (kind 1111 or 1); tags are built for you…"
placeholder="Plain-text reply; tags are set automatically…"
></textarea>
</div>
<div class="comment-reply__actions">
@ -115,13 +113,21 @@ @@ -115,13 +113,21 @@
{% set cpk = item.pubkey|default('') %}
{% set cts = item.created_at|default(null) %}
{% set q_repost = item.kind is defined and (item.kind == 6 or item.kind == 16) %}
{% set quote_has_q_tag = false %}
{% for t in item.tags|default([]) %}
{% if t[0] is defined and t[0]|lower == 'q' %}
{% set quote_has_q_tag = true %}
{% endif %}
{% endfor %}
<div class="card comment comment--quote">
<div class="metadata comment-card__head">
<p>
{% if q_repost %}
<span class="ui-badge ui-badge--neutral" title="NIP-18 repost (body omitted)">repost (kind {{ item.kind }})</span>
<span class="ui-badge ui-badge--neutral" title="Shared from elsewhere">repost</span>
{% elseif quote_has_q_tag %}
<span class="ui-badge ui-badge--neutral" title="Cites this with a quote tag">quote</span>
{% else %}
<span class="ui-badge ui-badge--neutral">kind {{ item.kind|default('?') }}</span>
<span class="ui-badge ui-badge--neutral" title="Cites or links this article">reference</span>
{% endif %}
{% if cpk != '' %}<twig:Molecules:UserFromNpub ident="{{ cpk }}" />{% else %}<span class="text-subtle">Unknown</span>{% endif %}
</p>
@ -133,13 +139,11 @@ @@ -133,13 +139,11 @@
{% if _qv_share %}{% include 'components/Molecules/NostrShareMenu.html.twig' with { share: _qv_share, event_menu: true } only %}{% endif %}
</div>
</div>
{% if not q_repost %}
<div class="card-body">
{% if q_repost %}
<p class="text-subtle">Repost</p>
{% else %}
<twig:Atoms:Content content="{{ item.content|default('') }}" />
{% endif %}
</div>
{% endif %}
{% if not q_repost and cid != '' and quoteLinks[cid] is defined and quoteLinks[cid]|length > 0 %}
<div class="card-footer nostr-previews mt-3">
<div class="preview-container">

40
tests/Service/NostrArticleDiscussionSupportTest.php

@ -5,6 +5,7 @@ declare(strict_types=1); @@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Tests\Service;
use App\Enum\KindsEnum;
use App\Nostr\Nip19Codec;
use App\Service\NostrArticleDiscussionSupport;
use PHPUnit\Framework\TestCase;
@ -12,9 +13,12 @@ final class NostrArticleDiscussionSupportTest extends TestCase @@ -12,9 +13,12 @@ final class NostrArticleDiscussionSupportTest extends TestCase
{
private NostrArticleDiscussionSupport $s;
private Nip19Codec $nip19;
protected function setUp(): void
{
$this->s = new NostrArticleDiscussionSupport();
$this->nip19 = new Nip19Codec();
$this->s = new NostrArticleDiscussionSupport($this->nip19);
}
public function testCreateArticleDiscussionFilterCountWithoutRoot(): void
@ -82,4 +86,38 @@ final class NostrArticleDiscussionSupportTest extends TestCase @@ -82,4 +86,38 @@ final class NostrArticleDiscussionSupportTest extends TestCase
];
$this->assertFalse($this->s->eventIsArticleQuote($e, $coord, null));
}
public function testKind1WithNaddrInContentIsQuoteNotThread(): void
{
$pk = str_repeat('a', 64);
$coord = '30023:'.$pk.':my-article';
$naddr = $this->nip19->encodeNaddr(30023, $pk, 'my-article');
$e = (object) [
'kind' => KindsEnum::TEXT_NOTE->value,
'content' => "Check this\n\nnostr:".$naddr."\n",
'tags' => [
['a', $coord, '', 'mention'],
],
];
$this->assertTrue($this->s->eventIsArticleQuote($e, $coord, null));
$this->assertFalse($this->s->eventIsLegacyThreadReply($e, $coord, null));
}
public function testKind1WithNaddrInContentButWithEtagStaysInThread(): void
{
$pk = str_repeat('a', 64);
$coord = '30023:'.$pk.':my-article';
$root = str_repeat('b', 64);
$naddr = $this->nip19->encodeNaddr(30023, $pk, 'my-article');
$e = (object) [
'kind' => KindsEnum::TEXT_NOTE->value,
'content' => "Reply\n\nnostr:".$naddr,
'tags' => [
['a', $coord],
['e', $root, '', 'reply', $pk],
],
];
$this->assertFalse($this->s->eventIsArticleQuote($e, $coord, null));
$this->assertTrue($this->s->eventIsLegacyThreadReply($e, $coord, $root));
}
}

Loading…
Cancel
Save