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 |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
@ -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