Browse Source

implement superchats kind 9741

gitcitadel
Silberengel 2 weeks ago
parent
commit
dd4c3ef9ed
  1. 7
      assets/controllers/color_scheme_controller.js
  2. 18
      assets/controllers/magazine_hierarchy_editor_controller.js
  3. BIN
      public/monero.png
  4. 7
      src/Enum/KindsEnum.php
  5. 9
      src/Service/ArticleCommentThreadLoader.php
  6. 207
      src/Service/NostrArticleDiscussionSupport.php
  7. 39
      src/Service/NostrClient.php
  8. 48
      templates/components/Organisms/Comments.html.twig

7
assets/controllers/color_scheme_controller.js

@ -40,7 +40,12 @@ export default class extends Controller { @@ -40,7 +40,12 @@ export default class extends Controller {
}
if (this.link) {
const light = this.link.getAttribute('data-href-light');
this.link.setAttribute('href', scheme === 'dark' && darkHref ? darkHref : light);
const href = scheme === 'dark' && darkHref ? darkHref : light;
// getAttribute returns null when the attribute is absent; passing null to setAttribute
// would coerce it to the string "null", producing a broken stylesheet URL.
if (href !== null) {
this.link.setAttribute('href', href);
}
}
this._refreshIcons();
}

18
assets/controllers/magazine_hierarchy_editor_controller.js

@ -28,15 +28,21 @@ export default class MagazineHierarchyEditorController extends Controller { @@ -28,15 +28,21 @@ export default class MagazineHierarchyEditorController extends Controller {
};
connect() {
this.nodeBaseline = new WeakMap();
// Preserve the WeakMap across Stimulus reconnects so that baselines captured at page-load
// (or after a successful publish) are not lost. A fresh WeakMap would make captureBaselines()
// re-snapshot the *current* (potentially edited) DOM state, causing isNodeDirty() to return
// false for every node and publish to silently do nothing.
this.nodeBaseline ??= new WeakMap();
this.captureBaselines();
/**
* Clicks: `document` capture + `this.element.contains(target)` so we still run when bubble
* never reaches the panel (e.g. `stopPropagation` from another listener) or fieldset/legend
* hit-testing is odd.
*/
this._onDocClickCapture = this._onDocClickCapture.bind(this);
this._onPanelFocusOut = this._onPanelFocusOut.bind(this);
// Bind once: re-binding on every reconnect wraps the already-bound function, and the bound
// reference must be stable so removeEventListener in disconnect() can match it.
this._onDocClickCapture ??= this._onDocClickCapture.bind(this);
this._onPanelFocusOut ??= this._onPanelFocusOut.bind(this);
document.addEventListener('click', this._onDocClickCapture, true);
this.element.addEventListener('focusout', this._onPanelFocusOut);
}
@ -126,9 +132,15 @@ export default class MagazineHierarchyEditorController extends Controller { @@ -126,9 +132,15 @@ export default class MagazineHierarchyEditorController extends Controller {
return;
}
for (const el of queryEditorNodeFieldsets(this.nodesTarget)) {
// Only set a baseline for nodes that don't already have one. On reconnect the WeakMap
// is preserved (see connect()), so existing entries reflect the true pre-edit state.
// Overwriting them here would reset "original = current dirty state", making every node
// appear clean and causing publish to silently skip it.
if (!this.nodeBaseline.has(el)) {
this.nodeBaseline.set(el, snapshotFromElement(el));
}
}
}
/**
* @param {Event} [event]

BIN
public/monero.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

7
src/Enum/KindsEnum.php

@ -37,7 +37,12 @@ enum KindsEnum: int @@ -37,7 +37,12 @@ enum KindsEnum: int
{
return [self::LONGFORM->value, self::LONGFORM_DRAFT->value, self::WIKI->value];
}
case ZAP = 9735; // NIP-57, Zaps
case ZAP_REQUEST = 9734; // NIP-57, Zap request
case ZAP = 9735; // NIP-57, Zap receipt (Lightning)
case MONERO_ZAP_RECEIPT = 9736; // Monero zap receipt (Garnet/Nosmero, analogous to 9735)
case PAYMENT_NOTIFICATION = 9740; // NIP-A3, payment notification (superchat sender)
case PAYMENT_ATTESTATION = 9741; // NIP-A3, payment attestation (superchat recipient confirms)
case MONERO_TIP = 1814; // Garnet Monero tip (self-attesting, proof embedded in content JSON)
case HIGHLIGHTS = 9802;
case RELAY_LIST = 10002; // NIP-65, Relay list metadata
case EMOJI_LIST = 10030; // NIP-51 standard list, NIP-30 emoji tags

9
src/Service/ArticleCommentThreadLoader.php

@ -39,6 +39,7 @@ final readonly class ArticleCommentThreadLoader @@ -39,6 +39,7 @@ final readonly class ArticleCommentThreadLoader
* @return array{
* list: array<int, object>,
* quotes: array<int, object>,
* superchats: list<array<string,mixed>>,
* commentLinks: array<string, array<int, mixed>>,
* quoteLinks: array<string, array<int, mixed>>,
* processedContent: array<string, string>
@ -46,6 +47,7 @@ final readonly class ArticleCommentThreadLoader @@ -46,6 +47,7 @@ final readonly class ArticleCommentThreadLoader
*
* Each object in `list` may be enriched with: unfold_reply_blurb, unfold_body, unfold_depth
* (0–3, for UI indentation).
* `superchats` contains attested NIP-A3 kind-9740 items sorted by amount desc.
*/
public function tryLoadFromCacheOnly(string $coordinate, ?string $articleEventHexId = null): ?array
{
@ -78,6 +80,7 @@ final readonly class ArticleCommentThreadLoader @@ -78,6 +80,7 @@ final readonly class ArticleCommentThreadLoader
* @return array{
* list: array<int, object>,
* quotes: array<int, object>,
* superchats: list<array<string,mixed>>,
* commentLinks: array<string, array<int, mixed>>,
* quoteLinks: array<string, array<int, mixed>>,
* processedContent: array<string, string>
@ -148,11 +151,12 @@ final readonly class ArticleCommentThreadLoader @@ -148,11 +151,12 @@ final readonly class ArticleCommentThreadLoader
}
/**
* @param array{thread: array<int, object>, quotes: array<int, object>} $discussion
* @param array{thread: array<int, object>, quotes: array<int, object>, superchats?: list<array<string,mixed>>} $discussion
*
* @return array{
* list: array<int, object>,
* quotes: array<int, object>,
* superchats: list<array<string,mixed>>,
* commentLinks: array<string, array<int, mixed>>,
* quoteLinks: array<string, array<int, mixed>>,
* processedContent: array<string, string>
@ -162,10 +166,12 @@ final readonly class ArticleCommentThreadLoader @@ -162,10 +166,12 @@ final readonly class ArticleCommentThreadLoader
{
$list = $discussion['thread'] ?? [];
$quotes = $discussion['quotes'] ?? [];
$superchats = $discussion['superchats'] ?? [];
$this->logger->info('comments.loader.cache_resolved', [
'elapsed_since_start_ms' => (int) round((microtime(true) - $t0) * 1000),
'thread_events' => \count($list),
'quote_events' => \count($quotes),
'superchat_count' => \count($superchats),
]);
$this->enrichThreadListForDisplay($list, $articleEventHexId);
@ -196,6 +202,7 @@ final readonly class ArticleCommentThreadLoader @@ -196,6 +202,7 @@ final readonly class ArticleCommentThreadLoader
return [
'list' => $list,
'quotes' => $quotes,
'superchats' => $discussion['superchats'] ?? [],
'commentLinks' => $commentLinks,
'quoteLinks' => $quoteLinks,
'processedContent' => $processedContent,

207
src/Service/NostrArticleDiscussionSupport.php

@ -9,7 +9,8 @@ use App\Nostr\Nip19Codec; @@ -9,7 +9,8 @@ use App\Nostr\Nip19Codec;
use swentel\nostr\Filter\Filter;
/**
* REQ {@link Filter}s and tag-matching rules for long-form article discussion (NIP-22 kind 1111, legacy kind 1, quotes).
* REQ {@link Filter}s and tag-matching rules for long-form article discussion (NIP-22 kind 1111, legacy kind 1, quotes)
* and NIP-A3 superchats (kind 9740 payment notifications attested by kind 9741 from the article author).
* Used by {@see NostrClient::getArticleDiscussion()}.
*/
final class NostrArticleDiscussionSupport
@ -85,6 +86,210 @@ final class NostrArticleDiscussionSupport @@ -85,6 +86,210 @@ final class NostrArticleDiscussionSupport
return $filters;
}
/**
* Additional REQ filters that fetch NIP-A3 superchats and Monero tips for an article:
* - kind 9740 (Lightning payment notifications) tagged `#a` with article coordinate
* - kind 9736 (Monero zap receipts) tagged `#a` with article coordinate
* - kind 1814 (Garnet self-attesting Monero tips) tagged `#a` with article coordinate
* - kind 9741 (payment attestations) published by the article author
*
* @return array<int, Filter>
*/
public function createSuperchatFilters(string $coordinate, string $authorPubkeyHex): array
{
if ($authorPubkeyHex === '' || 64 !== \strlen($authorPubkeyHex) || !ctype_xdigit($authorPubkeyHex)) {
return [];
}
$filters = [];
// Lightning payment notifications (9740) — require 9741 attestation.
$fNotif = new Filter();
$fNotif->setKinds([KindsEnum::PAYMENT_NOTIFICATION->value]);
$fNotif->setTag('#a', [$coordinate]);
$fNotif->setLimit(50);
$filters[] = $fNotif;
// Monero zap receipts (9736) — analogous to kind 9735; require 9741 attestation.
$fMoneroZap = new Filter();
$fMoneroZap->setKinds([KindsEnum::MONERO_ZAP_RECEIPT->value]);
$fMoneroZap->setTag('#a', [$coordinate]);
$fMoneroZap->setLimit(50);
$filters[] = $fMoneroZap;
// Garnet Monero tips (1814) — self-attesting; proof is embedded in the JSON content.
$fMoneroTip = new Filter();
$fMoneroTip->setKinds([KindsEnum::MONERO_TIP->value]);
$fMoneroTip->setTag('#a', [$coordinate]);
$fMoneroTip->setLimit(50);
$filters[] = $fMoneroTip;
// Attestations from the article author covering any of the above.
$fAttest = new Filter();
$fAttest->setKinds([KindsEnum::PAYMENT_ATTESTATION->value]);
$fAttest->setAuthors([$authorPubkeyHex]);
$fAttest->setLimit(200);
$filters[] = $fAttest;
return $filters;
}
/**
* Build the superchat item list from three event buckets:
*
* - `$attestRequiredEvents`: kind 9740 (Lightning payto) and kind 9736 (Monero zap receipt).
* Only included when the article author has published a matching kind 9741 attestation.
* - `$selfAttestingEvents`: kind 1814 (Garnet Monero tips).
* These embed a cryptographic Monero payment proof in the JSON `content`, so no 9741 is needed.
* - `$attestations`: kind 9741 events published by the article author.
*
* Items are sorted by payment amount descending (highest superchat first), then newest first.
*
* @param list<object> $attestRequiredEvents kind 9740 / 9736 events
* @param list<object> $selfAttestingEvents kind 1814 events (Garnet Monero tips)
* @param list<object> $attestations kind 9741 events (must be from the article author)
* @param string $authorPubkeyHex hex pubkey of the article author
* @return list<array{id:string,pubkey:string,content:string,amount_msats:int,amount_sats:int,tier:string,payment_type:string,created_at:int}>
*/
public function buildSuperchatItems(
array $attestRequiredEvents,
array $selfAttestingEvents,
array $attestations,
string $authorPubkeyHex,
): array {
$authorHex = strtolower($authorPubkeyHex);
// Build a set of event-IDs the author has attested (covers 9740, 9736, 9735).
$attestedIds = [];
foreach ($attestations as $att) {
if (strtolower((string) ($att->pubkey ?? '')) !== $authorHex) {
continue;
}
foreach ($att->tags ?? [] as $tag) {
if (!\is_array($tag) || \count($tag) < 2) {
continue;
}
if ((string) ($tag[0] ?? '') === 'e') {
$eid = strtolower(trim((string) ($tag[1] ?? '')));
if (64 === \strlen($eid) && ctype_xdigit($eid)) {
$attestedIds[$eid] = true;
}
}
}
}
$items = [];
// Attestation-required events (9740, 9736).
foreach ($attestRequiredEvents as $notif) {
$id = strtolower(trim((string) ($notif->id ?? '')));
if ($id === '' || !isset($attestedIds[$id])) {
continue;
}
$kind = (int) ($notif->kind ?? 0);
$paymentType = $kind === KindsEnum::MONERO_ZAP_RECEIPT->value ? 'monero_zap' : 'lightning';
$items[] = $this->buildSuperchatItemFromNotification($notif, $id, $paymentType);
}
// Self-attesting Monero tips (kind 1814 from Garnet).
foreach ($selfAttestingEvents as $notif) {
$id = strtolower(trim((string) ($notif->id ?? '')));
if ($id === '') {
continue;
}
$items[] = $this->buildSuperchatItemFromMoneroTip($notif, $id);
}
// Sort highest amount first, then newest first as tiebreaker.
usort($items, static function (array $a, array $b): int {
if ($b['amount_msats'] !== $a['amount_msats']) {
return $b['amount_msats'] <=> $a['amount_msats'];
}
return $b['created_at'] <=> $a['created_at'];
});
return $items;
}
/**
* @return array{id:string,pubkey:string,content:string,amount_msats:int,amount_sats:int,tier:string,payment_type:string,created_at:int}
*/
private function buildSuperchatItemFromNotification(object $notif, string $id, string $paymentType): array
{
$amountMsats = 0;
foreach ($notif->tags ?? [] as $tag) {
if (!\is_array($tag) || \count($tag) < 2) {
continue;
}
if ((string) ($tag[0] ?? '') === 'amount' && ctype_digit(trim((string) ($tag[1] ?? '')))) {
$amountMsats = (int) $tag[1];
break;
}
}
return $this->assembleItem($id, $notif, (string) ($notif->content ?? ''), $amountMsats, $paymentType);
}
/**
* Kind 1814 Garnet Monero tip: content is a JSON string with at minimum `{"message": "...", "txid": "..."}`.
* Extract the `message` field as the display content; fall back to the raw content string.
* Amount may be present in an `amount` tag (millisats) or absent.
*
* @return array{id:string,pubkey:string,content:string,amount_msats:int,amount_sats:int,tier:string,payment_type:string,created_at:int}
*/
private function buildSuperchatItemFromMoneroTip(object $notif, string $id): array
{
$rawContent = (string) ($notif->content ?? '');
$message = $rawContent;
try {
$parsed = json_decode($rawContent, true, 10, \JSON_THROW_ON_ERROR);
if (\is_array($parsed) && isset($parsed['message']) && \is_string($parsed['message'])) {
$message = $parsed['message'];
}
} catch (\JsonException) {
// keep raw content as message
}
$amountMsats = 0;
foreach ($notif->tags ?? [] as $tag) {
if (!\is_array($tag) || \count($tag) < 2) {
continue;
}
if ((string) ($tag[0] ?? '') === 'amount' && ctype_digit(trim((string) ($tag[1] ?? '')))) {
$amountMsats = (int) $tag[1];
break;
}
}
return $this->assembleItem($id, $notif, $message, $amountMsats, 'monero_tip');
}
/**
* @return array{id:string,pubkey:string,content:string,amount_msats:int,amount_sats:int,tier:string,payment_type:string,created_at:int}
*/
private function assembleItem(string $id, object $notif, string $content, int $amountMsats, string $paymentType): array
{
$amountSats = (int) round($amountMsats / 1000);
$tier = match (true) {
$amountSats >= 100_000 => 'gold',
$amountSats >= 10_000 => 'silver',
$amountSats >= 1_000 => 'bronze',
default => '',
};
return [
'id' => $id,
'pubkey' => (string) ($notif->pubkey ?? ''),
'content' => $content,
'amount_msats' => $amountMsats,
'amount_sats' => $amountSats,
'tier' => $tier,
'payment_type' => $paymentType,
'created_at' => (int) ($notif->created_at ?? 0),
];
}
public function eventIsNip22ArticleThreadReply(object $event, string $coordinate): bool
{
if ((int) ($event->kind ?? 0) !== KindsEnum::COMMENTS->value) {

39
src/Service/NostrClient.php

@ -762,7 +762,7 @@ class NostrClient @@ -762,7 +762,7 @@ class NostrClient
* @param string $coordinate kind:pubkey:d-identifier (e.g. longform address)
* @param null|string $rootEventHexId Published article event id (hex) for #e / #q matching
*
* @return array{thread: array<int, object>, quotes: array<int, object>, partial?: bool}
* @return array{thread: array<int, object>, quotes: array<int, object>, superchats: list<array<string,mixed>>, partial?: bool}
*/
public function getArticleDiscussion(string $coordinate, ?string $rootEventHexId = null): array
{
@ -797,6 +797,9 @@ class NostrClient @@ -797,6 +797,9 @@ class NostrClient
}
$filters = $this->articleDiscussion->createArticleDiscussionFilters($coordinate, $rootEventHexId);
foreach ($this->articleDiscussion->createSuperchatFilters($coordinate, $pubkey) as $sf) {
$filters[] = $sf;
}
$subscription = new Subscription();
$subscriptionId = $subscription->setId();
$requestMessage = new RequestMessage($subscriptionId, $filters);
@ -877,9 +880,24 @@ class NostrClient @@ -877,9 +880,24 @@ class NostrClient
$all = array_values($byId);
$thread = [];
$threadIds = [];
$attestRequiredSuperchats = []; // kind 9740 (Lightning payto) + kind 9736 (Monero zap receipt)
$selfAttestingSuperchats = []; // kind 1814 (Garnet Monero tip, proof embedded)
$attestations9741 = [];
foreach ($all as $event) {
$kind = (int) ($event->kind ?? 0);
if ($kind === KindsEnum::PAYMENT_NOTIFICATION->value || $kind === KindsEnum::MONERO_ZAP_RECEIPT->value) {
$attestRequiredSuperchats[] = $event;
continue;
}
if ($kind === KindsEnum::MONERO_TIP->value) {
$selfAttestingSuperchats[] = $event;
continue;
}
if ($kind === KindsEnum::PAYMENT_ATTESTATION->value) {
$attestations9741[] = $event;
continue;
}
if ($kind === KindsEnum::COMMENTS->value && $this->articleDiscussion->eventIsNip22ArticleThreadReply($event, $coordinate)) {
$thread[] = $event;
$threadIds[(string) $event->id] = true;
@ -892,17 +910,33 @@ class NostrClient @@ -892,17 +910,33 @@ class NostrClient
}
}
$superchatKinds = [
KindsEnum::PAYMENT_NOTIFICATION->value,
KindsEnum::MONERO_ZAP_RECEIPT->value,
KindsEnum::MONERO_TIP->value,
KindsEnum::PAYMENT_ATTESTATION->value,
];
$quotes = [];
foreach ($all as $event) {
$id = (string) ($event->id ?? '');
if ($id === '' || isset($threadIds[$id])) {
continue;
}
if (\in_array((int) ($event->kind ?? 0), $superchatKinds, true)) {
continue;
}
if ($this->articleDiscussion->eventIsArticleQuote($event, $coordinate, $rootEventHexId)) {
$quotes[] = $event;
}
}
$superchats = $this->articleDiscussion->buildSuperchatItems(
$attestRequiredSuperchats,
$selfAttestingSuperchats,
$attestations9741,
$pubkey,
);
$sortAsc = static function ($a, $b): int {
return ((int) ($a->created_at ?? 0)) <=> ((int) ($b->created_at ?? 0));
};
@ -915,12 +949,13 @@ class NostrClient @@ -915,12 +949,13 @@ class NostrClient
$this->logger->info('nostr.article_discussion.done', [
'thread_count' => \count($thread),
'quotes_count' => \count($quotes),
'superchat_count' => \count($superchats),
'partial' => $partial,
'responded_relays' => $respondedRelayCount,
'planned_relays' => \count($plannedRelayUrls),
]);
return ['thread' => $thread, 'quotes' => $quotes, 'partial' => $partial];
return ['thread' => $thread, 'quotes' => $quotes, 'superchats' => $superchats, 'partial' => $partial];
}
/**

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

@ -1,4 +1,52 @@ @@ -1,4 +1,52 @@
{% set ctx = comment_reply_context|default(null) %}
{% if superchats is defined and superchats|length > 0 %}
<div class="superchats">
<h3 class="superchats__title">Superchats</h3>
{% for sc in superchats %}
{% set tier = sc.tier|default('') %}
{% set ptype = sc.payment_type|default('lightning') %}
{% set is_monero = ptype starts with 'monero' %}
<div class="card superchat{% if tier != '' %} superchat--{{ tier }}{% endif %}{% if is_monero %} superchat--monero{% endif %}">
<div class="metadata superchat__head">
<p>
{% if tier == 'gold' %}
<span class="ui-badge ui-badge--warning" title="Gold superchat">⭐ gold</span>
{% elseif tier == 'silver' %}
<span class="ui-badge ui-badge--secondary" title="Silver superchat">silver</span>
{% elseif tier == 'bronze' %}
<span class="ui-badge ui-badge--neutral" title="Bronze superchat">bronze</span>
{% endif %}
{% if is_monero %}
<img src="{{ asset('monero.png') }}" alt="Monero" title="Monero" class="superchat__payment-icon superchat__payment-icon--monero" width="16" height="16">
{% else %}
<span class="superchat__payment-icon" title="Lightning">⚡</span>
{% endif %}
{% if sc.pubkey|default('') != '' %}
<twig:Molecules:UserFromNpub ident="{{ sc.pubkey }}" />
{% else %}
<span class="text-subtle">Unknown</span>
{% endif %}
</p>
<div class="metadata__end">
{% if sc.amount_sats|default(0) > 0 %}
<span class="superchat__amount">{{ sc.amount_sats|number_format }} {{ is_monero ? 'sats (XMR approx.)' : 'sats' }}</span>
{% endif %}
{% if sc.created_at|default(null) %}
<small>{{ sc.created_at|date('F j Y') }}</small>
{% endif %}
</div>
</div>
{% if sc.content|default('')|trim != '' %}
<div class="card-body superchat__body">
{{ sc.content|e }}
</div>
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
<div class="comments" data-comments-partial="{{ comments_partial|default(false) ? '1' : '0' }}">
{% for item in list %}
{% set cid = item.id|default('')|lower %}

Loading…
Cancel
Save