From dd4c3ef9edf46005fda7f96b8e8f4723abb8e100 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Wed, 27 May 2026 12:40:05 +0200 Subject: [PATCH] implement superchats kind 9741 --- assets/controllers/color_scheme_controller.js | 7 +- .../magazine_hierarchy_editor_controller.js | 20 +- public/monero.png | Bin 0 -> 1924 bytes src/Enum/KindsEnum.php | 7 +- src/Service/ArticleCommentThreadLoader.php | 9 +- src/Service/NostrArticleDiscussionSupport.php | 207 +++++++++++++++++- src/Service/NostrClient.php | 39 +++- .../components/Organisms/Comments.html.twig | 48 ++++ 8 files changed, 327 insertions(+), 10 deletions(-) create mode 100644 public/monero.png diff --git a/assets/controllers/color_scheme_controller.js b/assets/controllers/color_scheme_controller.js index d72aeec..65779fb 100644 --- a/assets/controllers/color_scheme_controller.js +++ b/assets/controllers/color_scheme_controller.js @@ -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(); } diff --git a/assets/controllers/magazine_hierarchy_editor_controller.js b/assets/controllers/magazine_hierarchy_editor_controller.js index 298d0c9..89067f1 100644 --- a/assets/controllers/magazine_hierarchy_editor_controller.js +++ b/assets/controllers/magazine_hierarchy_editor_controller.js @@ -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,7 +132,13 @@ export default class MagazineHierarchyEditorController extends Controller { return; } for (const el of queryEditorNodeFieldsets(this.nodesTarget)) { - this.nodeBaseline.set(el, snapshotFromElement(el)); + // 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)); + } } } diff --git a/public/monero.png b/public/monero.png new file mode 100644 index 0000000000000000000000000000000000000000..d157f36c54efb7bf160d2b0ac08d20358fbd0078 GIT binary patch literal 1924 zcmV-~2YdL5P)004R> z004l5008;`004mK004C`008P>0026e000+ooVrmw0006#P)t-s|NsC0{rvst-uTP2 z^sbBUmT2vUPwIL=>2fvca4_k6KJJE3|Ni~^+s*T_j_!?F>2^2nb2099GVF0N>Txjb zi&p#G&HerS{_yMezMk)hQ|@;%>v1vZaWUy}GwE+I?}=0Y|Nj2+?DVsd?0rJ+b~5O1 zFzIqM>w7@-w2}V){`SMB?0`k>aWVYo+wYZQ?Q${p$gk{qI`glK>2Ndiu8RKs`|+W5 z>T@*koNexSGWW@`>T)&w>EG&hIqY*Z?T=sRa4_}5sPLV0>T)st^zZ9>Kf!Xkr1rt2^Rtop-p};6nf14s^Rkit^Y80r)BpYZ^t_<$g--8t zG4r{a`Q6R#bTaC3G4r^Z@~?~UcQWjVR_Sv!>Todl%CqsViSCwX?S@Y3dp_uMH2wYk z{QUg-`uc=~gy`t#hK7b;UtecuXYTIq>2oyf?CfS{W?^ArZEbB-R8(tgYwhjrZ*Ol< zP*7=UY4x;|^s|&|YHCqYQS60IPEJlwPfzo%i}uR1O-)Vs%(eUK-|2EO{{H=JY;5zh zkMDOi=yEmhbTsWZQc_az@bJ32y1u@? zy}i9mOicIp_h@KnR#sN$=jT^fS5s3{=H}+RyStm4n~8~uf`WpaoSax#SiHQv;NakS zd3m(7w4tG)TwGkNtgO`3)SjN6aBy(r zL;#2d9Y_EG00(qQO+^Rl1se(m#f&o=!LlQZ)0QGK+FE4aT>duSo6MVi zGU<1EaweJg{oZ%qy~&vZcom^9lO|88cy4MGp=jmv6;mcpGH-+`M*d!y_TqFd&T)w< zgsKu8xt#vew3n;(o8k5uGhg9&t|nQVOd(w1KRYI>riE!;|r=imv6>l>=DEt&NgzX@+0Jidg^l2AY23rg!*kxIfFk zyJ;h>W5d+-MhD;H_4(&E!}~%gAuD_Un`sRs!iRb<@s_O#{T8<^4co&ASz$Y*x9I?s z*t!MN5%{PW0C78DXBdI{cfyV$Aj5x5hauA3l4OADNNGY=_yi)=43KPTro*(WbvI&w z7?dPrg-^+X7z3c)t-A!*MZGQ(K#Wlm{3FHD`55e3Yy8tLr35VL<0O)Y_Lf;=7H=Ka>Lty1J0G(Z~ z3}$`|U7aofLS8`E;UhYMWiaNC`j33kD?D%Fx?u)qnJ>uRSG1eGakjSnjw* zu<{?z9s9-&sH^m8V@?$NO~Q$9O@CgL@}4$+=QBt-QIhfoA#FTQP`@OJInN+f_>6K+ zlog3(NHc%Gq{tpXZTx%q!6$)Sun3mFAp0cv5l%*pQ#{pT12_nV-v($oWej5UB>dFY zirT3h2`GV`1Mv?~zav4>(%MEUJbmVDEmiNi^L79s6uU|Lr~56y`E$LxmkLSbhcoxO(NXZn<>vg3rT}2`HWe9A$nB;C{Mcasz0FYro`~-v)47 z0um5{+SjKte_#S~P91jjTY!SBD7H6Z-mwq|;QDNUf^5GQ`~3HZ!J*;d>!!Muk%F&A zlx}O)@bJ(zW706b@#{@VH6<4UC0nbco4Fg<9M#`$X`;eZC?Ru^sNMSA{$=??l?oG* zq+Yi_FL%JGCQigJYNN()H0vXbO@tt7W8mx}#vOl_PY|`k9oRJm7y>3lb0%noi(7Nwo zNB_Jb2Jb&m#St$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 diff --git a/src/Service/ArticleCommentThreadLoader.php b/src/Service/ArticleCommentThreadLoader.php index 36f8700..df80243 100644 --- a/src/Service/ArticleCommentThreadLoader.php +++ b/src/Service/ArticleCommentThreadLoader.php @@ -39,6 +39,7 @@ final readonly class ArticleCommentThreadLoader * @return array{ * list: array, * quotes: array, + * superchats: list>, * commentLinks: array>, * quoteLinks: array>, * processedContent: array @@ -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 * @return array{ * list: array, * quotes: array, + * superchats: list>, * commentLinks: array>, * quoteLinks: array>, * processedContent: array @@ -148,11 +151,12 @@ final readonly class ArticleCommentThreadLoader } /** - * @param array{thread: array, quotes: array} $discussion + * @param array{thread: array, quotes: array, superchats?: list>} $discussion * * @return array{ * list: array, * quotes: array, + * superchats: list>, * commentLinks: array>, * quoteLinks: array>, * processedContent: array @@ -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 return [ 'list' => $list, 'quotes' => $quotes, + 'superchats' => $discussion['superchats'] ?? [], 'commentLinks' => $commentLinks, 'quoteLinks' => $quoteLinks, 'processedContent' => $processedContent, diff --git a/src/Service/NostrArticleDiscussionSupport.php b/src/Service/NostrArticleDiscussionSupport.php index 73c7404..fcbd808 100644 --- a/src/Service/NostrArticleDiscussionSupport.php +++ b/src/Service/NostrArticleDiscussionSupport.php @@ -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 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 + */ + 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 $attestRequiredEvents kind 9740 / 9736 events + * @param list $selfAttestingEvents kind 1814 events (Garnet Monero tips) + * @param list $attestations kind 9741 events (must be from the article author) + * @param string $authorPubkeyHex hex pubkey of the article author + * @return list + */ + 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) { diff --git a/src/Service/NostrClient.php b/src/Service/NostrClient.php index a959c6f..a5f7c4c 100644 --- a/src/Service/NostrClient.php +++ b/src/Service/NostrClient.php @@ -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, quotes: array, partial?: bool} + * @return array{thread: array, quotes: array, superchats: list>, partial?: bool} */ public function getArticleDiscussion(string $coordinate, ?string $rootEventHexId = null): array { @@ -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 $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 } } + $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 $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]; } /** diff --git a/templates/components/Organisms/Comments.html.twig b/templates/components/Organisms/Comments.html.twig index 6bd1844..06c74b2 100644 --- a/templates/components/Organisms/Comments.html.twig +++ b/templates/components/Organisms/Comments.html.twig @@ -1,4 +1,52 @@ {% set ctx = comment_reply_context|default(null) %} + +{% if superchats is defined and superchats|length > 0 %} +
+

Superchats

+ {% for sc in superchats %} + {% set tier = sc.tier|default('') %} + {% set ptype = sc.payment_type|default('lightning') %} + {% set is_monero = ptype starts with 'monero' %} +
+ + {% if sc.content|default('')|trim != '' %} +
+ {{ sc.content|e }} +
+ {% endif %} +
+ {% endfor %} +
+{% endif %} +
{% for item in list %} {% set cid = item.id|default('')|lower %}