Browse Source

Editor: revive publishing

imwald
Nuša Pukšič 1 week ago
parent
commit
ab4b265dd0
  1. 1
      assets/controllers/editor/header_controller.js
  2. 29
      assets/controllers/editor/layout_controller.js
  3. 86
      assets/controllers/nostr/nostr_publish_controller.js
  4. 65
      assets/controllers/nostr/nostr_single_sign_controller.js
  5. 15
      src/Form/EditorType.php
  6. 23
      templates/editor/layout.html.twig

1
assets/controllers/editor/header_controller.js

@ -35,6 +35,7 @@ export default class extends Controller {
// Trigger click on the hidden Nostr publish button // Trigger click on the hidden Nostr publish button
const publishButton = document.querySelector('[data-nostr--nostr-publish-target="publishButton"]'); const publishButton = document.querySelector('[data-nostr--nostr-publish-target="publishButton"]');
if (publishButton) { if (publishButton) {
console.log('[Header] Triggering publish button click');
publishButton.click(); publishButton.click();
} else { } else {
console.error('[Header] Hidden publish button not found'); console.error('[Header] Hidden publish button not found');

29
assets/controllers/editor/layout_controller.js

@ -42,6 +42,34 @@ export default class extends Controller {
this.element.addEventListener('content:changed', () => { this.element.addEventListener('content:changed', () => {
this.updatePreview().then(r => console.log('Preview updated after content change', r)); this.updatePreview().then(r => console.log('Preview updated after content change', r));
}); });
this.setupAuthModal();
}
setupAuthModal() {
this.authModal = document.getElementById('auth-choice-modal');
this.signerBtn = document.getElementById('proceed-with-signer');
this.extensionBtn = document.getElementById('proceed-with-extension');
this.isAnon = !window.appUser || !window.appUser.isAuthenticated; // You may need to set window.appUser in base.html.twig
if (this.signerBtn) {
this.signerBtn.addEventListener('click', () => {
this.hideAuthModal();
this.startPublishWith('signer');
});
}
if (this.extensionBtn) {
this.extensionBtn.addEventListener('click', () => {
this.hideAuthModal();
this.startPublishWith('extension');
});
}
}
showAuthModal() {
if (this.authModal) this.authModal.style.display = 'block';
}
hideAuthModal() {
if (this.authModal) this.authModal.style.display = 'none';
} }
hydrateState() { hydrateState() {
@ -330,3 +358,4 @@ export default class extends Controller {
})); }));
} }
} }

86
assets/controllers/nostr/nostr_publish_controller.js

@ -208,44 +208,43 @@ export default class extends Controller {
event.preventDefault(); event.preventDefault();
} }
console.log('[nostr-publish] Publish triggered');
if (!this.publishUrlValue) { if (!this.publishUrlValue) {
this.showError('Publish URL is not configured'); this.showError('Publish URL is not configured');
return; return;
} }
if (!window.nostr) {
this.showError('Nostr extension not found');
return;
}
this.publishButtonTarget.disabled = true; this.publishButtonTarget.disabled = true;
this.showStatus('Preparing article for signing...'); this.showStatus('Preparing article for signing...');
try { try {
// Use canonical CodeMirror JSON for publishing const formData = this.collectFormData();
let nostrEvent; let nostrEvent = await this.createNostrEvent(formData);
const jsonString = this.getCurrentJson();
if (jsonString.trim()) { // Choose signing flow based on loginMethod
try { let signedEvent;
nostrEvent = JSON.parse(jsonString); console.log('[nostr-publish] loginMethod:', formData.loginMethod);
} catch (e) { if (formData.loginMethod === 'bunker') {
throw new Error('Invalid JSON in raw event area: ' + (e?.message || e)); // Hand off to signer_manager via custom event
} const handoffEvent = new CustomEvent('nostr:sign', {
detail: { nostrEvent, formData: formData },
bubbles: true,
cancelable: true
});
// Dispatch on the editor layout container or document
(this.element.closest('.editor-layout') || document).dispatchEvent(handoffEvent);
this.showStatus('Handed off to signer manager for signing.');
this.publishButtonTarget.disabled = false;
return;
} else { } else {
// Fallback: regenerate from form data // Get pubkey from extension and then signature
nostrEvent = await this.createNostrEvent(this.collectFormData()); this.showStatus('Requesting pubkey from Nostr extension...');
nostrEvent.pubkey = await window.nostr.getPublicKey();
this.showStatus('Requesting signature from Nostr extension...');
signedEvent = await window.nostr.signEvent(nostrEvent);
} }
// Ensure pubkey present before signing
if (!nostrEvent.pubkey) {
try { nostrEvent.pubkey = await window.nostr.getPublicKey(); } catch (_) {}
}
this.showStatus('Requesting signature from Nostr extension...');
// Sign the event with Nostr extension
const signedEvent = await window.nostr.signEvent(nostrEvent);
this.showStatus('Publishing article...'); this.showStatus('Publishing article...');
// Send to backend // Send to backend
@ -266,6 +265,12 @@ export default class extends Controller {
} }
} }
// Placeholder for the actual signer logic
async signWithSigner(event) {
// TODO: Implement the actual signer logic here
throw new Error('Signer signing flow is not yet implemented.');
}
// If a user provided a partial or custom event, make sure required keys exist and supplement from form // If a user provided a partial or custom event, make sure required keys exist and supplement from form
applyEventDefaults(event, formData, options = {}) { applyEventDefaults(event, formData, options = {}) {
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
@ -335,13 +340,14 @@ export default class extends Controller {
// Only use the Markdown field // Only use the Markdown field
const content = fd.get('editor[content]') || ''; const content = fd.get('editor[content]') || '';
const title = fd.get('editor[title]') || ''; const title = fd.get('editor[title]') || '';
const summary = fd.get('editor[summary]') || ''; const summary = fd.get('editor[summary]') || '';
const image = fd.get('editor[image]') || ''; const image = fd.get('editor[image]') || '';
const topicsString = fd.get('editor[topics]') || ''; const topicsString = fd.get('editor[topics]') || '';
const isDraft = fd.get('editor[isDraft]') === '1'; const isDraft = fd.get('editor[isDraft]') === '1';
const addClientTag = fd.get('editor[clientTag]') === '1'; const addClientTag = fd.get('editor[clientTag]') === '1';
const pubkey = fd.get('editor[pubkey]') || '';
const loginMethod = fd.get('editor[loginMethod]') || '';
// Collect advanced metadata // Collect advanced metadata
const advancedMetadata = this.collectAdvancedMetadata(fd); const advancedMetadata = this.collectAdvancedMetadata(fd);
@ -366,6 +372,8 @@ export default class extends Controller {
isDraft, isDraft,
addClientTag, addClientTag,
advancedMetadata, advancedMetadata,
pubkey,
loginMethod
}; };
} }
@ -412,18 +420,13 @@ export default class extends Controller {
} }
async createNostrEvent(formData) { async createNostrEvent(formData) {
// TODO This logic needs to be updated to take care of three distinct cases: // Use pubkey and loginMethod from formData only
// 1. user not logged in: generate event from form data with placeholder pubkey let pubkey = formData.pubkey;
// 2. user logged in with extension: get pubkey from extension and generate event
// 3. user logged in with signer: get pubkey from signer and generate event if (!pubkey) {
// ----------------------------------------------------------------------------- // Use placeholder
// Get user's public key if available (preview can work without it) pubkey = '<pubkey>';
let pubkey = ''; }
try {
if (window.nostr && typeof window.nostr.getPublicKey === 'function') {
pubkey = await window.nostr.getPublicKey();
}
} catch (_) {}
// Validate advanced metadata if present // Validate advanced metadata if present
if (formData.advancedMetadata && formData.advancedMetadata.zapSplits.length > 0) { if (formData.advancedMetadata && formData.advancedMetadata.zapSplits.length > 0) {
@ -467,14 +470,13 @@ export default class extends Controller {
const advancedTags = buildAdvancedTags(formData.advancedMetadata); const advancedTags = buildAdvancedTags(formData.advancedMetadata);
tags.push(...advancedTags); tags.push(...advancedTags);
} }
// Return the event object, with pubkey and loginMethod for signing logic
// Create the Nostr event (NIP-23 long-form content)
return { return {
kind: kind, kind: kind,
created_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000),
tags: tags, tags: tags,
content: formData.content, content: formData.content,
pubkey: pubkey pubkey: pubkey,
}; };
} }

65
assets/controllers/nostr/nostr_single_sign_controller.js

@ -5,14 +5,44 @@ export default class extends Controller {
static targets = ['status', 'publishButton', 'computedPreview']; static targets = ['status', 'publishButton', 'computedPreview'];
static values = { static values = {
event: String, event: String,
publishUrl: String, publishUrl: String
csrfToken: String
}; };
// Make targets optional for event-driven usage
get hasStatusTarget() {
return this.targets.has('status');
}
get hasPublishButtonTarget() {
return this.targets.has('publishButton');
}
get hasComputedPreviewTarget() {
return this.targets.has('computedPreview');
}
async connect() { async connect() {
try { try {
await this.preparePreview(); await this.preparePreview();
} catch (_) {} } catch (_) {}
// Listen for nostr:sign event for editor handoff
this.handleSignEvent = this.handleSignEvent.bind(this);
(this.element.closest('.editor-layout') || document).addEventListener('nostr:sign', this.handleSignEvent);
}
disconnect() {
(this.element.closest('.editor-layout') || document).removeEventListener('nostr:sign', this.handleSignEvent);
}
handleSignEvent(e) {
const { nostrEvent, formData } = e.detail;
// Update eventValue for signing
this.showStatus('Event received for signing. Ready to sign and publish.');
this.eventValue = JSON.stringify(nostrEvent);
// Store formData for later use in publishing
this.handoffFormData = formData;
// Trigger signing
this.signAndPublish(new Event('submit')).then(r => {
this.showStatus('Signing process completed.');
});
} }
async preparePreview() { async preparePreview() {
@ -78,13 +108,15 @@ export default class extends Controller {
return; return;
} }
if (!this.publishUrlValue || !this.csrfTokenValue) { if (!this.publishUrlValue) {
console.error('[nostr_single_sign] Missing config', { publishUrl: this.publishUrlValue, csrf: !!this.csrfTokenValue }); console.error('[nostr_single_sign] Missing config', { publishUrl: this.publishUrlValue});
this.showError('Missing config'); this.showError('Missing config');
return; return;
} }
this.publishButtonTarget.disabled = true; if (this.hasPublishButtonTarget) {
this.publishButtonTarget.disabled = true;
}
try { try {
this.showStatus('Preparing event...'); this.showStatus('Preparing event...');
const pubkey = await signer.getPublicKey(); const pubkey = await signer.getPublicKey();
@ -111,27 +143,36 @@ export default class extends Controller {
this.showSuccess('Published successfully! Redirecting...'); this.showSuccess('Published successfully! Redirecting...');
// Redirect to reading list index after successful publish // Redirect to reading list index after successful publish (only for form-based usage)
setTimeout(() => { if (this.hasPublishButtonTarget) {
window.location.href = '/reading-list'; setTimeout(() => {
}, 1500); window.location.href = '/reading-list';
}, 1500);
}
} catch (e) { } catch (e) {
console.error('[nostr_single_sign] Error during sign/publish:', e); console.error('[nostr_single_sign] Error during sign/publish:', e);
this.showError(e.message || 'Publish failed'); this.showError(e.message || 'Publish failed');
} finally { } finally {
this.publishButtonTarget.disabled = false; if (this.hasPublishButtonTarget) {
this.publishButtonTarget.disabled = false;
}
} }
} }
async publishSigned(signedEvent) { async publishSigned(signedEvent) {
// Build request body - include formData if available (from editor handoff)
const body = { event: signedEvent };
if (this.handoffFormData) {
body.formData = this.handoffFormData;
}
const res = await fetch(this.publishUrlValue, { const res = await fetch(this.publishUrlValue, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-CSRF-TOKEN': this.csrfTokenValue,
'X-Requested-With': 'XMLHttpRequest' 'X-Requested-With': 'XMLHttpRequest'
}, },
body: JSON.stringify({ event: signedEvent }) body: JSON.stringify(body)
}); });
if (!res.ok) { if (!res.ok) {
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));

15
src/Form/EditorType.php

@ -79,7 +79,20 @@ class EditorType extends AbstractType
'required' => false, 'required' => false,
'mapped' => false, 'mapped' => false,
'attr' => ['type' => 'hidden'], 'attr' => ['type' => 'hidden'],
]); ])
// user's pubkey
->add('pubkey', HiddenType::class, [
'required' => false,
'mapped' => false,
'attr' => ['type' => 'hidden'],
])
// log in method, can be extension or bunker
->add('loginMethod', HiddenType::class, [
'required' => false,
'mapped' => false,
'attr' => ['type' => 'hidden'],
])
;
// Apply the custom transformer // Apply the custom transformer
$builder->get('topics') $builder->get('topics')

23
templates/editor/layout.html.twig

@ -42,8 +42,11 @@
{% endblock %} {% endblock %}
{% block layout %} {% block layout %}
<main data-controller="editor--layout" data-article-id="{{ article.id|default('') }}"> <main class="editor-layout" data-controller="editor--layout nostr--nostr-single-sign" data-article-id="{{ article.id|default('') }}" data-nostr--nostr-single-sign-publish-url-value="{{ path('api-article-publish') }}">
<div class="editor-main"> <div class="editor-main">
<div data-nostr--nostr-single-sign-target="status"
style="position: fixed;top: 65px;right: 10px;"
></div>
{# Insert the article list sidebar as the first grid column #} {# Insert the article list sidebar as the first grid column #}
{% include 'editor/panels/_articlelist.html.twig' with { {% include 'editor/panels/_articlelist.html.twig' with {
readingLists: readingLists is defined ? readingLists : [], readingLists: readingLists is defined ? readingLists : [],
@ -269,6 +272,8 @@
</section> </section>
</aside> </aside>
{{ form_row(form.pubkey, {'value': app.user ? app.user.userIdentifier|toHex : ''}) }}
{{ form_row(form.loginMethod, {'value': app.session.get('nostr_sign_method', '')}) }}
{{ form_end(form) }} {{ form_end(form) }}
</div> </div>
@ -285,6 +290,22 @@
data-action="nostr--nostr-publish#publish" data-action="nostr--nostr-publish#publish"
></button> ></button>
</div> </div>
{# Modal for anon user authentication choice #}
<div id="auth-choice-modal" class="modal" tabindex="-1" style="display:none;">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Choose Authentication Method</h5>
</div>
<div class="modal-body">
<p>You need to sign in to publish. Please choose a method:</p>
<button type="button" class="btn btn-accent" id="proceed-with-signer">Proceed with Signer</button>
<button type="button" class="btn btn-accent" id="proceed-with-extension">Proceed with Extension</button>
</div>
</div>
</div>
</div>
</main> </main>
{% endblock %} {% endblock %}

Loading…
Cancel
Save