620 lines
17 KiB
JavaScript
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
|
|
};
|