22 changed files with 2162 additions and 243 deletions
@ -0,0 +1,345 @@ |
|||||||
|
import { Controller } from '@hotwired/stimulus'; |
||||||
|
|
||||||
|
// Inline utility functions
|
||||||
|
function calculateShares(splits) { |
||||||
|
if (splits.length === 0) { |
||||||
|
return []; |
||||||
|
} |
||||||
|
|
||||||
|
// Check if any weights are specified
|
||||||
|
const hasWeights = splits.some(s => s.weight !== undefined && s.weight !== null && s.weight > 0); |
||||||
|
|
||||||
|
if (!hasWeights) { |
||||||
|
// Equal distribution
|
||||||
|
const equalShare = 100 / splits.length; |
||||||
|
return splits.map(() => equalShare); |
||||||
|
} |
||||||
|
|
||||||
|
// Calculate total weight
|
||||||
|
const totalWeight = splits.reduce((sum, s) => sum + (s.weight || 0), 0); |
||||||
|
|
||||||
|
if (totalWeight === 0) { |
||||||
|
return splits.map(() => 0); |
||||||
|
} |
||||||
|
|
||||||
|
// Calculate weighted shares
|
||||||
|
return splits.map(s => { |
||||||
|
const weight = s.weight || 0; |
||||||
|
return (weight / totalWeight) * 100; |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
function isValidPubkey(pubkey) { |
||||||
|
if (!pubkey) return false; |
||||||
|
|
||||||
|
// Check if hex (64 chars)
|
||||||
|
if (/^[0-9a-f]{64}$/i.test(pubkey)) { |
||||||
|
return true; |
||||||
|
} |
||||||
|
|
||||||
|
// Check if npub (basic check)
|
||||||
|
if (pubkey.startsWith('npub1') && pubkey.length > 60) { |
||||||
|
return true; |
||||||
|
} |
||||||
|
|
||||||
|
return false; |
||||||
|
} |
||||||
|
|
||||||
|
function isValidRelay(relay) { |
||||||
|
if (!relay) return true; // Empty is valid (optional)
|
||||||
|
|
||||||
|
try { |
||||||
|
const url = new URL(relay); |
||||||
|
return url.protocol === 'wss:'; |
||||||
|
} catch { |
||||||
|
return false; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export default class extends Controller { |
||||||
|
static targets = [ |
||||||
|
'zapSplitsContainer', |
||||||
|
'addZapButton', |
||||||
|
'distributeEquallyButton', |
||||||
|
'licenseSelect', |
||||||
|
'customLicenseInput', |
||||||
|
'protectedCheckbox', |
||||||
|
'protectedWarning', |
||||||
|
'expirationInput' |
||||||
|
]; |
||||||
|
|
||||||
|
connect() { |
||||||
|
console.log('Advanced metadata controller connected'); |
||||||
|
this.updateLicenseVisibility(); |
||||||
|
this.updateProtectedWarning(); |
||||||
|
this.updateZapShares(); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Add a new zap split row |
||||||
|
*/ |
||||||
|
addZapSplit(event) { |
||||||
|
event.preventDefault(); |
||||||
|
|
||||||
|
const container = this.zapSplitsContainerTarget; |
||||||
|
const prototype = container.dataset.prototype; |
||||||
|
|
||||||
|
if (!prototype) { |
||||||
|
console.error('No prototype found for zap splits'); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
// Get the current index
|
||||||
|
const index = parseInt(container.dataset.index || '0', 10); |
||||||
|
|
||||||
|
// Replace __name__ with the index
|
||||||
|
const newForm = prototype.replace(/__name__/g, index.toString()); |
||||||
|
|
||||||
|
// Create wrapper div
|
||||||
|
const wrapper = document.createElement('div'); |
||||||
|
wrapper.classList.add('zap-split-item', 'mb-3', 'p-3', 'border', 'rounded'); |
||||||
|
wrapper.dataset.index = index.toString(); |
||||||
|
wrapper.innerHTML = newForm; |
||||||
|
|
||||||
|
// Add delete button
|
||||||
|
const deleteBtn = document.createElement('button'); |
||||||
|
deleteBtn.type = 'button'; |
||||||
|
deleteBtn.classList.add('btn', 'btn-sm', 'btn-danger', 'mt-2'); |
||||||
|
deleteBtn.textContent = 'Remove'; |
||||||
|
deleteBtn.addEventListener('click', () => this.removeZapSplit(wrapper)); |
||||||
|
wrapper.appendChild(deleteBtn); |
||||||
|
|
||||||
|
// Add share percentage display
|
||||||
|
const shareDisplay = document.createElement('div'); |
||||||
|
shareDisplay.classList.add('zap-share-display', 'mt-2', 'text-muted'); |
||||||
|
shareDisplay.innerHTML = '<small>Share: <span class="share-percent">0</span>%</small>'; |
||||||
|
wrapper.appendChild(shareDisplay); |
||||||
|
|
||||||
|
container.appendChild(wrapper); |
||||||
|
|
||||||
|
// Update index
|
||||||
|
container.dataset.index = (index + 1).toString(); |
||||||
|
|
||||||
|
// Add event listeners for live validation and share calculation
|
||||||
|
this.attachZapSplitListeners(wrapper); |
||||||
|
|
||||||
|
this.updateZapShares(); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Remove a zap split row |
||||||
|
*/ |
||||||
|
removeZapSplit(wrapper) { |
||||||
|
wrapper.remove(); |
||||||
|
this.updateZapShares(); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Distribute weights equally among all splits |
||||||
|
*/ |
||||||
|
distributeEqually(event) { |
||||||
|
event.preventDefault(); |
||||||
|
|
||||||
|
const splits = this.zapSplitsContainerTarget.querySelectorAll('.zap-split-item'); |
||||||
|
|
||||||
|
splits.forEach((split) => { |
||||||
|
const weightInput = split.querySelector('.zap-weight'); |
||||||
|
if (weightInput) { |
||||||
|
weightInput.value = '1'; |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
this.updateZapShares(); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Update share percentages for all zap splits |
||||||
|
*/ |
||||||
|
updateZapShares() { |
||||||
|
const splits = this.zapSplitsContainerTarget.querySelectorAll('.zap-split-item'); |
||||||
|
const zapSplits = []; |
||||||
|
|
||||||
|
splits.forEach((split) => { |
||||||
|
const weightInput = split.querySelector('.zap-weight'); |
||||||
|
const weight = weightInput?.value ? parseInt(weightInput.value, 10) : undefined; |
||||||
|
|
||||||
|
zapSplits.push({ |
||||||
|
recipient: '', |
||||||
|
weight: weight |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
const shares = calculateShares(zapSplits); |
||||||
|
|
||||||
|
splits.forEach((split, index) => { |
||||||
|
const shareDisplay = split.querySelector('.share-percent'); |
||||||
|
if (shareDisplay) { |
||||||
|
shareDisplay.textContent = shares[index].toFixed(1); |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Attach event listeners to a zap split row |
||||||
|
*/ |
||||||
|
attachZapSplitListeners(wrapper) { |
||||||
|
const recipientInput = wrapper.querySelector('.zap-recipient'); |
||||||
|
const relayInput = wrapper.querySelector('.zap-relay'); |
||||||
|
const weightInput = wrapper.querySelector('.zap-weight'); |
||||||
|
|
||||||
|
if (recipientInput) { |
||||||
|
recipientInput.addEventListener('blur', (e) => this.validateRecipient(e.target)); |
||||||
|
} |
||||||
|
|
||||||
|
if (relayInput) { |
||||||
|
relayInput.addEventListener('blur', (e) => this.validateRelay(e.target)); |
||||||
|
} |
||||||
|
|
||||||
|
if (weightInput) { |
||||||
|
weightInput.addEventListener('input', () => this.updateZapShares()); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Validate recipient pubkey (npub or hex) |
||||||
|
*/ |
||||||
|
validateRecipient(input) { |
||||||
|
const value = input.value.trim(); |
||||||
|
|
||||||
|
if (!value) { |
||||||
|
this.setInputValid(input, true); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
const isValid = isValidPubkey(value); |
||||||
|
this.setInputValid(input, isValid, isValid ? '' : 'Invalid pubkey. Must be npub or 64-character hex.'); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Validate relay URL |
||||||
|
*/ |
||||||
|
validateRelay(input) { |
||||||
|
const value = input.value.trim(); |
||||||
|
|
||||||
|
if (!value) { |
||||||
|
this.setInputValid(input, true); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
const isValid = isValidRelay(value); |
||||||
|
this.setInputValid(input, isValid, isValid ? '' : 'Invalid relay URL. Must start with wss://'); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Set input validation state |
||||||
|
*/ |
||||||
|
setInputValid(input, isValid, message = '') { |
||||||
|
if (isValid) { |
||||||
|
input.classList.remove('is-invalid'); |
||||||
|
input.classList.add('is-valid'); |
||||||
|
|
||||||
|
// Remove error message
|
||||||
|
const feedback = input.parentElement?.querySelector('.invalid-feedback'); |
||||||
|
if (feedback) { |
||||||
|
feedback.remove(); |
||||||
|
} |
||||||
|
} else { |
||||||
|
input.classList.remove('is-valid'); |
||||||
|
input.classList.add('is-invalid'); |
||||||
|
|
||||||
|
// Add/update error message
|
||||||
|
let feedback = input.parentElement?.querySelector('.invalid-feedback'); |
||||||
|
if (!feedback) { |
||||||
|
feedback = document.createElement('div'); |
||||||
|
feedback.classList.add('invalid-feedback'); |
||||||
|
input.parentElement?.appendChild(feedback); |
||||||
|
} |
||||||
|
feedback.textContent = message; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Update license field visibility based on selection |
||||||
|
*/ |
||||||
|
updateLicenseVisibility() { |
||||||
|
if (!this.hasLicenseSelectTarget || !this.hasCustomLicenseInputTarget) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
const isCustom = this.licenseSelectTarget.value === 'custom'; |
||||||
|
const customWrapper = this.customLicenseInputTarget.closest('.mb-3'); |
||||||
|
|
||||||
|
if (customWrapper) { |
||||||
|
if (isCustom) { |
||||||
|
customWrapper.style.display = 'block'; |
||||||
|
this.customLicenseInputTarget.required = true; |
||||||
|
} else { |
||||||
|
customWrapper.style.display = 'none'; |
||||||
|
this.customLicenseInputTarget.required = false; |
||||||
|
this.customLicenseInputTarget.value = ''; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Show/hide protected event warning |
||||||
|
*/ |
||||||
|
updateProtectedWarning() { |
||||||
|
if (!this.hasProtectedCheckboxTarget || !this.hasProtectedWarningTarget) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
if (this.protectedCheckboxTarget.checked) { |
||||||
|
this.protectedWarningTarget.style.display = 'block'; |
||||||
|
} else { |
||||||
|
this.protectedWarningTarget.style.display = 'none'; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Validate expiration is in the future |
||||||
|
*/ |
||||||
|
validateExpiration() { |
||||||
|
if (!this.hasExpirationInputTarget) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
const value = this.expirationInputTarget.value; |
||||||
|
if (!value) { |
||||||
|
this.setInputValid(this.expirationInputTarget, true); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
const expirationDate = new Date(value); |
||||||
|
const now = new Date(); |
||||||
|
|
||||||
|
const isValid = expirationDate > now; |
||||||
|
this.setInputValid( |
||||||
|
this.expirationInputTarget, |
||||||
|
isValid, |
||||||
|
isValid ? '' : 'Expiration date must be in the future' |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Event handler for license select change |
||||||
|
*/ |
||||||
|
licenseChanged() { |
||||||
|
this.updateLicenseVisibility(); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Event handler for protected checkbox change |
||||||
|
*/ |
||||||
|
protectedChanged() { |
||||||
|
this.updateProtectedWarning(); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Event handler for expiration input change |
||||||
|
*/ |
||||||
|
expirationChanged() { |
||||||
|
this.validateExpiration(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
@ -0,0 +1,285 @@ |
|||||||
|
/** |
||||||
|
* Nostr utilities for handling pubkeys, relays, and tag building |
||||||
|
*/ |
||||||
|
|
||||||
|
// Bech32 character set
|
||||||
|
const BECH32_CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'; |
||||||
|
|
||||||
|
/** |
||||||
|
* Decode bech32 string to hex |
||||||
|
* Simplified implementation for npub decoding |
||||||
|
*/ |
||||||
|
function bech32Decode(str: string): { prefix: string; data: Uint8Array } | null { |
||||||
|
const lowered = str.toLowerCase(); |
||||||
|
|
||||||
|
// Find the separator
|
||||||
|
let sepIndex = lowered.lastIndexOf('1'); |
||||||
|
if (sepIndex < 1) return null; |
||||||
|
|
||||||
|
const prefix = lowered.substring(0, sepIndex); |
||||||
|
const dataStr = lowered.substring(sepIndex + 1); |
||||||
|
|
||||||
|
if (dataStr.length < 6) return null; |
||||||
|
|
||||||
|
// Decode the data
|
||||||
|
const values: number[] = []; |
||||||
|
for (let i = 0; i < dataStr.length; i++) { |
||||||
|
const c = dataStr[i]; |
||||||
|
const v = BECH32_CHARSET.indexOf(c); |
||||||
|
if (v === -1) return null; |
||||||
|
values.push(v); |
||||||
|
} |
||||||
|
|
||||||
|
// Remove checksum (last 6 chars)
|
||||||
|
const data = values.slice(0, -6); |
||||||
|
|
||||||
|
// Convert from 5-bit to 8-bit
|
||||||
|
const bytes: number[] = []; |
||||||
|
let acc = 0; |
||||||
|
let bits = 0; |
||||||
|
|
||||||
|
for (const value of data) { |
||||||
|
acc = (acc << 5) | value; |
||||||
|
bits += 5; |
||||||
|
|
||||||
|
if (bits >= 8) { |
||||||
|
bits -= 8; |
||||||
|
bytes.push((acc >> bits) & 0xff); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return { |
||||||
|
prefix, |
||||||
|
data: new Uint8Array(bytes) |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Convert Uint8Array to hex string |
||||||
|
*/ |
||||||
|
function bytesToHex(bytes: Uint8Array): string { |
||||||
|
return Array.from(bytes) |
||||||
|
.map(b => b.toString(16).padStart(2, '0')) |
||||||
|
.join(''); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Convert npub to hex pubkey |
||||||
|
*/ |
||||||
|
export function npubToHex(npub: string): string | null { |
||||||
|
if (!npub.startsWith('npub1')) { |
||||||
|
// Check if it's already hex
|
||||||
|
if (/^[0-9a-f]{64}$/i.test(npub)) { |
||||||
|
return npub.toLowerCase(); |
||||||
|
} |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
const decoded = bech32Decode(npub); |
||||||
|
if (!decoded || decoded.prefix !== 'npub') { |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
return bytesToHex(decoded.data); |
||||||
|
} catch (e) { |
||||||
|
console.error('Error decoding npub:', e); |
||||||
|
return null; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Validate if a string is a valid npub or hex pubkey |
||||||
|
*/ |
||||||
|
export function isValidPubkey(pubkey: string): boolean { |
||||||
|
if (!pubkey) return false; |
||||||
|
|
||||||
|
// Check if hex (64 chars)
|
||||||
|
if (/^[0-9a-f]{64}$/i.test(pubkey)) { |
||||||
|
return true; |
||||||
|
} |
||||||
|
|
||||||
|
// Check if npub
|
||||||
|
if (pubkey.startsWith('npub1')) { |
||||||
|
return npubToHex(pubkey) !== null; |
||||||
|
} |
||||||
|
|
||||||
|
return false; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Validate relay URL |
||||||
|
*/ |
||||||
|
export function isValidRelay(relay: string): boolean { |
||||||
|
if (!relay) return true; // Empty is valid (optional)
|
||||||
|
|
||||||
|
try { |
||||||
|
const url = new URL(relay); |
||||||
|
return url.protocol === 'wss:'; |
||||||
|
} catch { |
||||||
|
return false; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* ZapSplit interface |
||||||
|
*/ |
||||||
|
export interface ZapSplit { |
||||||
|
recipient: string; |
||||||
|
relay?: string; |
||||||
|
weight?: number; |
||||||
|
sharePercent?: number; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Calculate share percentages for zap splits |
||||||
|
*/ |
||||||
|
export function calculateShares(splits: ZapSplit[]): number[] { |
||||||
|
if (splits.length === 0) { |
||||||
|
return []; |
||||||
|
} |
||||||
|
|
||||||
|
// Check if any weights are specified
|
||||||
|
const hasWeights = splits.some(s => s.weight !== undefined && s.weight !== null && s.weight > 0); |
||||||
|
|
||||||
|
if (!hasWeights) { |
||||||
|
// Equal distribution
|
||||||
|
const equalShare = 100 / splits.length; |
||||||
|
return splits.map(() => equalShare); |
||||||
|
} |
||||||
|
|
||||||
|
// Calculate total weight
|
||||||
|
const totalWeight = splits.reduce((sum, s) => sum + (s.weight || 0), 0); |
||||||
|
|
||||||
|
if (totalWeight === 0) { |
||||||
|
return splits.map(() => 0); |
||||||
|
} |
||||||
|
|
||||||
|
// Calculate weighted shares
|
||||||
|
return splits.map(s => { |
||||||
|
const weight = s.weight || 0; |
||||||
|
return (weight / totalWeight) * 100; |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Build a zap tag for Nostr event |
||||||
|
*/ |
||||||
|
export function buildZapTag(split: ZapSplit): (string | number)[] { |
||||||
|
const hexPubkey = npubToHex(split.recipient); |
||||||
|
if (!hexPubkey) { |
||||||
|
throw new Error(`Invalid recipient pubkey: ${split.recipient}`); |
||||||
|
} |
||||||
|
|
||||||
|
const tag: (string | number)[] = ['zap', hexPubkey]; |
||||||
|
|
||||||
|
// Add relay (even if empty, to maintain position)
|
||||||
|
tag.push(split.relay || ''); |
||||||
|
|
||||||
|
// Add weight if specified
|
||||||
|
if (split.weight !== undefined && split.weight !== null) { |
||||||
|
tag.push(split.weight); |
||||||
|
} |
||||||
|
|
||||||
|
return tag; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Advanced metadata interface |
||||||
|
*/ |
||||||
|
export interface AdvancedMetadata { |
||||||
|
doNotRepublish: boolean; |
||||||
|
license: string; |
||||||
|
customLicense?: string; |
||||||
|
zapSplits: ZapSplit[]; |
||||||
|
contentWarning?: string; |
||||||
|
expirationTimestamp?: number; |
||||||
|
isProtected: boolean; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Build advanced metadata tags for Nostr event |
||||||
|
*/ |
||||||
|
export function buildAdvancedTags(metadata: AdvancedMetadata): any[][] { |
||||||
|
const tags: any[][] = []; |
||||||
|
|
||||||
|
// Policy: Do not republish
|
||||||
|
if (metadata.doNotRepublish) { |
||||||
|
tags.push(['L', 'rights.decent.newsroom']); |
||||||
|
tags.push(['l', 'no-republish', 'rights.decent.newsroom']); |
||||||
|
} |
||||||
|
|
||||||
|
// License
|
||||||
|
const license = metadata.license === 'custom' ? metadata.customLicense : metadata.license; |
||||||
|
if (license && license !== 'All rights reserved') { |
||||||
|
tags.push(['L', 'spdx.org/licenses']); |
||||||
|
tags.push(['l', license, 'spdx.org/licenses']); |
||||||
|
} else if (license === 'All rights reserved') { |
||||||
|
tags.push(['L', 'rights.decent.newsroom']); |
||||||
|
tags.push(['l', 'all-rights-reserved', 'rights.decent.newsroom']); |
||||||
|
} |
||||||
|
|
||||||
|
// Zap splits
|
||||||
|
for (const split of metadata.zapSplits) { |
||||||
|
try { |
||||||
|
tags.push(buildZapTag(split)); |
||||||
|
} catch (e) { |
||||||
|
console.error('Error building zap tag:', e); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Content warning
|
||||||
|
if (metadata.contentWarning) { |
||||||
|
tags.push(['content-warning', metadata.contentWarning]); |
||||||
|
} |
||||||
|
|
||||||
|
// Expiration
|
||||||
|
if (metadata.expirationTimestamp) { |
||||||
|
tags.push(['expiration', metadata.expirationTimestamp.toString()]); |
||||||
|
} |
||||||
|
|
||||||
|
// Protected event
|
||||||
|
if (metadata.isProtected) { |
||||||
|
tags.push(['-']); |
||||||
|
} |
||||||
|
|
||||||
|
return tags; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Validate advanced metadata before publishing |
||||||
|
*/ |
||||||
|
export function validateAdvancedMetadata(metadata: AdvancedMetadata): { valid: boolean; errors: string[] } { |
||||||
|
const errors: string[] = []; |
||||||
|
|
||||||
|
// Validate zap splits
|
||||||
|
for (let i = 0; i < metadata.zapSplits.length; i++) { |
||||||
|
const split = metadata.zapSplits[i]; |
||||||
|
|
||||||
|
if (!isValidPubkey(split.recipient)) { |
||||||
|
errors.push(`Zap split ${i + 1}: Invalid recipient pubkey`); |
||||||
|
} |
||||||
|
|
||||||
|
if (split.relay && !isValidRelay(split.relay)) { |
||||||
|
errors.push(`Zap split ${i + 1}: Invalid relay URL (must start with wss://)`); |
||||||
|
} |
||||||
|
|
||||||
|
if (split.weight !== undefined && split.weight !== null && split.weight < 0) { |
||||||
|
errors.push(`Zap split ${i + 1}: Weight must be 0 or greater`); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Validate expiration is in the future
|
||||||
|
if (metadata.expirationTimestamp) { |
||||||
|
const now = Math.floor(Date.now() / 1000); |
||||||
|
if (metadata.expirationTimestamp <= now) { |
||||||
|
errors.push('Expiration date must be in the future'); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return { |
||||||
|
valid: errors.length === 0, |
||||||
|
errors |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
@ -1,273 +1,418 @@ |
|||||||
import { Controller } from '@hotwired/stimulus'; |
import { Controller } from '@hotwired/stimulus'; |
||||||
|
|
||||||
|
// Inline utility functions (simplified versions)
|
||||||
|
function buildAdvancedTags(metadata) { |
||||||
|
const tags = []; |
||||||
|
|
||||||
|
// Policy: Do not republish
|
||||||
|
if (metadata.doNotRepublish) { |
||||||
|
tags.push(['L', 'rights.decent.newsroom']); |
||||||
|
tags.push(['l', 'no-republish', 'rights.decent.newsroom']); |
||||||
|
} |
||||||
|
|
||||||
|
// License
|
||||||
|
const license = metadata.license === 'custom' ? metadata.customLicense : metadata.license; |
||||||
|
if (license && license !== 'All rights reserved') { |
||||||
|
tags.push(['L', 'spdx.org/licenses']); |
||||||
|
tags.push(['l', license, 'spdx.org/licenses']); |
||||||
|
} else if (license === 'All rights reserved') { |
||||||
|
tags.push(['L', 'rights.decent.newsroom']); |
||||||
|
tags.push(['l', 'all-rights-reserved', 'rights.decent.newsroom']); |
||||||
|
} |
||||||
|
|
||||||
|
// Zap splits
|
||||||
|
for (const split of metadata.zapSplits) { |
||||||
|
const zapTag = ['zap', split.recipient, split.relay || '']; |
||||||
|
if (split.weight !== undefined && split.weight !== null) { |
||||||
|
zapTag.push(split.weight.toString()); |
||||||
|
} |
||||||
|
tags.push(zapTag); |
||||||
|
} |
||||||
|
|
||||||
|
// Content warning
|
||||||
|
if (metadata.contentWarning) { |
||||||
|
tags.push(['content-warning', metadata.contentWarning]); |
||||||
|
} |
||||||
|
|
||||||
|
// Expiration
|
||||||
|
if (metadata.expirationTimestamp) { |
||||||
|
tags.push(['expiration', metadata.expirationTimestamp.toString()]); |
||||||
|
} |
||||||
|
|
||||||
|
// Protected event
|
||||||
|
if (metadata.isProtected) { |
||||||
|
tags.push(['-']); |
||||||
|
} |
||||||
|
|
||||||
|
return tags; |
||||||
|
} |
||||||
|
|
||||||
|
function validateAdvancedMetadata(metadata) { |
||||||
|
const errors = []; |
||||||
|
|
||||||
|
// Basic validation
|
||||||
|
for (let i = 0; i < metadata.zapSplits.length; i++) { |
||||||
|
const split = metadata.zapSplits[i]; |
||||||
|
|
||||||
|
if (!split.recipient) { |
||||||
|
errors.push(`Zap split ${i + 1}: Recipient is required`); |
||||||
|
} |
||||||
|
|
||||||
|
if (split.relay && !split.relay.startsWith('wss://')) { |
||||||
|
errors.push(`Zap split ${i + 1}: Invalid relay URL (must start with wss://)`); |
||||||
|
} |
||||||
|
|
||||||
|
if (split.weight !== undefined && split.weight !== null && split.weight < 0) { |
||||||
|
errors.push(`Zap split ${i + 1}: Weight must be 0 or greater`); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Validate expiration is in the future
|
||||||
|
if (metadata.expirationTimestamp) { |
||||||
|
const now = Math.floor(Date.now() / 1000); |
||||||
|
if (metadata.expirationTimestamp <= now) { |
||||||
|
errors.push('Expiration date must be in the future'); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return { |
||||||
|
valid: errors.length === 0, |
||||||
|
errors |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
export default class extends Controller { |
export default class extends Controller { |
||||||
static targets = ['form', 'publishButton', 'status']; |
static targets = ['form', 'publishButton', 'status']; |
||||||
static values = { |
static values = { |
||||||
publishUrl: String, |
publishUrl: String, |
||||||
csrfToken: String |
csrfToken: String |
||||||
}; |
}; |
||||||
|
|
||||||
|
connect() { |
||||||
|
console.log('Nostr publish controller connected'); |
||||||
|
try { |
||||||
|
console.debug('[nostr-publish] publishUrl:', this.publishUrlValue || '(none)'); |
||||||
|
console.debug('[nostr-publish] has csrfToken:', Boolean(this.csrfTokenValue)); |
||||||
|
console.debug('[nostr-publish] existing slug:', (this.element.dataset.slug || '(none)')); |
||||||
|
} 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; |
||||||
|
} |
||||||
|
|
||||||
connect() { |
if (!window.nostr) { |
||||||
console.log('Nostr publish controller connected'); |
this.showError('Nostr extension not found'); |
||||||
try { |
return; |
||||||
console.debug('[nostr-publish] publishUrl:', this.publishUrlValue || '(none)'); |
|
||||||
console.debug('[nostr-publish] has csrfToken:', Boolean(this.csrfTokenValue)); |
|
||||||
console.debug('[nostr-publish] existing slug:', (this.element.dataset.slug || '(none)')); |
|
||||||
} catch (_) {} |
|
||||||
} |
} |
||||||
|
|
||||||
async publish(event) { |
this.publishButtonTarget.disabled = true; |
||||||
event.preventDefault(); |
this.showStatus('Preparing article for signing...'); |
||||||
|
|
||||||
|
try { |
||||||
|
// Collect form data
|
||||||
|
const formData = this.collectFormData(); |
||||||
|
|
||||||
if (!this.publishUrlValue) { |
// Validate required fields
|
||||||
this.showError('Publish URL is not configured'); |
if (!formData.title || !formData.content) { |
||||||
return; |
throw new Error('Title and content are required'); |
||||||
} |
} |
||||||
if (!this.csrfTokenValue) { |
|
||||||
this.showError('Missing CSRF token'); |
|
||||||
return; |
|
||||||
} |
|
||||||
|
|
||||||
if (!window.nostr) { |
// Create Nostr event
|
||||||
this.showError('Nostr extension not found'); |
const nostrEvent = await this.createNostrEvent(formData); |
||||||
return; |
|
||||||
} |
|
||||||
|
|
||||||
this.publishButtonTarget.disabled = true; |
this.showStatus('Requesting signature from Nostr extension...'); |
||||||
this.showStatus('Preparing article for signing...'); |
|
||||||
|
|
||||||
try { |
// Sign the event with Nostr extension
|
||||||
// Collect form data
|
const signedEvent = await window.nostr.signEvent(nostrEvent); |
||||||
const formData = this.collectFormData(); |
|
||||||
|
|
||||||
// Validate required fields
|
this.showStatus('Publishing article...'); |
||||||
if (!formData.title || !formData.content) { |
|
||||||
throw new Error('Title and content are required'); |
|
||||||
} |
|
||||||
|
|
||||||
// Create Nostr event
|
// Send to backend
|
||||||
const nostrEvent = await this.createNostrEvent(formData); |
await this.sendToBackend(signedEvent, formData); |
||||||
|
|
||||||
this.showStatus('Requesting signature from Nostr extension...'); |
this.showSuccess('Article published successfully!'); |
||||||
|
|
||||||
// Sign the event with Nostr extension
|
// Optionally redirect after successful publish
|
||||||
const signedEvent = await window.nostr.signEvent(nostrEvent); |
setTimeout(() => { |
||||||
|
window.location.href = `/article/d/${encodeURIComponent(formData.slug)}`; |
||||||
|
}, 2000); |
||||||
|
|
||||||
|
} catch (error) { |
||||||
|
console.error('Publishing error:', error); |
||||||
|
this.showError(`Publishing failed: ${error.message}`); |
||||||
|
} finally { |
||||||
|
this.publishButtonTarget.disabled = false; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
collectFormData() { |
||||||
|
// Find the actual form element within our target
|
||||||
|
const form = this.formTarget.querySelector('form'); |
||||||
|
if (!form) throw new Error('Form element not found'); |
||||||
|
|
||||||
|
const fd = new FormData(form); |
||||||
|
|
||||||
|
// Prefer the Markdown field populated by the Quill controller
|
||||||
|
const md = fd.get('editor[content_md]'); |
||||||
|
let html = fd.get('editor[content]') || fd.get('content') || ''; |
||||||
|
|
||||||
|
// Final content: use MD if present, otherwise convert HTML -> MD
|
||||||
|
const content = (typeof md === 'string' && md.length > 0) |
||||||
|
? md |
||||||
|
: this.htmlToMarkdown(String(html)); |
||||||
|
|
||||||
|
const title = fd.get('editor[title]') || ''; |
||||||
|
const summary = fd.get('editor[summary]') || ''; |
||||||
|
const image = fd.get('editor[image]') || ''; |
||||||
|
const topicsString = fd.get('editor[topics]') || ''; |
||||||
|
const isDraft = fd.get('editor[isDraft]') === '1'; |
||||||
|
const addClientTag = fd.get('editor[clientTag]') === '1'; |
||||||
|
|
||||||
|
// Collect advanced metadata
|
||||||
|
const advancedMetadata = this.collectAdvancedMetadata(fd); |
||||||
|
|
||||||
|
// Parse topics
|
||||||
|
const topics = String(topicsString).split(',') |
||||||
|
.map(s => s.trim()) |
||||||
|
.filter(Boolean) |
||||||
|
.map(t => t.startsWith('#') ? t : `#${t}`); |
||||||
|
|
||||||
|
// Reuse existing slug if provided on the container (editing), else generate from title
|
||||||
|
const existingSlug = (this.element.dataset.slug || '').trim(); |
||||||
|
const slug = existingSlug || this.generateSlug(String(title)); |
||||||
|
|
||||||
|
return { |
||||||
|
title: String(title), |
||||||
|
summary: String(summary), |
||||||
|
content, |
||||||
|
image: String(image), |
||||||
|
topics, |
||||||
|
slug, |
||||||
|
isDraft, |
||||||
|
addClientTag, |
||||||
|
advancedMetadata, |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
collectAdvancedMetadata(fd) { |
||||||
|
const metadata = { |
||||||
|
doNotRepublish: fd.get('editor[advancedMetadata][doNotRepublish]') === '1', |
||||||
|
license: fd.get('editor[advancedMetadata][license]') || '', |
||||||
|
customLicense: fd.get('editor[advancedMetadata][customLicense]') || '', |
||||||
|
contentWarning: fd.get('editor[advancedMetadata][contentWarning]') || '', |
||||||
|
isProtected: fd.get('editor[advancedMetadata][isProtected]') === '1', |
||||||
|
zapSplits: [], |
||||||
|
}; |
||||||
|
|
||||||
this.showStatus('Publishing article...'); |
// Parse expiration timestamp
|
||||||
|
const expirationDate = fd.get('editor[advancedMetadata][expirationTimestamp]'); |
||||||
|
if (expirationDate) { |
||||||
|
try { |
||||||
|
metadata.expirationTimestamp = Math.floor(new Date(expirationDate).getTime() / 1000); |
||||||
|
} catch (e) { |
||||||
|
console.warn('Invalid expiration date:', e); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
// Send to backend
|
// Collect zap splits
|
||||||
await this.sendToBackend(signedEvent, formData); |
let index = 0; |
||||||
|
while (true) { |
||||||
|
const recipient = fd.get(`editor[advancedMetadata][zapSplits][${index}][recipient]`); |
||||||
|
if (!recipient) break; |
||||||
|
|
||||||
this.showSuccess('Article published successfully!'); |
const relay = fd.get(`editor[advancedMetadata][zapSplits][${index}][relay]`) || ''; |
||||||
|
const weightStr = fd.get(`editor[advancedMetadata][zapSplits][${index}][weight]`); |
||||||
|
const weight = weightStr ? parseInt(weightStr, 10) : undefined; |
||||||
|
|
||||||
// Optionally redirect after successful publish
|
metadata.zapSplits.push({ |
||||||
setTimeout(() => { |
recipient: String(recipient), |
||||||
window.location.href = `/article/d/${encodeURIComponent(formData.slug)}`; |
relay: relay ? String(relay) : undefined, |
||||||
}, 2000); |
weight: weight !== undefined && !isNaN(weight) ? weight : undefined, |
||||||
|
}); |
||||||
|
|
||||||
} catch (error) { |
index++; |
||||||
console.error('Publishing error:', error); |
|
||||||
this.showError(`Publishing failed: ${error.message}`); |
|
||||||
} finally { |
|
||||||
this.publishButtonTarget.disabled = false; |
|
||||||
} |
|
||||||
} |
} |
||||||
|
|
||||||
collectFormData() { |
return metadata; |
||||||
// Find the actual form element within our target
|
} |
||||||
const form = this.formTarget.querySelector('form'); |
|
||||||
if (!form) { |
async createNostrEvent(formData) { |
||||||
throw new Error('Form element not found'); |
// Get user's public key
|
||||||
} |
const pubkey = await window.nostr.getPublicKey(); |
||||||
|
|
||||||
const formData = new FormData(form); |
// Validate advanced metadata if present
|
||||||
|
if (formData.advancedMetadata && formData.advancedMetadata.zapSplits.length > 0) { |
||||||
let content = formData.get('editor[content]') || ''; |
const validation = validateAdvancedMetadata(formData.advancedMetadata); |
||||||
content = this.htmlToMarkdown(content); |
if (!validation.valid) { |
||||||
|
throw new Error('Invalid advanced metadata: ' + validation.errors.join(', ')); |
||||||
const title = formData.get('editor[title]') || ''; |
} |
||||||
const summary = formData.get('editor[summary]') || ''; |
|
||||||
const image = formData.get('editor[image]') || ''; |
|
||||||
const topicsString = formData.get('editor[topics]') || ''; |
|
||||||
const isDraft = formData.get('editor[isDraft]') === '1'; |
|
||||||
const addClientTag = formData.get('editor[clientTag]') === '1'; |
|
||||||
|
|
||||||
// Parse topics
|
|
||||||
const topics = topicsString.split(',') |
|
||||||
.map(topic => topic.trim()) |
|
||||||
.filter(topic => topic.length > 0) |
|
||||||
.map(topic => topic.startsWith('#') ? topic : `#${topic}`); |
|
||||||
|
|
||||||
// Reuse existing slug if provided on the container (editing), else generate from title
|
|
||||||
const existingSlug = (this.element.dataset.slug || '').trim(); |
|
||||||
const slug = existingSlug || this.generateSlug(title); |
|
||||||
|
|
||||||
return { |
|
||||||
title, |
|
||||||
summary, |
|
||||||
content, |
|
||||||
image, |
|
||||||
topics, |
|
||||||
slug, |
|
||||||
isDraft, |
|
||||||
addClientTag |
|
||||||
}; |
|
||||||
} |
} |
||||||
|
|
||||||
async createNostrEvent(formData) { |
// Create tags array
|
||||||
// Get user's public key
|
const tags = [ |
||||||
const pubkey = await window.nostr.getPublicKey(); |
['d', formData.slug], |
||||||
|
['title', formData.title], |
||||||
// Create tags array
|
['published_at', Math.floor(Date.now() / 1000).toString()], |
||||||
const tags = [ |
]; |
||||||
['d', formData.slug], |
|
||||||
['title', formData.title], |
let kind = 30023; // Default kind for long-form content
|
||||||
['published_at', Math.floor(Date.now() / 1000).toString()], |
if (formData.isDraft) { |
||||||
]; |
kind = 30024; // Draft kind
|
||||||
|
|
||||||
let kind = 30023; // Default kind for long-form content
|
|
||||||
if (formData.isDraft) { |
|
||||||
kind = 30024; // Draft kind
|
|
||||||
} |
|
||||||
|
|
||||||
if (formData.summary) { |
|
||||||
tags.push(['summary', formData.summary]); |
|
||||||
} |
|
||||||
|
|
||||||
if (formData.image) { |
|
||||||
tags.push(['image', formData.image]); |
|
||||||
} |
|
||||||
|
|
||||||
// Add topic tags
|
|
||||||
formData.topics.forEach(topic => { |
|
||||||
tags.push(['t', topic.replace('#', '')]); |
|
||||||
}); |
|
||||||
|
|
||||||
if (formData.addClientTag) { |
|
||||||
tags.push(['client', 'Decent Newsroom']); |
|
||||||
} |
|
||||||
|
|
||||||
// Create the Nostr event (NIP-23 long-form content)
|
|
||||||
const event = { |
|
||||||
kind: kind, // Long-form content kind
|
|
||||||
created_at: Math.floor(Date.now() / 1000), |
|
||||||
tags: tags, |
|
||||||
content: formData.content, |
|
||||||
pubkey: pubkey |
|
||||||
}; |
|
||||||
|
|
||||||
return event; |
|
||||||
} |
} |
||||||
|
|
||||||
async sendToBackend(signedEvent, formData) { |
if (formData.summary) { |
||||||
const response = await fetch(this.publishUrlValue, { |
tags.push(['summary', formData.summary]); |
||||||
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(); |
|
||||||
} |
} |
||||||
|
|
||||||
htmlToMarkdown(html) { |
if (formData.image) { |
||||||
// Basic HTML to Markdown conversion
|
tags.push(['image', formData.image]); |
||||||
// This is a simplified version - you might want to use a proper library
|
|
||||||
let markdown = html; |
|
||||||
|
|
||||||
// Convert headers
|
|
||||||
markdown = markdown.replace(/<h1[^>]*>(.*?)<\/h1>/gi, '# $1\n\n'); |
|
||||||
markdown = markdown.replace(/<h2[^>]*>(.*?)<\/h2>/gi, '## $1\n\n'); |
|
||||||
markdown = markdown.replace(/<h3[^>]*>(.*?)<\/h3>/gi, '### $1\n\n'); |
|
||||||
|
|
||||||
// Convert formatting
|
|
||||||
markdown = markdown.replace(/<strong[^>]*>(.*?)<\/strong>/gi, '**$1**'); |
|
||||||
markdown = markdown.replace(/<b[^>]*>(.*?)<\/b>/gi, '**$1**'); |
|
||||||
markdown = markdown.replace(/<em[^>]*>(.*?)<\/em>/gi, '*$1*'); |
|
||||||
markdown = markdown.replace(/<i[^>]*>(.*?)<\/i>/gi, '*$1*'); |
|
||||||
|
|
||||||
// Convert links
|
|
||||||
markdown = markdown.replace(/<a[^>]*href="([^"]*)"[^>]*>(.*?)<\/a>/gi, '[$2]($1)'); |
|
||||||
|
|
||||||
// Convert images (handle src/alt in any order)
|
|
||||||
markdown = markdown.replace(/<img\b[^>]*>/gi, (imgTag) => { |
|
||||||
const srcMatch = imgTag.match(/src=["']([^"']+)["']/i); |
|
||||||
const altMatch = imgTag.match(/alt=["']([^"']*)["']/i); |
|
||||||
const src = srcMatch ? srcMatch[1] : ''; |
|
||||||
const alt = altMatch ? altMatch[1] : ''; |
|
||||||
return src ? `` : ''; |
|
||||||
}); |
|
||||||
|
|
||||||
// Convert lists
|
|
||||||
markdown = markdown.replace(/<ul[^>]*>(.*?)<\/ul>/gis, '$1\n'); |
|
||||||
markdown = markdown.replace(/<ol[^>]*>(.*?)<\/ol>/gis, '$1\n'); |
|
||||||
markdown = markdown.replace(/<li[^>]*>(.*?)<\/li>/gi, '- $1\n'); |
|
||||||
|
|
||||||
// Convert paragraphs
|
|
||||||
markdown = markdown.replace(/<p[^>]*>(.*?)<\/p>/gi, '$1\n\n'); |
|
||||||
|
|
||||||
// Convert line breaks
|
|
||||||
markdown = markdown.replace(/<br[^>]*>/gi, '\n'); |
|
||||||
|
|
||||||
// Convert blockquotes
|
|
||||||
markdown = markdown.replace(/<blockquote[^>]*>(.*?)<\/blockquote>/gis, '> $1\n\n'); |
|
||||||
|
|
||||||
// Convert code blocks
|
|
||||||
markdown = markdown.replace(/<pre[^>]*><code[^>]*>(.*?)<\/code><\/pre>/gis, '```\n$1\n```\n\n'); |
|
||||||
markdown = markdown.replace(/<code[^>]*>(.*?)<\/code>/gi, '`$1`'); |
|
||||||
|
|
||||||
// Clean up HTML entities and remaining tags
|
|
||||||
markdown = markdown.replace(/ /g, ' '); |
|
||||||
markdown = markdown.replace(/&/g, '&'); |
|
||||||
markdown = markdown.replace(/</g, '<'); |
|
||||||
markdown = markdown.replace(/>/g, '>'); |
|
||||||
markdown = markdown.replace(/"/g, '"'); |
|
||||||
markdown = markdown.replace(/<[^>]*>/g, ''); // Remove any remaining HTML tags
|
|
||||||
|
|
||||||
// Clean up extra whitespace
|
|
||||||
markdown = markdown.replace(/\n{3,}/g, '\n\n').trim(); |
|
||||||
|
|
||||||
return markdown; |
|
||||||
} |
} |
||||||
|
|
||||||
generateSlug(title) { |
// Add topic tags
|
||||||
// add a random seed at the end of the title to avoid collisions
|
formData.topics.forEach(topic => { |
||||||
const randomSeed = Math.random().toString(36).substring(2, 8); |
tags.push(['t', topic.replace('#', '')]); |
||||||
title = `${title} ${randomSeed}`; |
}); |
||||||
return title |
|
||||||
.toLowerCase() |
if (formData.addClientTag) { |
||||||
.replace(/[^a-z0-9\s-]/g, '') // Remove special characters
|
tags.push(['client', 'Decent Newsroom']); |
||||||
.replace(/\s+/g, '-') // Replace spaces with hyphens
|
|
||||||
.replace(/-+/g, '-') // Replace multiple hyphens with single
|
|
||||||
.replace(/^-|-$/g, ''); // Remove leading/trailing hyphens
|
|
||||||
} |
} |
||||||
|
|
||||||
showStatus(message) { |
// Add advanced metadata tags
|
||||||
if (this.hasStatusTarget) { |
if (formData.advancedMetadata) { |
||||||
this.statusTarget.innerHTML = `<div class="alert alert-info">${message}</div>`; |
const advancedTags = buildAdvancedTags(formData.advancedMetadata); |
||||||
} |
tags.push(...advancedTags); |
||||||
} |
} |
||||||
|
|
||||||
showSuccess(message) { |
// Create the Nostr event (NIP-23 long-form content)
|
||||||
if (this.hasStatusTarget) { |
const event = { |
||||||
this.statusTarget.innerHTML = `<div class="alert alert-success">${message}</div>`; |
kind: kind, |
||||||
} |
created_at: Math.floor(Date.now() / 1000), |
||||||
|
tags: tags, |
||||||
|
content: formData.content, |
||||||
|
pubkey: pubkey |
||||||
|
}; |
||||||
|
|
||||||
|
return event; |
||||||
|
} |
||||||
|
|
||||||
|
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}`); |
||||||
} |
} |
||||||
|
|
||||||
showError(message) { |
return await response.json(); |
||||||
if (this.hasStatusTarget) { |
} |
||||||
this.statusTarget.innerHTML = `<div class="alert alert-danger">${message}</div>`; |
|
||||||
} |
htmlToMarkdown(html) { |
||||||
|
// Basic HTML to Markdown conversion
|
||||||
|
let markdown = html; |
||||||
|
|
||||||
|
// Convert headers
|
||||||
|
markdown = markdown.replace(/<h1[^>]*>(.*?)<\/h1>/gi, '# $1\n\n'); |
||||||
|
markdown = markdown.replace(/<h2[^>]*>(.*?)<\/h2>/gi, '## $1\n\n'); |
||||||
|
markdown = markdown.replace(/<h3[^>]*>(.*?)<\/h3>/gi, '### $1\n\n'); |
||||||
|
|
||||||
|
// Convert formatting
|
||||||
|
markdown = markdown.replace(/<strong[^>]*>(.*?)<\/strong>/gi, '**$1**'); |
||||||
|
markdown = markdown.replace(/<b[^>]*>(.*?)<\/b>/gi, '**$1**'); |
||||||
|
markdown = markdown.replace(/<em[^>]*>(.*?)<\/em>/gi, '*$1*'); |
||||||
|
markdown = markdown.replace(/<i[^>]*>(.*?)<\/i>/gi, '*$1*'); |
||||||
|
|
||||||
|
// Convert links
|
||||||
|
markdown = markdown.replace(/<a[^>]*href="([^"]*)"[^>]*>(.*?)<\/a>/gi, '[$2]($1)'); |
||||||
|
|
||||||
|
// Convert images
|
||||||
|
markdown = markdown.replace(/<img\b[^>]*>/gi, (imgTag) => { |
||||||
|
const srcMatch = imgTag.match(/src=["']([^"']+)["']/i); |
||||||
|
const altMatch = imgTag.match(/alt=["']([^"']*)["']/i); |
||||||
|
const src = srcMatch ? srcMatch[1] : ''; |
||||||
|
const alt = altMatch ? altMatch[1] : ''; |
||||||
|
return src ? `` : ''; |
||||||
|
}); |
||||||
|
|
||||||
|
// Convert lists
|
||||||
|
markdown = markdown.replace(/<ul[^>]*>(.*?)<\/ul>/gis, '$1\n'); |
||||||
|
markdown = markdown.replace(/<ol[^>]*>(.*?)<\/ol>/gis, '$1\n'); |
||||||
|
markdown = markdown.replace(/<li[^>]*>(.*?)<\/li>/gi, '- $1\n'); |
||||||
|
|
||||||
|
// Convert paragraphs
|
||||||
|
markdown = markdown.replace(/<p[^>]*>(.*?)<\/p>/gi, '$1\n\n'); |
||||||
|
|
||||||
|
// Convert line breaks
|
||||||
|
markdown = markdown.replace(/<br[^>]*>/gi, '\n'); |
||||||
|
|
||||||
|
// Convert blockquotes
|
||||||
|
markdown = markdown.replace(/<blockquote[^>]*>(.*?)<\/blockquote>/gis, '> $1\n\n'); |
||||||
|
|
||||||
|
// Convert code blocks
|
||||||
|
markdown = markdown.replace(/<pre[^>]*><code[^>]*>(.*?)<\/code><\/pre>/gis, '```\n$1\n```\n\n'); |
||||||
|
markdown = markdown.replace(/<code[^>]*>(.*?)<\/code>/gi, '`$1`'); |
||||||
|
|
||||||
|
// Clean up HTML entities and remaining tags
|
||||||
|
markdown = markdown.replace(/ /g, ' '); |
||||||
|
markdown = markdown.replace(/&/g, '&'); |
||||||
|
markdown = markdown.replace(/</g, '<'); |
||||||
|
markdown = markdown.replace(/>/g, '>'); |
||||||
|
markdown = markdown.replace(/"/g, '"'); |
||||||
|
markdown = markdown.replace(/<[^>]*>/g, ''); |
||||||
|
|
||||||
|
// Clean up extra whitespace
|
||||||
|
markdown = markdown.replace(/\n{3,}/g, '\n\n').trim(); |
||||||
|
|
||||||
|
return markdown; |
||||||
|
} |
||||||
|
|
||||||
|
generateSlug(title) { |
||||||
|
// add a random seed at the end of the title to avoid collisions
|
||||||
|
const randomSeed = Math.random().toString(36).substring(2, 8); |
||||||
|
title = `${title} ${randomSeed}`; |
||||||
|
return title |
||||||
|
.toLowerCase() |
||||||
|
.replace(/[^a-z0-9\s-]/g, '') |
||||||
|
.replace(/\s+/g, '-') |
||||||
|
.replace(/-+/g, '-') |
||||||
|
.replace(/^-|-$/g, ''); |
||||||
|
} |
||||||
|
|
||||||
|
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>`; |
||||||
|
} |
||||||
|
} |
||||||
} |
} |
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,150 @@ |
|||||||
|
/* Advanced Metadata Styles */ |
||||||
|
|
||||||
|
.advanced-metadata-section details { |
||||||
|
border: 1px solid #dee2e6; |
||||||
|
border-radius: 0.375rem; |
||||||
|
padding: 1rem; |
||||||
|
background-color: #f8f9fa; |
||||||
|
} |
||||||
|
|
||||||
|
.advanced-metadata-section summary { |
||||||
|
cursor: pointer; |
||||||
|
user-select: none; |
||||||
|
font-weight: 600; |
||||||
|
margin: -1rem -1rem 0 -1rem; |
||||||
|
padding: 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
.advanced-metadata-section summary:hover { |
||||||
|
background-color: #e9ecef; |
||||||
|
} |
||||||
|
|
||||||
|
.advanced-metadata-section details[open] summary { |
||||||
|
border-bottom: 1px solid #dee2e6; |
||||||
|
margin-bottom: 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
.zap-split-item { |
||||||
|
background-color: #ffffff; |
||||||
|
transition: box-shadow 0.2s; |
||||||
|
} |
||||||
|
|
||||||
|
.zap-split-item:hover { |
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); |
||||||
|
} |
||||||
|
|
||||||
|
.zap-share-display { |
||||||
|
font-size: 0.875rem; |
||||||
|
font-weight: 600; |
||||||
|
color: #6c757d; |
||||||
|
} |
||||||
|
|
||||||
|
.upload-area { |
||||||
|
border: 2px dashed #dee2e6; |
||||||
|
border-radius: 0.375rem; |
||||||
|
padding: 2rem; |
||||||
|
text-align: center; |
||||||
|
cursor: pointer; |
||||||
|
transition: all 0.2s; |
||||||
|
margin: 1rem 0; |
||||||
|
} |
||||||
|
|
||||||
|
.upload-area:hover { |
||||||
|
border-color: #0d6efd; |
||||||
|
background-color: #f8f9fa; |
||||||
|
} |
||||||
|
|
||||||
|
.upload-area input[type="file"] { |
||||||
|
display: none; |
||||||
|
} |
||||||
|
|
||||||
|
.iu-dialog { |
||||||
|
display: none; |
||||||
|
position: fixed; |
||||||
|
top: 0; |
||||||
|
left: 0; |
||||||
|
right: 0; |
||||||
|
bottom: 0; |
||||||
|
z-index: 1050; |
||||||
|
} |
||||||
|
|
||||||
|
.iu-dialog.active { |
||||||
|
display: block; |
||||||
|
} |
||||||
|
|
||||||
|
.iu-backdrop { |
||||||
|
position: absolute; |
||||||
|
top: 0; |
||||||
|
left: 0; |
||||||
|
right: 0; |
||||||
|
bottom: 0; |
||||||
|
background-color: rgba(0, 0, 0, 0.5); |
||||||
|
} |
||||||
|
|
||||||
|
.iu-modal { |
||||||
|
position: relative; |
||||||
|
max-width: 500px; |
||||||
|
margin: 3rem auto; |
||||||
|
background-color: white; |
||||||
|
border-radius: 0.375rem; |
||||||
|
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); |
||||||
|
z-index: 1051; |
||||||
|
} |
||||||
|
|
||||||
|
.modal-header { |
||||||
|
padding: 1rem; |
||||||
|
border-bottom: 1px solid #dee2e6; |
||||||
|
display: flex; |
||||||
|
justify-content: space-between; |
||||||
|
align-items: center; |
||||||
|
} |
||||||
|
|
||||||
|
.modal-header .close { |
||||||
|
background: none; |
||||||
|
border: none; |
||||||
|
font-size: 1.5rem; |
||||||
|
cursor: pointer; |
||||||
|
color: #6c757d; |
||||||
|
} |
||||||
|
|
||||||
|
.modal-body { |
||||||
|
padding: 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
.upload-progress, |
||||||
|
.upload-error { |
||||||
|
margin-top: 1rem; |
||||||
|
padding: 0.75rem; |
||||||
|
border-radius: 0.375rem; |
||||||
|
} |
||||||
|
|
||||||
|
.upload-progress { |
||||||
|
background-color: #d1ecf1; |
||||||
|
color: #0c5460; |
||||||
|
} |
||||||
|
|
||||||
|
.upload-error { |
||||||
|
background-color: #f8d7da; |
||||||
|
color: #721c24; |
||||||
|
} |
||||||
|
|
||||||
|
/* Form validation styles */ |
||||||
|
.form-control.is-invalid { |
||||||
|
border-color: #dc3545; |
||||||
|
} |
||||||
|
|
||||||
|
.form-control.is-valid { |
||||||
|
border-color: #28a745; |
||||||
|
} |
||||||
|
|
||||||
|
.invalid-feedback { |
||||||
|
display: block; |
||||||
|
margin-top: 0.25rem; |
||||||
|
font-size: 0.875em; |
||||||
|
color: #dc3545; |
||||||
|
} |
||||||
|
|
||||||
|
.cursor-pointer { |
||||||
|
cursor: pointer; |
||||||
|
} |
||||||
|
|
||||||
@ -0,0 +1,27 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace DoctrineMigrations; |
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema; |
||||||
|
use Doctrine\Migrations\AbstractMigration; |
||||||
|
|
||||||
|
final class Version20251105000000 extends AbstractMigration |
||||||
|
{ |
||||||
|
public function getDescription(): string |
||||||
|
{ |
||||||
|
return 'Add advanced_metadata field to article table for storing Nostr advanced tags'; |
||||||
|
} |
||||||
|
|
||||||
|
public function up(Schema $schema): void |
||||||
|
{ |
||||||
|
$this->addSql('ALTER TABLE article ADD advanced_metadata JSON DEFAULT NULL'); |
||||||
|
} |
||||||
|
|
||||||
|
public function down(Schema $schema): void |
||||||
|
{ |
||||||
|
$this->addSql('ALTER TABLE article DROP advanced_metadata'); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
@ -0,0 +1,67 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace App\Dto; |
||||||
|
|
||||||
|
use Symfony\Component\Validator\Constraints as Assert; |
||||||
|
|
||||||
|
class AdvancedMetadata |
||||||
|
{ |
||||||
|
public bool $doNotRepublish = false; |
||||||
|
|
||||||
|
#[Assert\Choice(choices: [ |
||||||
|
'', |
||||||
|
'CC0-1.0', |
||||||
|
'CC-BY-4.0', |
||||||
|
'CC-BY-SA-4.0', |
||||||
|
'CC-BY-NC-4.0', |
||||||
|
'CC-BY-NC-SA-4.0', |
||||||
|
'CC-BY-ND-4.0', |
||||||
|
'CC-BY-NC-ND-4.0', |
||||||
|
'MIT', |
||||||
|
'Apache-2.0', |
||||||
|
'GPL-3.0', |
||||||
|
'AGPL-3.0', |
||||||
|
'All rights reserved', |
||||||
|
'custom' |
||||||
|
])] |
||||||
|
public string $license = ''; |
||||||
|
|
||||||
|
public ?string $customLicense = null; |
||||||
|
|
||||||
|
/** @var ZapSplit[] */ |
||||||
|
#[Assert\Valid] |
||||||
|
public array $zapSplits = []; |
||||||
|
|
||||||
|
public ?string $contentWarning = null; |
||||||
|
|
||||||
|
public ?int $expirationTimestamp = null; |
||||||
|
|
||||||
|
public bool $isProtected = false; |
||||||
|
|
||||||
|
/** @var array<string, mixed> Additional tags to preserve on re-publish */ |
||||||
|
public array $extraTags = []; |
||||||
|
|
||||||
|
public function addZapSplit(ZapSplit $split): void |
||||||
|
{ |
||||||
|
$this->zapSplits[] = $split; |
||||||
|
} |
||||||
|
|
||||||
|
public function removeZapSplit(int $index): void |
||||||
|
{ |
||||||
|
if (isset($this->zapSplits[$index])) { |
||||||
|
unset($this->zapSplits[$index]); |
||||||
|
$this->zapSplits = array_values($this->zapSplits); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
public function getLicenseValue(): ?string |
||||||
|
{ |
||||||
|
if ($this->license === 'custom') { |
||||||
|
return $this->customLicense; |
||||||
|
} |
||||||
|
return $this->license !== '' ? $this->license : null; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
@ -0,0 +1,51 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace App\Dto; |
||||||
|
|
||||||
|
use Symfony\Component\Validator\Constraints as Assert; |
||||||
|
|
||||||
|
class ZapSplit |
||||||
|
{ |
||||||
|
#[Assert\NotBlank(message: 'Recipient is required')] |
||||||
|
public string $recipient = ''; |
||||||
|
|
||||||
|
#[Assert\Regex( |
||||||
|
pattern: '/^wss:\/\/.+/', |
||||||
|
message: 'Relay must be a valid WebSocket URL starting with wss://' |
||||||
|
)] |
||||||
|
public ?string $relay = null; |
||||||
|
|
||||||
|
#[Assert\PositiveOrZero(message: 'Weight must be 0 or greater')] |
||||||
|
public ?int $weight = null; |
||||||
|
|
||||||
|
/** Computed share percentage (0-100) */ |
||||||
|
public ?float $sharePercent = null; |
||||||
|
|
||||||
|
public function __construct( |
||||||
|
string $recipient = '', |
||||||
|
?string $relay = null, |
||||||
|
?int $weight = null |
||||||
|
) { |
||||||
|
$this->recipient = $recipient; |
||||||
|
$this->relay = $relay; |
||||||
|
$this->weight = $weight; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Returns the recipient as hex pubkey. |
||||||
|
* If it's an npub, it gets converted to hex. |
||||||
|
*/ |
||||||
|
public function getRecipientHex(): string |
||||||
|
{ |
||||||
|
// This will be handled by the service/utility |
||||||
|
return $this->recipient; |
||||||
|
} |
||||||
|
|
||||||
|
public function isNpub(): bool |
||||||
|
{ |
||||||
|
return str_starts_with($this->recipient, 'npub1'); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
@ -0,0 +1,106 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace App\Form; |
||||||
|
|
||||||
|
use App\Dto\AdvancedMetadata; |
||||||
|
use Symfony\Component\Form\AbstractType; |
||||||
|
use Symfony\Component\Form\Extension\Core\Type\CheckboxType; |
||||||
|
use Symfony\Component\Form\Extension\Core\Type\ChoiceType; |
||||||
|
use Symfony\Component\Form\Extension\Core\Type\CollectionType; |
||||||
|
use Symfony\Component\Form\Extension\Core\Type\DateTimeType; |
||||||
|
use Symfony\Component\Form\Extension\Core\Type\TextType; |
||||||
|
use Symfony\Component\Form\FormBuilderInterface; |
||||||
|
use Symfony\Component\OptionsResolver\OptionsResolver; |
||||||
|
|
||||||
|
class AdvancedMetadataType extends AbstractType |
||||||
|
{ |
||||||
|
public function buildForm(FormBuilderInterface $builder, array $options): void |
||||||
|
{ |
||||||
|
$builder |
||||||
|
->add('doNotRepublish', CheckboxType::class, [ |
||||||
|
'label' => 'Do not republish', |
||||||
|
'required' => false, |
||||||
|
'help' => 'Mark this article with a policy label indicating it should not be republished', |
||||||
|
'row_attr' => ['class' => 'form-check'], |
||||||
|
'label_attr' => ['class' => 'form-check-label'], |
||||||
|
'attr' => ['class' => 'form-check-input'], |
||||||
|
]) |
||||||
|
->add('license', ChoiceType::class, [ |
||||||
|
'label' => 'License', |
||||||
|
'required' => false, |
||||||
|
'choices' => [ |
||||||
|
'No license' => '', |
||||||
|
'Public Domain (CC0)' => 'CC0-1.0', |
||||||
|
'Attribution (CC-BY)' => 'CC-BY-4.0', |
||||||
|
'Attribution-ShareAlike (CC-BY-SA)' => 'CC-BY-SA-4.0', |
||||||
|
'Attribution-NonCommercial (CC-BY-NC)' => 'CC-BY-NC-4.0', |
||||||
|
'Attribution-NonCommercial-ShareAlike (CC-BY-NC-SA)' => 'CC-BY-NC-SA-4.0', |
||||||
|
'Attribution-NoDerivs (CC-BY-ND)' => 'CC-BY-ND-4.0', |
||||||
|
'Attribution-NonCommercial-NoDerivs (CC-BY-NC-ND)' => 'CC-BY-NC-ND-4.0', |
||||||
|
'MIT License' => 'MIT', |
||||||
|
'Apache License 2.0' => 'Apache-2.0', |
||||||
|
'GNU GPL v3' => 'GPL-3.0', |
||||||
|
'GNU AGPL v3' => 'AGPL-3.0', |
||||||
|
'All rights reserved' => 'All rights reserved', |
||||||
|
'Custom license' => 'custom', |
||||||
|
], |
||||||
|
'attr' => ['class' => 'form-select'], |
||||||
|
]) |
||||||
|
->add('customLicense', TextType::class, [ |
||||||
|
'label' => 'Custom license identifier', |
||||||
|
'required' => false, |
||||||
|
'attr' => [ |
||||||
|
'class' => 'form-control', |
||||||
|
'placeholder' => 'e.g., My-Custom-License-1.0' |
||||||
|
], |
||||||
|
'help' => 'Specify a custom SPDX identifier or license name', |
||||||
|
]) |
||||||
|
->add('zapSplits', CollectionType::class, [ |
||||||
|
'entry_type' => ZapSplitType::class, |
||||||
|
'allow_add' => true, |
||||||
|
'allow_delete' => true, |
||||||
|
'by_reference' => false, |
||||||
|
'label' => 'Zap splits', |
||||||
|
'required' => false, |
||||||
|
'attr' => [ |
||||||
|
'class' => 'zap-splits-collection', |
||||||
|
], |
||||||
|
'help' => 'Configure multiple recipients for zaps (tips). Leave weights empty for equal distribution.', |
||||||
|
]) |
||||||
|
->add('contentWarning', TextType::class, [ |
||||||
|
'label' => 'Content warning', |
||||||
|
'required' => false, |
||||||
|
'attr' => [ |
||||||
|
'class' => 'form-control', |
||||||
|
'placeholder' => 'e.g., graphic content, spoilers, etc.' |
||||||
|
], |
||||||
|
'help' => 'Optional warning about sensitive content', |
||||||
|
]) |
||||||
|
->add('expirationTimestamp', DateTimeType::class, [ |
||||||
|
'label' => 'Expiration date', |
||||||
|
'required' => false, |
||||||
|
'widget' => 'single_text', |
||||||
|
'input' => 'timestamp', |
||||||
|
'attr' => ['class' => 'form-control'], |
||||||
|
'help' => 'When this article should expire (optional)', |
||||||
|
]) |
||||||
|
->add('isProtected', CheckboxType::class, [ |
||||||
|
'label' => 'Protected event', |
||||||
|
'required' => false, |
||||||
|
'help' => 'Mark this event as protected. Warning: Some relays may reject protected events.', |
||||||
|
'row_attr' => ['class' => 'form-check'], |
||||||
|
'label_attr' => ['class' => 'form-check-label'], |
||||||
|
'attr' => ['class' => 'form-check-input'], |
||||||
|
]); |
||||||
|
} |
||||||
|
|
||||||
|
public function configureOptions(OptionsResolver $resolver): void |
||||||
|
{ |
||||||
|
$resolver->setDefaults([ |
||||||
|
'data_class' => AdvancedMetadata::class, |
||||||
|
]); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
@ -0,0 +1,66 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace App\Form; |
||||||
|
|
||||||
|
use App\Dto\ZapSplit; |
||||||
|
use Symfony\Component\Form\AbstractType; |
||||||
|
use Symfony\Component\Form\Extension\Core\Type\IntegerType; |
||||||
|
use Symfony\Component\Form\Extension\Core\Type\TextType; |
||||||
|
use Symfony\Component\Form\FormBuilderInterface; |
||||||
|
use Symfony\Component\OptionsResolver\OptionsResolver; |
||||||
|
use Symfony\Component\Validator\Constraints as Assert; |
||||||
|
|
||||||
|
class ZapSplitType extends AbstractType |
||||||
|
{ |
||||||
|
public function buildForm(FormBuilderInterface $builder, array $options): void |
||||||
|
{ |
||||||
|
$builder |
||||||
|
->add('recipient', TextType::class, [ |
||||||
|
'label' => 'Recipient', |
||||||
|
'required' => true, |
||||||
|
'attr' => [ |
||||||
|
'class' => 'form-control zap-recipient', |
||||||
|
'placeholder' => 'npub1... or hex pubkey' |
||||||
|
], |
||||||
|
'constraints' => [ |
||||||
|
new Assert\NotBlank(['message' => 'Recipient is required']), |
||||||
|
], |
||||||
|
]) |
||||||
|
->add('relay', TextType::class, [ |
||||||
|
'label' => 'Relay hint', |
||||||
|
'required' => false, |
||||||
|
'attr' => [ |
||||||
|
'class' => 'form-control zap-relay', |
||||||
|
'placeholder' => 'wss://relay.example.com' |
||||||
|
], |
||||||
|
'constraints' => [ |
||||||
|
new Assert\Regex([ |
||||||
|
'pattern' => '/^wss:\/\/.+/', |
||||||
|
'message' => 'Relay must be a valid WebSocket URL starting with wss://' |
||||||
|
]), |
||||||
|
], |
||||||
|
]) |
||||||
|
->add('weight', IntegerType::class, [ |
||||||
|
'label' => 'Weight', |
||||||
|
'required' => false, |
||||||
|
'attr' => [ |
||||||
|
'class' => 'form-control zap-weight', |
||||||
|
'placeholder' => '1', |
||||||
|
'min' => 0 |
||||||
|
], |
||||||
|
'constraints' => [ |
||||||
|
new Assert\PositiveOrZero(['message' => 'Weight must be 0 or greater']), |
||||||
|
], |
||||||
|
]); |
||||||
|
} |
||||||
|
|
||||||
|
public function configureOptions(OptionsResolver $resolver): void |
||||||
|
{ |
||||||
|
$resolver->setDefaults([ |
||||||
|
'data_class' => ZapSplit::class, |
||||||
|
]); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
@ -0,0 +1,192 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace App\Service\Nostr; |
||||||
|
|
||||||
|
use App\Dto\AdvancedMetadata; |
||||||
|
use App\Dto\ZapSplit; |
||||||
|
use App\Entity\Article; |
||||||
|
use nostriphant\NIP19\Bech32; |
||||||
|
use swentel\nostr\Key\Key; |
||||||
|
|
||||||
|
class NostrEventBuilder |
||||||
|
{ |
||||||
|
private Key $key; |
||||||
|
|
||||||
|
public function __construct() |
||||||
|
{ |
||||||
|
$this->key = new Key(); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Build tags array from Article and AdvancedMetadata |
||||||
|
* |
||||||
|
* @param Article $article |
||||||
|
* @param AdvancedMetadata|null $metadata |
||||||
|
* @param array $formData Additional form data (isDraft, addClientTag, etc.) |
||||||
|
* @return array |
||||||
|
*/ |
||||||
|
public function buildTags(Article $article, ?AdvancedMetadata $metadata, array $formData = []): array |
||||||
|
{ |
||||||
|
$tags = []; |
||||||
|
|
||||||
|
// Base NIP-23 tags |
||||||
|
$tags[] = ['d', $article->getSlug() ?? '']; |
||||||
|
$tags[] = ['title', $article->getTitle() ?? '']; |
||||||
|
$tags[] = ['published_at', (string)($article->getPublishedAt()?->getTimestamp() ?? time())]; |
||||||
|
|
||||||
|
if ($article->getSummary()) { |
||||||
|
$tags[] = ['summary', $article->getSummary()]; |
||||||
|
} |
||||||
|
|
||||||
|
if ($article->getImage()) { |
||||||
|
$tags[] = ['image', $article->getImage()]; |
||||||
|
} |
||||||
|
|
||||||
|
// Topic tags |
||||||
|
if ($article->getTopics()) { |
||||||
|
foreach ($article->getTopics() as $topic) { |
||||||
|
$cleanTopic = str_replace('#', '', $topic); |
||||||
|
$tags[] = ['t', $cleanTopic]; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Client tag |
||||||
|
if ($formData['addClientTag'] ?? false) { |
||||||
|
$tags[] = ['client', 'Decent Newsroom']; |
||||||
|
} |
||||||
|
|
||||||
|
// Advanced metadata tags |
||||||
|
if ($metadata) { |
||||||
|
$tags = array_merge($tags, $this->buildAdvancedTags($metadata)); |
||||||
|
} |
||||||
|
|
||||||
|
return $tags; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Build advanced metadata tags |
||||||
|
*/ |
||||||
|
private function buildAdvancedTags(AdvancedMetadata $metadata): array |
||||||
|
{ |
||||||
|
$tags = []; |
||||||
|
|
||||||
|
// Policy: Do not republish |
||||||
|
if ($metadata->doNotRepublish) { |
||||||
|
$tags[] = ['L', 'rights.decent.newsroom']; |
||||||
|
$tags[] = ['l', 'no-republish', 'rights.decent.newsroom']; |
||||||
|
} |
||||||
|
|
||||||
|
// License |
||||||
|
$license = $metadata->getLicenseValue(); |
||||||
|
if ($license && $license !== 'All rights reserved') { |
||||||
|
$tags[] = ['L', 'spdx.org/licenses']; |
||||||
|
$tags[] = ['l', $license, 'spdx.org/licenses']; |
||||||
|
} elseif ($license === 'All rights reserved') { |
||||||
|
$tags[] = ['L', 'rights.decent.newsroom']; |
||||||
|
$tags[] = ['l', 'all-rights-reserved', 'rights.decent.newsroom']; |
||||||
|
} |
||||||
|
|
||||||
|
// Zap splits |
||||||
|
foreach ($metadata->zapSplits as $split) { |
||||||
|
$zapTag = ['zap', $this->convertToHex($split->recipient)]; |
||||||
|
|
||||||
|
if ($split->relay) { |
||||||
|
$zapTag[] = $split->relay; |
||||||
|
} else { |
||||||
|
$zapTag[] = ''; |
||||||
|
} |
||||||
|
|
||||||
|
if ($split->weight !== null) { |
||||||
|
$zapTag[] = (string)$split->weight; |
||||||
|
} |
||||||
|
|
||||||
|
$tags[] = $zapTag; |
||||||
|
} |
||||||
|
|
||||||
|
// Content warning |
||||||
|
if ($metadata->contentWarning) { |
||||||
|
$tags[] = ['content-warning', $metadata->contentWarning]; |
||||||
|
} |
||||||
|
|
||||||
|
// Expiration |
||||||
|
if ($metadata->expirationTimestamp) { |
||||||
|
$tags[] = ['expiration', (string)$metadata->expirationTimestamp]; |
||||||
|
} |
||||||
|
|
||||||
|
// Protected event |
||||||
|
if ($metadata->isProtected) { |
||||||
|
$tags[] = ['-']; |
||||||
|
} |
||||||
|
|
||||||
|
// Extra tags (passthrough) |
||||||
|
foreach ($metadata->extraTags as $tag) { |
||||||
|
if (is_array($tag)) { |
||||||
|
$tags[] = $tag; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return $tags; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Convert npub or hex to hex pubkey |
||||||
|
*/ |
||||||
|
public function convertToHex(string $pubkey): string |
||||||
|
{ |
||||||
|
if (str_starts_with($pubkey, 'npub1')) { |
||||||
|
try { |
||||||
|
return $this->key->convertToHex($pubkey); |
||||||
|
} catch (\Exception $e) { |
||||||
|
throw new \InvalidArgumentException('Invalid npub format: ' . $e->getMessage()); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Validate hex format |
||||||
|
if (!preg_match('/^[0-9a-f]{64}$/i', $pubkey)) { |
||||||
|
throw new \InvalidArgumentException('Invalid pubkey format. Must be hex (64 chars) or npub'); |
||||||
|
} |
||||||
|
|
||||||
|
return strtolower($pubkey); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Calculate share percentages for zap splits |
||||||
|
* |
||||||
|
* @param ZapSplit[] $splits |
||||||
|
* @return array Array of percentages indexed by split position |
||||||
|
*/ |
||||||
|
public function calculateShares(array $splits): array |
||||||
|
{ |
||||||
|
if (empty($splits)) { |
||||||
|
return []; |
||||||
|
} |
||||||
|
|
||||||
|
$hasWeights = false; |
||||||
|
$totalWeight = 0; |
||||||
|
|
||||||
|
foreach ($splits as $split) { |
||||||
|
if ($split->weight !== null && $split->weight > 0) { |
||||||
|
$hasWeights = true; |
||||||
|
$totalWeight += $split->weight; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// If no weights specified, equal distribution |
||||||
|
if (!$hasWeights) { |
||||||
|
$equalShare = 100.0 / count($splits); |
||||||
|
return array_fill(0, count($splits), $equalShare); |
||||||
|
} |
||||||
|
|
||||||
|
// Calculate weighted shares |
||||||
|
$shares = []; |
||||||
|
foreach ($splits as $split) { |
||||||
|
$weight = $split->weight ?? 0; |
||||||
|
$shares[] = $totalWeight > 0 ? ($weight / $totalWeight) * 100 : 0; |
||||||
|
} |
||||||
|
|
||||||
|
return $shares; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
@ -0,0 +1,188 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace App\Service\Nostr; |
||||||
|
|
||||||
|
use App\Dto\AdvancedMetadata; |
||||||
|
use App\Dto\ZapSplit; |
||||||
|
use swentel\nostr\Key\Key; |
||||||
|
|
||||||
|
class NostrEventParser |
||||||
|
{ |
||||||
|
private Key $key; |
||||||
|
|
||||||
|
public function __construct() |
||||||
|
{ |
||||||
|
$this->key = new Key(); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Parse tags from a Nostr event into AdvancedMetadata DTO |
||||||
|
* |
||||||
|
* @param array $tags The tags array from the Nostr event |
||||||
|
* @return AdvancedMetadata |
||||||
|
*/ |
||||||
|
public function parseAdvancedMetadata(array $tags): AdvancedMetadata |
||||||
|
{ |
||||||
|
$metadata = new AdvancedMetadata(); |
||||||
|
$knownTags = ['d', 'title', 'summary', 'image', 'published_at', 't', 'client']; |
||||||
|
$processedAdvancedTags = []; |
||||||
|
|
||||||
|
foreach ($tags as $tag) { |
||||||
|
if (!is_array($tag) || empty($tag)) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
$tagName = $tag[0] ?? ''; |
||||||
|
|
||||||
|
switch ($tagName) { |
||||||
|
case 'L': |
||||||
|
// Label namespace - just track it, process with 'l' tags |
||||||
|
break; |
||||||
|
|
||||||
|
case 'l': |
||||||
|
$this->parseLabel($tag, $metadata); |
||||||
|
$processedAdvancedTags[] = $tag; |
||||||
|
break; |
||||||
|
|
||||||
|
case 'zap': |
||||||
|
$split = $this->parseZapTag($tag); |
||||||
|
if ($split) { |
||||||
|
$metadata->addZapSplit($split); |
||||||
|
$processedAdvancedTags[] = $tag; |
||||||
|
} |
||||||
|
break; |
||||||
|
|
||||||
|
case 'content-warning': |
||||||
|
$metadata->contentWarning = $tag[1] ?? ''; |
||||||
|
$processedAdvancedTags[] = $tag; |
||||||
|
break; |
||||||
|
|
||||||
|
case 'expiration': |
||||||
|
$timestamp = (int)($tag[1] ?? 0); |
||||||
|
if ($timestamp > 0) { |
||||||
|
$metadata->expirationTimestamp = $timestamp; |
||||||
|
} |
||||||
|
$processedAdvancedTags[] = $tag; |
||||||
|
break; |
||||||
|
|
||||||
|
case '-': |
||||||
|
$metadata->isProtected = true; |
||||||
|
$processedAdvancedTags[] = $tag; |
||||||
|
break; |
||||||
|
|
||||||
|
default: |
||||||
|
// Preserve unknown tags for passthrough |
||||||
|
if (!in_array($tagName, $knownTags, true)) { |
||||||
|
$metadata->extraTags[] = $tag; |
||||||
|
} |
||||||
|
break; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return $metadata; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Parse a label tag (NIP-32) |
||||||
|
*/ |
||||||
|
private function parseLabel(array $tag, AdvancedMetadata $metadata): void |
||||||
|
{ |
||||||
|
$label = $tag[1] ?? ''; |
||||||
|
$namespace = $tag[2] ?? ''; |
||||||
|
|
||||||
|
if ($namespace === 'rights.decent.newsroom') { |
||||||
|
if ($label === 'no-republish') { |
||||||
|
$metadata->doNotRepublish = true; |
||||||
|
} elseif ($label === 'all-rights-reserved') { |
||||||
|
$metadata->license = 'All rights reserved'; |
||||||
|
} |
||||||
|
} elseif ($namespace === 'spdx.org/licenses') { |
||||||
|
$metadata->license = $label; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Parse a zap split tag |
||||||
|
* |
||||||
|
* Format: ["zap", <hex-pubkey>, <relay>, <weight>] |
||||||
|
* Relay and weight are optional |
||||||
|
*/ |
||||||
|
private function parseZapTag(array $tag): ?ZapSplit |
||||||
|
{ |
||||||
|
if (count($tag) < 2) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
$pubkeyHex = $tag[1] ?? ''; |
||||||
|
if (empty($pubkeyHex)) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
// Convert hex to npub for display (more user-friendly) |
||||||
|
try { |
||||||
|
$npub = $this->key->convertPublicKeyToBech32($pubkeyHex); |
||||||
|
} catch (\Exception $e) { |
||||||
|
// If conversion fails, use hex |
||||||
|
$npub = $pubkeyHex; |
||||||
|
} |
||||||
|
|
||||||
|
$relay = $tag[2] ?? null; |
||||||
|
if ($relay === '') { |
||||||
|
$relay = null; |
||||||
|
} |
||||||
|
|
||||||
|
$weight = isset($tag[3]) && $tag[3] !== '' ? (int)$tag[3] : null; |
||||||
|
|
||||||
|
return new ZapSplit($npub, $relay, $weight); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Extract basic article data from tags |
||||||
|
*/ |
||||||
|
public function extractArticleData(array $tags): array |
||||||
|
{ |
||||||
|
$data = [ |
||||||
|
'slug' => '', |
||||||
|
'title' => '', |
||||||
|
'summary' => '', |
||||||
|
'image' => '', |
||||||
|
'topics' => [], |
||||||
|
'publishedAt' => null, |
||||||
|
]; |
||||||
|
|
||||||
|
foreach ($tags as $tag) { |
||||||
|
if (!is_array($tag) || count($tag) < 2) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
switch ($tag[0]) { |
||||||
|
case 'd': |
||||||
|
$data['slug'] = $tag[1]; |
||||||
|
break; |
||||||
|
case 'title': |
||||||
|
$data['title'] = $tag[1]; |
||||||
|
break; |
||||||
|
case 'summary': |
||||||
|
$data['summary'] = $tag[1]; |
||||||
|
break; |
||||||
|
case 'image': |
||||||
|
$data['image'] = $tag[1]; |
||||||
|
break; |
||||||
|
case 't': |
||||||
|
$data['topics'][] = $tag[1]; |
||||||
|
break; |
||||||
|
case 'published_at': |
||||||
|
$timestamp = (int)$tag[1]; |
||||||
|
if ($timestamp > 0) { |
||||||
|
$data['publishedAt'] = new \DateTimeImmutable('@' . $timestamp); |
||||||
|
} |
||||||
|
break; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return $data; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
@ -0,0 +1,115 @@ |
|||||||
|
{% block _editor_advancedMetadata_widget %} |
||||||
|
<div class="advanced-metadata-section" {{ stimulus_controller('advanced-metadata') }}> |
||||||
|
<details class="mb-4"> |
||||||
|
<summary class="h5 cursor-pointer">Zap splits and content warning</summary> |
||||||
|
<div class="mt-2 p-3 border rounded"> |
||||||
|
<div class="row hidden"> |
||||||
|
<div class="col-md-6"> |
||||||
|
{{ form_row(form.doNotRepublish) }} |
||||||
|
</div> |
||||||
|
<div class="col-md-6"> |
||||||
|
{{ form_row(form.license, { |
||||||
|
'attr': { |
||||||
|
'data-advanced-metadata-target': 'licenseSelect', |
||||||
|
'data-action': 'change->advanced-metadata#licenseChanged' |
||||||
|
} |
||||||
|
}) }} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="row hidden"> |
||||||
|
<div class="col-md-12"> |
||||||
|
{{ form_row(form.customLicense, { |
||||||
|
'attr': { |
||||||
|
'data-advanced-metadata-target': 'customLicenseInput' |
||||||
|
} |
||||||
|
}) }} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
|
||||||
|
<h6>Zap Splits</h6> |
||||||
|
<p class="text-muted small">Configure multiple recipients for zaps (tips). Weights determine the split ratio.</p> |
||||||
|
|
||||||
|
<div data-advanced-metadata-target="zapSplitsContainer" |
||||||
|
data-prototype="{{ form_widget(form.zapSplits.vars.prototype)|e('html_attr') }}" |
||||||
|
data-index="{{ form.zapSplits|length }}"> |
||||||
|
{% for zapSplit in form.zapSplits %} |
||||||
|
<div class="zap-split-item mb-3 p-3 border rounded" data-index="{{ loop.index0 }}"> |
||||||
|
<div class="row"> |
||||||
|
<div class="col-md-5"> |
||||||
|
{{ form_row(zapSplit.recipient) }} |
||||||
|
</div> |
||||||
|
<div class="col-md-4"> |
||||||
|
{{ form_row(zapSplit.relay) }} |
||||||
|
</div> |
||||||
|
<div class="col-md-3"> |
||||||
|
{{ form_row(zapSplit.weight) }} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
<button type="button" |
||||||
|
class="btn btn-sm btn-danger mt-2" |
||||||
|
data-action="click->advanced-metadata#removeZapSplit" |
||||||
|
data-index="{{ loop.index0 }}"> |
||||||
|
Remove |
||||||
|
</button> |
||||||
|
<div class="zap-share-display mt-2 text-muted"> |
||||||
|
<small>Share: <span class="share-percent">0</span>%</small> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{% endfor %} |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="mb-3"> |
||||||
|
<button type="button" |
||||||
|
class="btn btn-sm btn-secondary" |
||||||
|
data-advanced-metadata-target="addZapButton" |
||||||
|
data-action="click->advanced-metadata#addZapSplit"> |
||||||
|
Add Zap Recipient |
||||||
|
</button> |
||||||
|
<button type="button" |
||||||
|
class="btn btn-sm btn-outline-secondary" |
||||||
|
data-advanced-metadata-target="distributeEquallyButton" |
||||||
|
data-action="click->advanced-metadata#distributeEqually"> |
||||||
|
Distribute Equally |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
|
||||||
|
<hr class="my-4"> |
||||||
|
|
||||||
|
<div class="row"> |
||||||
|
<div class="col-md-6"> |
||||||
|
{{ form_row(form.contentWarning) }} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
<div class="row hidden"> |
||||||
|
<div class="col-md-6"> |
||||||
|
{{ form_row(form.expirationTimestamp, { |
||||||
|
'attr': { |
||||||
|
'data-advanced-metadata-target': 'expirationInput', |
||||||
|
'data-action': 'change->advanced-metadata#expirationChanged' |
||||||
|
} |
||||||
|
}) }} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="row hidden"> |
||||||
|
<div class="col-md-12"> |
||||||
|
{{ form_row(form.isProtected, { |
||||||
|
'attr': { |
||||||
|
'data-advanced-metadata-target': 'protectedCheckbox', |
||||||
|
'data-action': 'change->advanced-metadata#protectedChanged' |
||||||
|
} |
||||||
|
}) }} |
||||||
|
<div class="alert alert-warning mt-2" |
||||||
|
style="display: none;" |
||||||
|
data-advanced-metadata-target="protectedWarning"> |
||||||
|
<small><strong>Warning:</strong> Some relays may reject protected events. Use with caution.</small> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</details> |
||||||
|
</div> |
||||||
|
{% endblock %} |
||||||
|
|
||||||
Loading…
Reference in new issue