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.
 
 
 
 
 
 

235 lines
8.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 },
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…');
const unsigned = {
kind: 1111,
created_at: Math.floor(Date.now() / 1000),
tags: this._tags,
// Keep user-authored content clean; reply context is encoded in NIP-22 tags.
content: 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) {
const msg = data.error || `HTTP ${res.status}`;
this.setHint(msg);
this.showToast(msg, 'error');
return;
}
const okRelaysRaw = Number(data.ok_relays);
const totalRelaysRaw = Number(data.total_relays);
const okRelays = Number.isFinite(okRelaysRaw) ? okRelaysRaw : null;
const totalRelays = Number.isFinite(totalRelaysRaw) ? totalRelaysRaw : null;
const successMsg =
okRelays !== null && totalRelays !== null
? `Published to ${okRelays}/${totalRelays} relays.`
: 'Published.';
this.setHint(successMsg);
this.showToast(successMsg, 'success');
// Keep form content until the success toast is visible.
await new Promise((r) => window.setTimeout(r, 180));
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) {
const publishedId =
typeof data.id === 'string' && data.id ? data.id.toLowerCase() : '';
void this.refreshThread(publishedId);
}
}
hasNip07() {
return typeof window.nostr !== 'undefined' && typeof window.nostr.signEvent === 'function';
}
/**
* Reload the section HTML from the article comments fragment. After publishing, relays can lag;
* if `expectedEventIdHex` is set, re-fetch with backoff until the new note appears (or a cap is hit).
*
* Only sets `innerHTML` once the response actually contains the new `data-event-id`. Assigning on
* every poll replaced the whole subtree each time and re-instantiated every Stimulus `comment-reply`
* (connect/disconnect storms) while relays were still behind.
*
* @param {string} [expectedEventIdHex] lowercase 64-char hex
*/
async refreshThread(expectedEventIdHex = '') {
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 wantId =
expectedEventIdHex && /^[0-9a-f]{64}$/.test(expectedEventIdHex) ? expectedEventIdHex : '';
const maxRounds = wantId ? 14 : 1;
for (let round = 0; round < maxRounds; round += 1) {
if (round > 0) {
const delay = Math.min(1400, 200 * 2 ** (round - 1));
await new Promise((r) => setTimeout(r, delay));
}
const bust = `cb=${Date.now()}`;
const u = url.includes('?') ? `${url}&${bust}` : `${url}?${bust}`;
try {
const res = await fetch(u, {
cache: 'no-store',
credentials: 'same-origin',
headers: { Accept: 'text/html', 'X-Requested-With': 'XMLHttpRequest' },
});
if (!res.ok) {
throw new Error(String(res.status));
}
const html = await res.text();
if (!wantId) {
container.innerHTML = html;
return;
}
const parsed = new DOMParser().parseFromString(html, 'text/html');
if (parsed.querySelector(`[data-event-id="${wantId}"]`)) {
container.innerHTML = html;
return;
}
} catch {
if (round === maxRounds - 1) {
window.location.reload();
}
}
}
if (wantId) {
window.location.reload();
}
}
/**
* @param {string} msg
*/
setHint(msg) {
if (this.hasHintTarget) {
this.hintTarget.textContent = msg;
}
}
showToast(message, tone = 'success') {
const el = document.createElement('div');
el.className = `reply-toast reply-toast--${tone === 'error' ? 'error' : 'success'}`;
el.setAttribute('role', 'status');
el.setAttribute('aria-live', 'polite');
el.textContent = message;
document.body.appendChild(el);
window.setTimeout(() => {
el.classList.add('reply-toast--visible');
}, 10);
window.setTimeout(() => {
el.classList.remove('reply-toast--visible');
window.setTimeout(() => el.remove(), 220);
}, 2600);
}
}