473 lines
12 KiB
JavaScript
473 lines
12 KiB
JavaScript
/**
|
|
* ============================================
|
|
* ENCODING / DECODING FUNCTIONS
|
|
* ============================================
|
|
*/
|
|
|
|
/**
|
|
* Encode a template object into a URL-safe base64 string
|
|
*/
|
|
export const encodeTemplate = (template) => {
|
|
try {
|
|
const json = JSON.stringify(template);
|
|
return btoa(encodeURIComponent(json));
|
|
} catch (error) {
|
|
throw new Error('Failed to encode template: ' + error.message);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Decode a URL-safe base64 string back to a template object
|
|
*/
|
|
export const decodeTemplate = (encoded) => {
|
|
try {
|
|
const clean = encoded.trim();
|
|
const decoded = atob(clean);
|
|
const json = decodeURIComponent(decoded);
|
|
return JSON.parse(json);
|
|
} catch (error) {
|
|
console.error('Decode error:', error);
|
|
throw new Error('Failed to decode template: ' + error.message);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Generate a short unique ID for templates
|
|
*/
|
|
export const generateTemplateId = () => {
|
|
const timestamp = Date.now().toString(36);
|
|
const random = Math.random().toString(36).substring(2, 8);
|
|
return `${timestamp}-${random}`;
|
|
};
|
|
|
|
/**
|
|
* ============================================
|
|
* VALIDATION FUNCTIONS
|
|
* ============================================
|
|
*/
|
|
|
|
/**
|
|
* Validate template structure and data
|
|
*/
|
|
export const validateTemplate = (template) => {
|
|
if (!template || typeof template !== 'object') {
|
|
throw new Error('Template must be an object');
|
|
}
|
|
|
|
if (!template.name || typeof template.name !== 'string' || template.name.trim().length === 0) {
|
|
throw new Error('Template must have a name');
|
|
}
|
|
|
|
// Validate main relays
|
|
if (!Array.isArray(template.relays) || template.relays.length === 0) {
|
|
throw new Error('Template must have at least one relay');
|
|
}
|
|
|
|
template.relays.forEach((relay, index) => {
|
|
if (!relay.url || typeof relay.url !== 'string') {
|
|
throw new Error(`Relay ${index + 1} must have a URL`);
|
|
}
|
|
|
|
try {
|
|
const url = new URL(relay.url);
|
|
if (url.protocol !== 'wss:' && url.protocol !== 'ws:') {
|
|
throw new Error(`Relay ${index + 1} must use wss:// or ws:// protocol`);
|
|
}
|
|
} catch (error) {
|
|
throw new Error(`Relay ${index + 1} has invalid URL: ${relay.url}`);
|
|
}
|
|
|
|
if (typeof relay.read !== 'boolean') {
|
|
throw new Error(`Relay ${index + 1} must specify read as boolean`);
|
|
}
|
|
|
|
if (typeof relay.write !== 'boolean') {
|
|
throw new Error(`Relay ${index + 1} must specify write as boolean`);
|
|
}
|
|
});
|
|
|
|
// Validate blossom servers (optional)
|
|
if (template.blossomServers && Array.isArray(template.blossomServers)) {
|
|
template.blossomServers.forEach((server, index) => {
|
|
if (!server.url || typeof server.url !== 'string') {
|
|
throw new Error(`Blossom server ${index + 1} must have a URL`);
|
|
}
|
|
try {
|
|
const url = new URL(server.url);
|
|
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
|
|
throw new Error(`Blossom server ${index + 1} must use https:// or http:// protocol`);
|
|
}
|
|
} catch (error) {
|
|
throw new Error(`Blossom server ${index + 1} has invalid URL: ${server.url}`);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Validate DM relays (optional)
|
|
if (template.dmRelays && Array.isArray(template.dmRelays)) {
|
|
template.dmRelays.forEach((relay, index) => {
|
|
if (!relay.url || typeof relay.url !== 'string') {
|
|
throw new Error(`DM relay ${index + 1} must have a URL`);
|
|
}
|
|
try {
|
|
const url = new URL(relay.url);
|
|
if (url.protocol !== 'wss:' && url.protocol !== 'ws:') {
|
|
throw new Error(`DM relay ${index + 1} must use wss:// or ws:// protocol`);
|
|
}
|
|
} catch (error) {
|
|
throw new Error(`DM relay ${index + 1} has invalid URL: ${relay.url}`);
|
|
}
|
|
});
|
|
}
|
|
|
|
return true;
|
|
};
|
|
|
|
/**
|
|
* Check if a template has duplicate relay URLs
|
|
*/
|
|
export const hasDuplicateRelays = (template) => {
|
|
const urls = template.relays.map(r => r.url);
|
|
return new Set(urls).size !== urls.length;
|
|
};
|
|
|
|
/**
|
|
* ============================================
|
|
* DEFAULT / PRESET TEMPLATES
|
|
* ============================================
|
|
*/
|
|
|
|
/**
|
|
* Get default relay template
|
|
*/
|
|
export const getDefaultTemplate = () => {
|
|
return {
|
|
id: generateTemplateId(),
|
|
name: 'Default Relays',
|
|
description: 'Standard Nostr relay set for beginners',
|
|
relays: [
|
|
{ url: 'wss://relay.damus.io', read: true, write: true },
|
|
{ url: 'wss://nos.lol', read: true, write: true },
|
|
{ url: 'wss://relay.snort.social', read: true, write: true }
|
|
],
|
|
blossomServers: [
|
|
{ url: 'https://cdn.satellite.earth' }
|
|
],
|
|
dmRelays: [
|
|
{ url: 'wss://relay.private-msgs.com' }
|
|
],
|
|
created_at: Math.floor(Date.now() / 1000)
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Get popular community templates
|
|
*/
|
|
export const getCommunityTemplates = () => {
|
|
return {
|
|
'planet-dyne': {
|
|
id: 'planet-dyne',
|
|
name: 'Planet Dyne',
|
|
description: 'Relay set for dynes like you',
|
|
relays: [
|
|
{ url: 'wss://relay.dyne.org', read: false, write: true },
|
|
{ url: 'wss://relay.dyne.org/inbox', read: true, write: false },
|
|
{ url: 'wss://relay.ditto.pub', read: true, write: true }
|
|
],
|
|
blossomServers: [
|
|
{ url: 'https://relay.dyne.org' },
|
|
{ url: 'https://blossom.primal.net' }
|
|
],
|
|
dmRelays: [
|
|
{ url: 'wss://relay.dyne.org/inbox' }
|
|
]
|
|
},
|
|
'basspistol': {
|
|
id: 'basspistol',
|
|
name: 'Basspistol',
|
|
description: 'Relay set for Basspistol outernational music syndicate',
|
|
relays: [
|
|
{ url: 'wss://basspistol.org', read: false, write: true },
|
|
{ url: 'wss://basspistol.org/inbox', read: true, write: false },
|
|
{ url: 'wss://relay.ditto.pub', read: true, write: true }
|
|
],
|
|
blossomServers: [
|
|
{ url: 'https://basspistol.org' },
|
|
{ url: 'https://blossom.primal.net' }
|
|
],
|
|
dmRelays: [
|
|
{ url: 'wss://basspistol.org/inbox' }
|
|
]
|
|
},
|
|
'spatia-arcana': {
|
|
id: 'spatia-arcana',
|
|
name: 'Spatia Arcana',
|
|
description: 'Relay set for Spatia Arcana',
|
|
relays: [
|
|
{ url: 'wss://spatia-arcana.com', read: false, write: true },
|
|
{ url: 'wss://spatia-arcana.com/inbox', read: true, write: false },
|
|
{ url: 'wss://relay.ditto.pub', read: true, write: true }
|
|
],
|
|
blossomServers: [
|
|
{ url: 'https://spatia-arcana.com' },
|
|
{ url: 'https://blossom.primal.net' }
|
|
],
|
|
dmRelays: [
|
|
{ url: 'wss://spatia-arcana.com/inbox' }
|
|
]
|
|
},
|
|
'pyramid-fiatjaf': {
|
|
id: 'pyramid-fiatjaf',
|
|
name: 'Fiatjaf Pyramid',
|
|
description: 'Relay set for Fiatjaf Pyramid',
|
|
relays: [
|
|
{ url: 'wss://pyramid.fiatjaf.com', read: false, write: true },
|
|
{ url: 'wss://pyramid.fiatjaf.com/inbox', read: true, write: false },
|
|
{ url: 'wss://relay.ditto.pub', read: true, write: true }
|
|
],
|
|
blossomServers: [
|
|
{ url: 'https://pyramid.fiatjaf.com' },
|
|
{ url: 'https://blossom.primal.net' }
|
|
],
|
|
dmRelays: [
|
|
{ url: 'wss://pyramid.fiatjaf.com/inbox' }
|
|
]
|
|
},
|
|
'neuch-blockchain': {
|
|
id: 'neuch-blockchain',
|
|
name: 'Neuchatel Blockchain',
|
|
description: 'Relay set for Neuchatel Blockchain community',
|
|
relays: [
|
|
{ url: 'wss://nestr.nedao.ch', read: false, write: true },
|
|
{ url: 'wss://nestr.nedao.ch/inbox', read: true, write: false },
|
|
{ url: 'wss://relay.ditto.pub', read: true, write: true }
|
|
],
|
|
blossomServers: [
|
|
{ url: 'https://nestr.nedao.ch' },
|
|
{ url: 'https://blossom.primal.net' }
|
|
],
|
|
dmRelays: [
|
|
{ url: 'wss://nestr.nedao.ch/inbox' }
|
|
]
|
|
},
|
|
'anon': {
|
|
id: 'anon',
|
|
name: 'Anon Relays',
|
|
description: 'Relay set for anons',
|
|
relays: [
|
|
{ url: 'wss://nos.lol', read: true, write: true },
|
|
{ url: 'wss://nostr.mom', read: true, write: true },
|
|
{ url: 'wss://relay.ditto.pub', read: true, write: true }
|
|
],
|
|
blossomServers: [
|
|
{ url: 'https://spatia-arcana.com' },
|
|
{ url: 'https://blossom.primal.net' }
|
|
],
|
|
dmRelays: [
|
|
{ url: 'wss://spatia-arcana.com/inbox' }
|
|
]
|
|
},
|
|
};
|
|
};
|
|
|
|
/**
|
|
* ============================================
|
|
* TEMPLATE MANIPULATION FUNCTIONS
|
|
* ============================================
|
|
*/
|
|
|
|
/**
|
|
* Add a relay to a template
|
|
*/
|
|
export const addRelayToTemplate = (template, relay) => {
|
|
if (!relay.url) {
|
|
throw new Error('Relay must have a URL');
|
|
}
|
|
|
|
if (template.relays.some(r => r.url === relay.url)) {
|
|
throw new Error('Relay already exists in template');
|
|
}
|
|
|
|
return {
|
|
...template,
|
|
relays: [
|
|
...template.relays,
|
|
{
|
|
url: relay.url,
|
|
read: relay.read !== undefined ? relay.read : true,
|
|
write: relay.write !== undefined ? relay.write : true
|
|
}
|
|
]
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Remove a relay from a template by URL
|
|
*/
|
|
export const removeRelayFromTemplate = (template, relayUrl) => {
|
|
return {
|
|
...template,
|
|
relays: template.relays.filter(r => r.url !== relayUrl)
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Update a relay in a template
|
|
*/
|
|
export const updateRelayInTemplate = (template, relayUrl, updates) => {
|
|
return {
|
|
...template,
|
|
relays: template.relays.map(r =>
|
|
r.url === relayUrl ? { ...r, ...updates } : r
|
|
)
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Clone a template (deep copy)
|
|
*/
|
|
export const cloneTemplate = (template) => {
|
|
return JSON.parse(JSON.stringify(template));
|
|
};
|
|
|
|
/**
|
|
* ============================================
|
|
* CONVERSION FUNCTIONS
|
|
* ============================================
|
|
*/
|
|
|
|
/**
|
|
* Convert template relays to kind 10002 tags format
|
|
*/
|
|
export const templateToKind10002Tags = (template) => {
|
|
return template.relays.map(relay => {
|
|
const params = [];
|
|
if (!relay.read) params.push('read');
|
|
if (!relay.write) params.push('write');
|
|
return ['r', relay.url, ...params];
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Convert kind 10002 tags to template format
|
|
*/
|
|
export const kind10002TagsToTemplate = (tags, name = 'Imported Relays') => {
|
|
const relays = tags
|
|
.filter(tag => tag[0] === 'r')
|
|
.map(tag => {
|
|
const url = tag[1];
|
|
const params = tag.slice(2);
|
|
return {
|
|
url: url,
|
|
read: !params.includes('read'),
|
|
write: !params.includes('write')
|
|
};
|
|
});
|
|
|
|
return {
|
|
id: generateTemplateId(),
|
|
name: name,
|
|
description: `Imported from existing relay set`,
|
|
relays: relays,
|
|
blossomServers: [],
|
|
dmRelays: [],
|
|
created_at: Math.floor(Date.now() / 1000)
|
|
};
|
|
};
|
|
|
|
/**
|
|
* ============================================
|
|
* STORAGE FUNCTIONS (LocalStorage)
|
|
* ============================================
|
|
*/
|
|
|
|
/**
|
|
* Save template to localStorage
|
|
*/
|
|
export const saveTemplateToStorage = (template) => {
|
|
const history = JSON.parse(localStorage.getItem('templateHistory') || '[]');
|
|
const entry = {
|
|
...template,
|
|
saved_at: Date.now()
|
|
};
|
|
const filtered = history.filter(h => h.id !== template.id);
|
|
filtered.unshift(entry);
|
|
localStorage.setItem('templateHistory', JSON.stringify(filtered.slice(0, 50)));
|
|
return entry;
|
|
};
|
|
|
|
/**
|
|
* Get template history from localStorage
|
|
*/
|
|
export const getTemplateHistory = () => {
|
|
return JSON.parse(localStorage.getItem('templateHistory') || '[]');
|
|
};
|
|
|
|
/**
|
|
* Clear template history
|
|
*/
|
|
export const clearTemplateHistory = () => {
|
|
localStorage.removeItem('templateHistory');
|
|
};
|
|
|
|
/**
|
|
* Get recently applied templates
|
|
*/
|
|
export const getRecentTemplates = (limit = 5) => {
|
|
const history = getTemplateHistory();
|
|
return history.slice(0, limit);
|
|
};
|
|
|
|
/**
|
|
* ============================================
|
|
* SHARE FUNCTIONS
|
|
* ============================================
|
|
*/
|
|
|
|
/**
|
|
* Generate a shareable URL for a template
|
|
*/
|
|
export const generateShareUrl = (template, baseUrl = window.location.origin) => {
|
|
const encoded = encodeTemplate(template);
|
|
return `${baseUrl}/apply/${encoded}`;
|
|
};
|
|
|
|
/**
|
|
* Extract template from share URL
|
|
*/
|
|
export const extractTemplateFromUrl = (url) => {
|
|
const match = url.match(/\/apply\/([^/?]+)/);
|
|
if (!match) {
|
|
throw new Error('Invalid share URL');
|
|
}
|
|
return decodeTemplate(match[1]);
|
|
};
|
|
|
|
/**
|
|
* ============================================
|
|
* EXPORT ALL
|
|
* ============================================
|
|
*/
|
|
|
|
// Default export for convenience
|
|
export default {
|
|
encodeTemplate,
|
|
decodeTemplate,
|
|
generateTemplateId,
|
|
validateTemplate,
|
|
hasDuplicateRelays,
|
|
getDefaultTemplate,
|
|
getCommunityTemplates,
|
|
addRelayToTemplate,
|
|
removeRelayFromTemplate,
|
|
updateRelayInTemplate,
|
|
cloneTemplate,
|
|
templateToKind10002Tags,
|
|
kind10002TagsToTemplate,
|
|
saveTemplateToStorage,
|
|
getTemplateHistory,
|
|
clearTemplateHistory,
|
|
getRecentTemplates,
|
|
generateShareUrl,
|
|
extractTemplateFromUrl
|
|
};
|