Files
client/www/js/strudel-nostr.mjs
T
2026-04-17 16:52:51 -04:00

620 lines
17 KiB
JavaScript

/**
* Strudel Nostr Save & Share Module
* Handles saving/loading songs and instruments to/from Nostr relays
* Uses custom kinds: 32220 (Instrument), 32221 (Song), 32222 (Sound)
*/
import { publishEvent, subscribe, getPubkey } from './init-ndk.mjs';
// ============================================================================
// Constants
// ============================================================================
export const KINDS = {
INSTRUMENT: 32220,
SONG: 32221,
SOUND: 32222
};
const CLIENT_TAG = 'strudel-ndk';
// ============================================================================
// Event Building
// ============================================================================
/**
* Build a song event (kind 32221)
* @param {string} title - Song title
* @param {string} code - Full editor content
* @param {string[]} tags - Array of hashtag strings
* @param {string} description - Optional description
* @returns {Object} Unsigned event object
*/
export function buildSongEvent(title, code, tags = [], description = '', dTag = null) {
// If dTag is provided, use it (for updates), otherwise generate new one
const finalDTag = dTag || `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const event = {
kind: KINDS.SONG,
created_at: Math.floor(Date.now() / 1000),
tags: [
['d', finalDTag],
['title', title],
['alt', `Strudel song: ${title}`],
['client', CLIENT_TAG]
],
content: code
};
// Add hashtags
tags.forEach(tag => {
if (tag && tag.trim()) {
event.tags.push(['t', tag.trim().toLowerCase()]);
}
});
// Add description if provided
if (description && description.trim()) {
event.tags.push(['description', description.trim()]);
}
return event;
}
/**
* Build an instrument event (kind 32220)
* @param {string} title - Instrument title
* @param {string} code - Selected code snippet
* @param {string[]} tags - Array of hashtag strings
* @param {string} description - Optional description
* @returns {Object} Unsigned event object
*/
export function buildInstrumentEvent(title, code, tags = [], description = '', dTag = null) {
// If dTag is provided, use it (for updates), otherwise generate new one
const finalDTag = dTag || `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const event = {
kind: KINDS.INSTRUMENT,
created_at: Math.floor(Date.now() / 1000),
tags: [
['d', finalDTag],
['title', title],
['alt', `Strudel instrument: ${title}`],
['client', CLIENT_TAG]
],
content: code
};
// Add hashtags
tags.forEach(tag => {
if (tag && tag.trim()) {
event.tags.push(['t', tag.trim().toLowerCase()]);
}
});
// Add description if provided
if (description && description.trim()) {
event.tags.push(['description', description.trim()]);
}
return event;
}
/**
* Build a deletion event (kind 5)
* @param {number} kind - The kind of event to delete
* @param {string} dTag - The d-tag value of the event to delete
* @returns {Object} Unsigned deletion event
*/
export async function buildDeleteEvent(kind, dTag) {
const pubkey = await getPubkey();
if (!pubkey) {
throw new Error('No active user');
}
// For addressable events, we use the 'a' tag format: kind:pubkey:d-tag
const address = `${kind}:${pubkey}:${dTag}`;
return {
kind: 5,
created_at: Math.floor(Date.now() / 1000),
tags: [
['a', address],
['k', kind.toString()]
],
content: 'Deleted from Strudel'
};
}
// ============================================================================
// Publishing
// ============================================================================
/**
* Save a song to Nostr
* @param {string} title - Song title
* @param {string} code - Full editor content
* @param {string[]} tags - Array of hashtag strings
* @param {string} description - Optional description
* @param {string|null} dTag - Optional dTag for updating existing song
* @returns {Promise<Object>} Published event
*/
export async function saveSong(title, code, tags = [], description = '', dTag = null) {
if (!title || !title.trim()) {
throw new Error('Title is required');
}
if (!code || !code.trim()) {
throw new Error('Code is required');
}
const event = buildSongEvent(title.trim(), code, tags, description, dTag);
return await publishEvent(event);
}
/**
* Save an instrument to Nostr
* @param {string} title - Instrument title
* @param {string} code - Selected code snippet
* @param {string[]} tags - Array of hashtag strings
* @param {string} description - Optional description
* @param {string|null} dTag - Optional dTag for updating existing instrument
* @returns {Promise<Object>} Published event
*/
export async function saveInstrument(title, code, tags = [], description = '', dTag = null) {
if (!title || !title.trim()) {
throw new Error('Title is required');
}
if (!code || !code.trim()) {
throw new Error('Code is required');
}
const event = buildInstrumentEvent(title.trim(), code, tags, description, dTag);
return await publishEvent(event);
}
/**
* Delete an item from Nostr
* @param {number} kind - The kind of event to delete
* @param {string} dTag - The d-tag value of the event to delete
* @returns {Promise<Object>} Published deletion event
*/
export async function deleteItem(kind, dTag) {
const event = buildDeleteEvent(kind, dTag);
return await publishEvent(event);
}
// ============================================================================
// Subscriptions
// ============================================================================
let eventHandlers = [];
let currentSubIds = [];
/**
* Subscribe to user's saved items
* @param {string} pubkey - User's pubkey (hex)
* @param {number|null} kind - Specific kind to subscribe to, or null for all
* @param {Function} onEvent - Callback for each event received
* @param {Function} onEose - Callback when end of stored events reached
* @returns {Object} Subscription handle with close() method
*/
export function subscribeToMyItems(pubkey, kind = null, onEvent = null, onEose = null) {
const targetKinds = kind ? [kind] : [KINDS.INSTRUMENT, KINDS.SONG, KINDS.SOUND];
const filter = {
authors: [pubkey],
kinds: targetKinds
};
const sub = subscribe(filter, { closeOnEose: false });
currentSubIds.push(sub.subId);
// Set up event handler - filter by kind since all subscriptions receive all ndkEvent events
const eventHandler = (e) => {
const event = e.detail;
if (event && onEvent && targetKinds.includes(event.kind)) {
onEvent(event);
}
};
const eoseHandler = (e) => {
if (onEose && e.detail?.subId === sub.subId) {
onEose();
}
};
window.addEventListener('ndkEvent', eventHandler);
window.addEventListener('ndkEose', eoseHandler);
eventHandlers.push({ event: eventHandler, eose: eoseHandler });
return {
subId: sub.subId,
close: () => {
sub.close();
window.removeEventListener('ndkEvent', eventHandler);
window.removeEventListener('ndkEose', eoseHandler);
currentSubIds = currentSubIds.filter(id => id !== sub.subId);
eventHandlers = eventHandlers.filter(h => h.event !== eventHandler);
}
};
}
/**
* Subscribe to community items (no author filter)
* @param {number|null} kind - Specific kind to subscribe to, or null for all
* @param {number} limit - Max number of events to fetch
* @param {Function} onEvent - Callback for each event received
* @param {Function} onEose - Callback when end of stored events reached
* @returns {Object} Subscription handle with close() method
*/
export function subscribeToCommunity(kind = null, limit = 50, onEvent = null, onEose = null) {
const filter = {
kinds: kind ? [kind] : [KINDS.INSTRUMENT, KINDS.SONG, KINDS.SOUND],
limit: limit
};
const sub = subscribe(filter, { closeOnEose: true });
currentSubIds.push(sub.subId);
// Set up event handler
const eventHandler = (e) => {
const event = e.detail;
if (event && onEvent) {
onEvent(event);
}
};
const eoseHandler = (e) => {
if (onEose && e.detail?.subId === sub.subId) {
onEose();
}
};
window.addEventListener('ndkEvent', eventHandler);
window.addEventListener('ndkEose', eoseHandler);
eventHandlers.push({ event: eventHandler, eose: eoseHandler });
return {
subId: sub.subId,
close: () => {
sub.close();
window.removeEventListener('ndkEvent', eventHandler);
window.removeEventListener('ndkEose', eoseHandler);
currentSubIds = currentSubIds.filter(id => id !== sub.subId);
eventHandlers = eventHandlers.filter(h => h.event !== eventHandler);
}
};
}
/**
* Unsubscribe from all active subscriptions
*/
export function unsubscribeAll() {
eventHandlers.forEach(h => {
window.removeEventListener('ndkEvent', h.event);
window.removeEventListener('ndkEose', h.eose);
});
eventHandlers = [];
currentSubIds = [];
}
// ============================================================================
// Event Parsing
// ============================================================================
/**
* Parse a Strudel event into a display object
* @param {Object} event - NDK event object
* @returns {Object} Parsed item with id, kind, title, content, tags, etc.
*/
export function parseStrudelEvent(event) {
const item = {
id: event.id,
kind: event.kind,
pubkey: event.pubkey,
createdAt: event.created_at,
dTag: null,
title: 'Untitled',
description: '',
tags: [],
content: event.content || '',
alt: ''
};
// Parse tags
if (event.tags && Array.isArray(event.tags)) {
event.tags.forEach(tag => {
if (!Array.isArray(tag) || tag.length < 2) return;
const [key, ...values] = tag;
switch (key) {
case 'd':
item.dTag = values[0];
break;
case 'title':
item.title = values[0] || 'Untitled';
break;
case 'description':
item.description = values[0] || '';
break;
case 't':
item.tags.push(values[0]);
break;
case 'alt':
item.alt = values[0] || '';
break;
}
});
}
// Determine type from kind
switch (item.kind) {
case KINDS.INSTRUMENT:
item.type = 'instrument';
break;
case KINDS.SONG:
item.type = 'song';
break;
case KINDS.SOUND:
item.type = 'sound';
break;
default:
item.type = 'unknown';
}
return item;
}
// ============================================================================
// Editor Interaction Helpers
// ============================================================================
/**
* Get the strudel-editor element and its CodeMirror view
* @param {HTMLElement} container - The strudel-editor element or its container
* @returns {Object|null} { element, view, state } or null if not found
*/
export function getEditorInstance(container) {
if (!container) return null;
// If container is the strudel-editor element
let editorEl = container.tagName === 'STRUDEL-EDITOR' ? container : null;
// Otherwise look for it inside the container
if (!editorEl && container.querySelector) {
editorEl = container.querySelector('strudel-editor');
}
if (!editorEl) return null;
// The strudel-editor component exposes the CodeMirror view
// This depends on the internal structure of the component
const view = editorEl._view || editorEl.view || (editorEl.editor && editorEl.editor._view);
if (!view) {
// Fallback: try to access via the shadow DOM
const shadow = editorEl.shadowRoot;
if (shadow) {
const cmEditor = shadow.querySelector('.cm-editor');
if (cmEditor && cmEditor.cmView) {
return { element: editorEl, view: cmEditor.cmView, state: cmEditor.cmView.state };
}
}
}
if (view && view.state) {
return { element: editorEl, view: view, state: view.state };
}
return null;
}
/**
* Get the currently selected text from the editor
* @param {Object} view - CodeMirror EditorView
* @returns {string|null} Selected text or null if no selection
*/
export function getEditorSelection(view) {
if (!view || !view.state) return null;
const state = view.state;
const selection = state.selection;
if (!selection || selection.ranges.length === 0) return null;
const range = selection.main;
if (range.empty) return null;
return state.sliceDoc(range.from, range.to);
}
/**
* Check if there is a text selection in the editor
* @param {Object} view - CodeMirror EditorView
* @returns {boolean}
*/
export function hasEditorSelection(view) {
if (!view || !view.state) return false;
const selection = view.state.selection;
if (!selection || !selection.main) return false;
return !selection.main.empty;
}
/**
* Insert code at the cursor position
* @param {Object} view - CodeMirror EditorView
* @param {string} code - Code to insert
*/
export function insertAtCursor(view, code) {
if (!view || !view.state || !code) return;
const state = view.state;
const selection = state.selection.main;
view.dispatch({
changes: {
from: selection.from,
to: selection.to,
insert: code
},
selection: { anchor: selection.from + code.length }
});
}
/**
* Replace the selected text with new code
* @param {Object} view - CodeMirror EditorView
* @param {string} code - Code to replace selection with
*/
export function replaceSelection(view, code) {
if (!view || !view.state || !code) return;
const state = view.state;
const selection = state.selection.main;
view.dispatch({
changes: {
from: selection.from,
to: selection.to,
insert: code
},
selection: { anchor: selection.from + code.length }
});
}
/**
* Replace the entire editor content
* @param {HTMLElement} editorEl - The strudel-editor element
* @param {string} code - New code content
*/
export function replaceEditorContent(editorEl, code) {
if (!editorEl || !code) return;
// The strudel-editor component has a 'code' property
if (typeof editorEl.code !== 'undefined') {
editorEl.code = code;
} else {
// Fallback: try to set via the view
const editor = getEditorInstance(editorEl);
if (editor && editor.view) {
const state = editor.view.state;
editor.view.dispatch({
changes: {
from: 0,
to: state.doc.length,
insert: code
}
});
}
}
}
/**
* Get the entire editor content
* @param {HTMLElement} editorEl - The strudel-editor element
* @returns {string} Editor content
*/
export function getEditorContent(editorEl) {
if (!editorEl) return '';
// The strudel-editor component has a 'code' property
if (typeof editorEl.code !== 'undefined') {
return editorEl.code;
}
// Fallback: try to get via the view
const editor = getEditorInstance(editorEl);
if (editor && editor.state) {
return editor.state.doc.toString();
}
return '';
}
// ============================================================================
// Utilities
// ============================================================================
/**
* Format a timestamp for display
* @param {number} timestamp - Unix timestamp in seconds
* @returns {string} Formatted date string
*/
export function formatTimestamp(timestamp) {
const date = new Date(timestamp * 1000);
const now = new Date();
const diff = now - date;
// Less than 24 hours ago
if (diff < 24 * 60 * 60 * 1000) {
if (diff < 60 * 1000) return 'just now';
if (diff < 60 * 60 * 1000) return `${Math.floor(diff / (60 * 1000))}m ago`;
return `${Math.floor(diff / (60 * 60 * 1000))}h ago`;
}
// Less than 7 days ago
if (diff < 7 * 24 * 60 * 60 * 1000) {
return `${Math.floor(diff / (24 * 60 * 60 * 1000))}d ago`;
}
// Older
return date.toLocaleDateString();
}
/**
* Truncate text with ellipsis
* @param {string} text - Text to truncate
* @param {number} maxLength - Maximum length
* @returns {string} Truncated text
*/
export function truncateText(text, maxLength = 50) {
if (!text || text.length <= maxLength) return text;
return text.substring(0, maxLength - 3) + '...';
}
/**
* Parse a comma-separated tag string into an array
* @param {string} tagString - Comma-separated tags
* @returns {string[]} Array of tags
*/
export function parseTags(tagString) {
if (!tagString || !tagString.trim()) return [];
return tagString
.split(/[,\s]+/)
.map(t => t.trim().toLowerCase())
.filter(t => t.length > 0);
}
// ============================================================================
// Export default object for convenience
// ============================================================================
export default {
KINDS,
saveSong,
saveInstrument,
deleteItem,
subscribeToMyItems,
subscribeToCommunity,
unsubscribeAll,
parseStrudelEvent,
buildSongEvent,
buildInstrumentEvent,
buildDeleteEvent,
getEditorInstance,
getEditorSelection,
hasEditorSelection,
insertAtCursor,
replaceSelection,
replaceEditorContent,
getEditorContent,
formatTimestamp,
truncateText,
parseTags
};