Browse Source

implement superchats kind 9741

gitcitadel
Silberengel 2 weeks ago
parent
commit
dd4c3ef9ed
  1. 7
      assets/controllers/color_scheme_controller.js
  2. 20
      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 {
} }
if (this.link) { if (this.link) {
const light = this.link.getAttribute('data-href-light'); 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(); this._refreshIcons();
} }

20
assets/controllers/magazine_hierarchy_editor_controller.js

@ -28,15 +28,21 @@ export default class MagazineHierarchyEditorController extends Controller {
}; };
connect() { 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(); this.captureBaselines();
/** /**
* Clicks: `document` capture + `this.element.contains(target)` so we still run when bubble * 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 * never reaches the panel (e.g. `stopPropagation` from another listener) or fieldset/legend
* hit-testing is odd. * hit-testing is odd.
*/ */
this._onDocClickCapture = this._onDocClickCapture.bind(this); // Bind once: re-binding on every reconnect wraps the already-bound function, and the bound
this._onPanelFocusOut = this._onPanelFocusOut.bind(this); // 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); document.addEventListener('click', this._onDocClickCapture, true);
this.element.addEventListener('focusout', this._onPanelFocusOut); this.element.addEventListener('focusout', this._onPanelFocusOut);
} }
@ -126,7 +132,13 @@ export default class MagazineHierarchyEditorController extends Controller {
return; return;
} }
for (const el of queryEditorNodeFieldsets(this.nodesTarget)) { 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));
}
} }
} }

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
{ {
return [self::LONGFORM->value, self::LONGFORM_DRAFT->value, self::WIKI->value]; 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 HIGHLIGHTS = 9802;
case RELAY_LIST = 10002; // NIP-65, Relay list metadata case RELAY_LIST = 10002; // NIP-65, Relay list metadata
case EMOJI_LIST = 10030; // NIP-51 standard list, NIP-30 emoji tags 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
* @return array{ * @return array{
* list: array<int, object>, * list: array<int, object>,
* quotes: array<int, object>, * quotes: array<int, object>,
* superchats: list<array<string,mixed>>,
* commentLinks: array<string, array<int, mixed>>, * commentLinks: array<string, array<int, mixed>>,
* quoteLinks: array<string, array<int, mixed>>, * quoteLinks: array<string, array<int, mixed>>,
* processedContent: array<string, string> * processedContent: array<string, string>
@ -46,6 +47,7 @@ final readonly class ArticleCommentThreadLoader
* *
* Each object in `list` may be enriched with: unfold_reply_blurb, unfold_body, unfold_depth * Each object in `list` may be enriched with: unfold_reply_blurb, unfold_body, unfold_depth
* (0–3, for UI indentation). * (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 public function tryLoadFromCacheOnly(string $coordinate, ?string $articleEventHexId = null): ?array
{ {
@ -78,6 +80,7 @@ final readonly class ArticleCommentThreadLoader
* @return array{ * @return array{
* list: array<int, object>, * list: array<int, object>,
* quotes: array<int, object>, * quotes: array<int, object>,
* superchats: list<array<string,mixed>>,
* commentLinks: array<string, array<int, mixed>>, * commentLinks: array<string, array<int, mixed>>,
* quoteLinks: array<string, array<int, mixed>>, * quoteLinks: array<string, array<int, mixed>>,
* processedContent: array<string, string> * processedContent: array<string, string>
@ -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{ * @return array{
* list: array<int, object>, * list: array<int, object>,
* quotes: array<int, object>, * quotes: array<int, object>,
* superchats: list<array<string,mixed>>,
* commentLinks: array<string, array<int, mixed>>, * commentLinks: array<string, array<int, mixed>>,
* quoteLinks: array<string, array<int, mixed>>, * quoteLinks: array<string, array<int, mixed>>,
* processedContent: array<string, string> * processedContent: array<string, string>
@ -162,10 +166,12 @@ final readonly class ArticleCommentThreadLoader
{ {
$list = $discussion['thread'] ?? []; $list = $discussion['thread'] ?? [];
$quotes = $discussion['quotes'] ?? []; $quotes = $discussion['quotes'] ?? [];
$superchats = $discussion['superchats'] ?? [];
$this->logger->info('comments.loader.cache_resolved', [ $this->logger->info('comments.loader.cache_resolved', [
'elapsed_since_start_ms' => (int) round((microtime(true) - $t0) * 1000), 'elapsed_since_start_ms' => (int) round((microtime(true) - $t0) * 1000),
'thread_events' => \count($list), 'thread_events' => \count($list),
'quote_events' => \count($quotes), 'quote_events' => \count($quotes),
'superchat_count' => \count($superchats),
]); ]);
$this->enrichThreadListForDisplay($list, $articleEventHexId); $this->enrichThreadListForDisplay($list, $articleEventHexId);
@ -196,6 +202,7 @@ final readonly class ArticleCommentThreadLoader
return [ return [
'list' => $list, 'list' => $list,
'quotes' => $quotes, 'quotes' => $quotes,
'superchats' => $discussion['superchats'] ?? [],
'commentLinks' => $commentLinks, 'commentLinks' => $commentLinks,
'quoteLinks' => $quoteLinks, 'quoteLinks' => $quoteLinks,
'processedContent' => $processedContent, 'processedContent' => $processedContent,

207
src/Service/NostrArticleDiscussionSupport.php

@ -9,7 +9,8 @@ use App\Nostr\Nip19Codec;
use swentel\nostr\Filter\Filter; 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()}. * Used by {@see NostrClient::getArticleDiscussion()}.
*/ */
final class NostrArticleDiscussionSupport final class NostrArticleDiscussionSupport
@ -85,6 +86,210 @@ final class NostrArticleDiscussionSupport
return $filters; 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 public function eventIsNip22ArticleThreadReply(object $event, string $coordinate): bool
{ {
if ((int) ($event->kind ?? 0) !== KindsEnum::COMMENTS->value) { if ((int) ($event->kind ?? 0) !== KindsEnum::COMMENTS->value) {

39
src/Service/NostrClient.php

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

Loading…
Cancel
Save