clone of github.com/decent-newsroom/newsroom
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.
 
 
 
 
 
 

170 lines
5.9 KiB

import { Controller } from '@hotwired/stimulus';
// NIP-22 Comment Publishing Controller
// Usage: Attach to a form with data attributes for root/parent context
export default class extends Controller {
static targets = ['publishButton', 'status'];
static values = {
publishUrl: String,
csrfToken: String
};
connect() {
console.log('Nostr comment controller connected');
try {
console.debug('[nostr-comment] publishUrl:', this.publishUrlValue || '(none)');
console.debug('[nostr-comment] has csrfToken:', Boolean(this.csrfTokenValue));
} catch (_) {}
}
async publish(event) {
event.preventDefault();
if (!this.publishUrlValue) {
this.showError('Publish URL is not configured');
return;
}
if (!this.csrfTokenValue) {
this.showError('Missing CSRF token');
return;
}
if (!window.nostr) {
this.showError('Nostr extension not found');
return;
}
this.publishButtonTarget.disabled = true;
this.showStatus('Preparing comment for signing...');
try {
// Collect form data and context
const formData = this.collectFormData();
// Validate required fields
if (!formData.content) {
throw new Error('Comment content is required');
}
if (!formData.root || !formData.parent) {
throw new Error('Missing root or parent context');
}
if (!this.isPlaintext(formData.content)) {
throw new Error('Comment must be plaintext (no formatting)');
}
// Create NIP-22 event
const nostrEvent = await this.createNip22Event(formData);
this.showStatus('Requesting signature from Nostr extension...');
const signedEvent = await window.nostr.signEvent(nostrEvent);
this.showStatus('Publishing comment...');
await this.sendToBackend(signedEvent, formData);
this.showSuccess('Comment published successfully!');
// Optionally reload or clear form
setTimeout(() => {
window.location.reload();
}, 1500);
} catch (error) {
console.error('Publishing error:', error);
this.showError(`Publishing failed: ${error.message}`);
} finally {
this.publishButtonTarget.disabled = false;
}
}
collectFormData() {
// Use the form element directly (this.element is the <form>)
const form = this.element;
if (!form) {
throw new Error('Form element not found');
}
const formData = new FormData(form);
// Comment content (plaintext only)
const content = (formData.get('comment[content]') || '').trim();
// Root and parent context (JSON in hidden fields or data attributes)
let root, parent;
try {
root = JSON.parse(formData.get('comment[root]') || this.element.dataset.root || '{}');
parent = JSON.parse(formData.get('comment[parent]') || this.element.dataset.parent || '{}');
} catch (_) {
throw new Error('Invalid root/parent context');
}
return { content, root, parent };
}
isPlaintext(text) {
// No HTML tags, no Markdown formatting
return !(/[<>\*\_\`\[\]#]/.test(text));
}
async createNip22Event({ content, root, parent }) {
// Get user's public key
const pubkey = await window.nostr.getPublicKey();
const created_at = Math.floor(Date.now() / 1000);
// Build tags according to NIP-22
const tags = [];
// Root tags (uppercase)
if (root.tag && root.value) {
const rootTag = [root.tag.toUpperCase(), root.value];
if (root.relay) rootTag.push(root.relay);
if (root.pubkey) rootTag.push(root.pubkey);
tags.push(rootTag);
}
if (root.kind) tags.push(['K', String(root.kind)]);
if (root.pubkey) tags.push(['P', root.pubkey, root.relay || '']);
// Parent tags (lowercase)
if (parent.tag && parent.value) {
const parentTag = [parent.tag.toLowerCase(), parent.value];
if (parent.relay) parentTag.push(parent.relay);
if (parent.pubkey) parentTag.push(parent.pubkey);
tags.push(parentTag);
}
if (parent.kind) tags.push(['k', String(parent.kind)]);
if (parent.pubkey) tags.push(['p', parent.pubkey, parent.relay || '']);
// NIP-22 event
return {
kind: 1111,
created_at,
tags,
content,
pubkey
};
}
async sendToBackend(signedEvent, formData) {
const response = await fetch(this.publishUrlValue, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-TOKEN': this.csrfTokenValue
},
body: JSON.stringify({
event: signedEvent,
formData: formData
})
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || `HTTP ${response.status}: ${response.statusText}`);
}
return await response.json();
}
showStatus(message) {
if (this.hasStatusTarget) {
this.statusTarget.innerHTML = `<div class="alert alert-info">${message}</div>`;
}
}
showSuccess(message) {
if (this.hasStatusTarget) {
this.statusTarget.innerHTML = `<div class="alert alert-success">${message}</div>`;
}
}
showError(message) {
if (this.hasStatusTarget) {
this.statusTarget.innerHTML = `<div class="alert alert-danger">${message}</div>`;
}
}
}