You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

191 lines
6.6 KiB

import { Controller } from '@hotwired/stimulus';
/**
* NIP-22 kind-1111 reply: optional collapsed panel (Reply button), sign with NIP-07, POST, refresh thread.
*/
export default class extends Controller {
static targets = ['hint', 'panel', 'toggleBtn'];
static values = {
publishUrl: String,
csrf: String,
expectedCoordinate: String,
articleEventId: String,
fragmentUrl: String,
refreshAfter: { type: Boolean, default: true },
blurbLabel: String,
expectedTags: Array,
parentKind: Number,
parentId: String,
authorPubkey: String,
};
connect() {
this._tags = this.expectedTagsValue;
if (!Array.isArray(this._tags)) {
const raw = this.element.getAttribute('data-comment-reply-expected-tags-value');
try {
this._tags = raw ? JSON.parse(raw) : [];
} catch {
this._tags = [];
}
}
}
togglePanel() {
if (!this.hasPanelTarget) {
return;
}
const hidden = this.panelTarget.classList.toggle('comment-reply__panel--hidden');
const open = !hidden;
if (this.hasToggleBtnTarget) {
this.toggleBtnTarget.setAttribute('aria-expanded', open ? 'true' : 'false');
}
if (open) {
const ta = this.panelTarget.querySelector('textarea[name="body"]');
requestAnimationFrame(() => ta?.focus());
}
}
/**
* @param {Event} ev
*/
async publish(ev) {
ev.preventDefault();
if (!this.hasNip07()) {
this.setHint('Install a Nostr extension (NIP-07) to sign comments.');
return;
}
const root = this.hasPanelTarget ? this.panelTarget : this.element;
const ta = root.querySelector('textarea[name="body"]');
const text = (ta?.value ?? '').trim();
if (!text) {
this.setHint('Write something first.');
return;
}
if (this._tags.length === 0) {
this.setHint('Missing NIP-22 tag template.');
return;
}
this.setHint('Preparing event…');
// `nostr-tools` entry pulls @noble/curves (bare spec → breaks in AssetMapper). NIP-19 only needs bech32 helpers.
const { naddrEncode, neventEncode } = await import('nostr-tools/nip19');
const link = this.buildParentBech32(naddrEncode, neventEncode);
const blurb = `> Replying to **${this.blurbLabelValue}** — [view parent](nostr:${link})\n\n`;
const unsigned = {
kind: 1111,
created_at: Math.floor(Date.now() / 1000),
tags: this._tags,
content: blurb + text,
};
let signed;
try {
signed = await window.nostr.signEvent(unsigned);
} catch (err) {
this.setHint(`Signing failed: ${err instanceof Error ? err.message : String(err)}`);
return;
}
this.setHint('Publishing…');
const payload = {
event: signed,
expected_coordinate: this.expectedCoordinateValue,
parent_kind: parseInt(String(this.parentKindValue), 10),
parent_id: this.parentIdValue,
parent_author_pubkey: this.authorPubkeyValue,
article_event_id: this.articleEventIdValue || null,
csrf: this.csrfValue,
};
let res;
try {
res = await fetch(this.publishUrlValue, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'X-CSRF-TOKEN': this.csrfValue,
},
credentials: 'same-origin',
body: JSON.stringify(payload),
});
} catch (err) {
this.setHint(`Network error: ${err instanceof Error ? err.message : String(err)}`);
return;
}
const data = await res.json().catch(() => ({}));
if (!res.ok) {
this.setHint(data.error || `HTTP ${res.status}`);
return;
}
this.setHint('Published.');
if (ta) {
ta.value = '';
}
if (this.hasPanelTarget) {
this.panelTarget.classList.add('comment-reply__panel--hidden');
if (this.hasToggleBtnTarget) {
this.toggleBtnTarget.setAttribute('aria-expanded', 'false');
}
}
if (this.refreshAfterValue && this.fragmentUrlValue) {
this.refreshThread();
}
}
hasNip07() {
return typeof window.nostr !== 'undefined' && typeof window.nostr.signEvent === 'function';
}
/**
* @param {function(object): string} naddrEncode
* @param {function(object): string} neventEncode
*/
buildParentBech32(naddrEncode, neventEncode) {
const allZero = /^0{64}$/.test(this.parentIdValue);
const parts = (this.expectedCoordinateValue || '').split(':');
const k = parts[0] ? parseInt(parts[0], 10) : 30023;
const pub = parts[1] || this.authorPubkeyValue;
const d = parts[2] || '';
if (allZero && d !== '') {
return naddrEncode({ kind: k, pubkey: pub, identifier: d, relays: [] });
}
return neventEncode({
id: this.parentIdValue,
kind: this.parentKindValue,
pubkey: this.authorPubkeyValue,
relays: [],
});
}
refreshThread() {
const wrap = this.element.closest('[data-article-comments-wrapper]');
const url =
wrap?.getAttribute('data-article-comments-url-value') ||
document.querySelector('[data-article-comments-wrapper]')?.getAttribute('data-article-comments-url-value');
const container =
wrap?.querySelector('[data-article-comments-target="container"]') ||
document.querySelector('[data-article-comments-target="container"]');
if (!url || !container) {
window.location.reload();
return;
}
const bust = `cb=${Date.now()}`;
const u = url.includes('?') ? `${url}&${bust}` : `${url}?${bust}`;
void fetch(u, { headers: { Accept: 'text/html', 'X-Requested-With': 'XMLHttpRequest' } })
.then((r) => (r.ok ? r.text() : Promise.reject(new Error(String(r.status)))))
.then((html) => {
container.innerHTML = html;
})
.catch(() => {
window.location.reload();
});
}
/**
* @param {string} msg
*/
setHint(msg) {
if (this.hasHintTarget) {
this.hintTarget.textContent = msg;
}
}
}