/** * NOSTR_LOGIN_LITE Modal UI * Minimal vanilla JS modal to replace Stencil/Tailwind component library */ class Modal { constructor(options = {}) { this.options = options; this.container = null; this.isVisible = false; this.currentScreen = null; this.isEmbedded = !!options.embedded; this.embeddedContainer = options.embedded; // Initialize modal container and styles this._initModal(); } _initModal() { // Create modal container this.container = document.createElement('div'); this.container.id = this.isEmbedded ? 'nl-modal-embedded' : 'nl-modal'; if (this.isEmbedded) { // Embedded mode: inline positioning, no overlay this.container.style.cssText = ` position: relative; display: none; font-family: var(--nl-font-family, 'Courier New', monospace); width: 100%; `; } else { // Modal mode: fixed overlay this.container.style.cssText = ` position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.75); display: none; z-index: 10000; font-family: var(--nl-font-family, 'Courier New', monospace); `; } // Create modal content const modalContent = document.createElement('div'); if (this.isEmbedded) { // Embedded content: no centering margin, full width modalContent.style.cssText = ` position: relative; background: var(--nl-secondary-color); color: var(--nl-primary-color); width: 100%; border-radius: var(--nl-border-radius, 15px); border: var(--nl-border-width) solid var(--nl-primary-color); overflow: hidden; `; } else { // Modal content: centered with margin modalContent.style.cssText = ` position: relative; background: var(--nl-secondary-color); color: var(--nl-primary-color); width: 90%; max-width: 400px; margin: 50px auto; border-radius: var(--nl-border-radius, 15px); border: var(--nl-border-width) solid var(--nl-primary-color); max-height: 600px; overflow: hidden; `; } // Header const modalHeader = document.createElement('div'); modalHeader.style.cssText = ` padding: 20px 24px 0 24px; display: flex; justify-content: space-between; align-items: center; background: transparent; border-bottom: none; `; const modalTitle = document.createElement('h2'); modalTitle.textContent = 'Nostr Login'; modalTitle.style.cssText = ` margin: 0; font-size: 24px; font-weight: 600; color: var(--nl-primary-color); font-family: var(--nl-font-family, 'Courier New', monospace); `; modalHeader.appendChild(modalTitle); // Only add close button for non-embedded modals // Embedded modals shouldn't have a close button because there's no way to reopen them if (!this.isEmbedded) { const closeButton = document.createElement('button'); closeButton.innerHTML = 'Ć'; closeButton.onclick = () => this.close(); closeButton.style.cssText = ` background: var(--nl-secondary-color); border: var(--nl-border-width) solid var(--nl-primary-color); border-radius: var(--nl-border-radius); font-size: 28px; color: var(--nl-primary-color); cursor: pointer; padding: 0; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; font-family: var(--nl-font-family, 'Courier New', monospace); `; closeButton.onmouseover = () => { closeButton.style.borderColor = 'var(--nl-accent-color)'; closeButton.style.background = 'var(--nl-secondary-color)'; }; closeButton.onmouseout = () => { closeButton.style.borderColor = 'var(--nl-primary-color)'; closeButton.style.background = 'var(--nl-secondary-color)'; }; modalHeader.appendChild(closeButton); } // Body this.modalBody = document.createElement('div'); this.modalBody.style.cssText = ` padding: 24px; overflow-y: auto; max-height: 500px; background: transparent; font-family: var(--nl-font-family, 'Courier New', monospace); `; modalContent.appendChild(modalHeader); modalContent.appendChild(this.modalBody); this.container.appendChild(modalContent); // Add to appropriate parent if (this.isEmbedded && this.embeddedContainer) { // Append to specified container for embedding if (typeof this.embeddedContainer === 'string') { const targetElement = document.querySelector(this.embeddedContainer); if (targetElement) { targetElement.appendChild(this.container); } else { console.error('NOSTR_LOGIN_LITE: Embedded container not found:', this.embeddedContainer); document.body.appendChild(this.container); } } else if (this.embeddedContainer instanceof HTMLElement) { this.embeddedContainer.appendChild(this.container); } else { console.error('NOSTR_LOGIN_LITE: Invalid embedded container'); document.body.appendChild(this.container); } } else { // Add to body for modal mode document.body.appendChild(this.container); } // Click outside to close (only for modal mode) if (!this.isEmbedded) { this.container.onclick = (e) => { if (e.target === this.container) { this.close(); } }; } // Update theme this.updateTheme(); } updateTheme() { // The theme will automatically update through CSS custom properties // No manual styling needed - the CSS variables handle everything } open(opts = {}) { this.currentScreen = opts.startScreen; this.isVisible = true; this.container.style.display = 'block'; // Render login options this._renderLoginOptions(); } close() { this.isVisible = false; this.container.style.display = 'none'; this.modalBody.innerHTML = ''; } _renderLoginOptions() { this.modalBody.innerHTML = ''; const options = []; // Extension option if (this.options?.methods?.extension !== false) { options.push({ type: 'extension', title: 'Browser Extension', description: 'Use your browser extension', icon: 'š' }); } // Local key option if (this.options?.methods?.local !== false) { options.push({ type: 'local', title: 'Local Key', description: 'Create or import your own key', icon: 'š' }); } // Nostr Connect option (check both 'connect' and 'remote' for compatibility) if (this.options?.methods?.connect !== false && this.options?.methods?.remote !== false) { options.push({ type: 'connect', title: 'Nostr Connect', description: 'Connect with external signer', icon: 'š' }); } // Read-only option if (this.options?.methods?.readonly !== false) { options.push({ type: 'readonly', title: 'Read Only', description: 'Browse without signing', icon: 'šļø' }); } // OTP/DM option if (this.options?.methods?.otp !== false) { options.push({ type: 'otp', title: 'DM/OTP', description: 'Receive OTP via DM', icon: 'š±' }); } // Render each option options.forEach(option => { const button = document.createElement('button'); button.onclick = () => this._handleOptionClick(option.type); button.style.cssText = ` display: flex; align-items: center; width: 100%; padding: 16px; margin-bottom: 12px; background: var(--nl-secondary-color); color: var(--nl-primary-color); border: var(--nl-border-width) solid var(--nl-primary-color); border-radius: var(--nl-border-radius); cursor: pointer; transition: all 0.2s; font-family: var(--nl-font-family, 'Courier New', monospace); `; button.onmouseover = () => { button.style.borderColor = 'var(--nl-accent-color)'; button.style.background = 'var(--nl-secondary-color)'; }; button.onmouseout = () => { button.style.borderColor = 'var(--nl-primary-color)'; button.style.background = 'var(--nl-secondary-color)'; }; const iconDiv = document.createElement('div'); // Replace emoji icons with text-based ones const iconMap = { 'š': '[EXT]', 'š': '[KEY]', 'š': '[NET]', 'šļø': '[VIEW]', 'š±': '[SMS]' }; iconDiv.textContent = iconMap[option.icon] || option.icon; iconDiv.style.cssText = ` font-size: 16px; font-weight: bold; margin-right: 16px; width: 50px; text-align: center; color: var(--nl-primary-color); font-family: var(--nl-font-family, 'Courier New', monospace); `; const contentDiv = document.createElement('div'); contentDiv.style.cssText = 'flex: 1; text-align: left;'; const titleDiv = document.createElement('div'); titleDiv.textContent = option.title; titleDiv.style.cssText = ` font-weight: 600; margin-bottom: 4px; color: var(--nl-primary-color); font-family: var(--nl-font-family, 'Courier New', monospace); `; const descDiv = document.createElement('div'); descDiv.textContent = option.description; descDiv.style.cssText = ` font-size: 14px; color: #666666; font-family: var(--nl-font-family, 'Courier New', monospace); `; contentDiv.appendChild(titleDiv); contentDiv.appendChild(descDiv); button.appendChild(iconDiv); button.appendChild(contentDiv); this.modalBody.appendChild(button); }); } _handleOptionClick(type) { console.log('Selected login type:', type); // Handle different login types switch (type) { case 'extension': this._handleExtension(); break; case 'local': this._showLocalKeyScreen(); break; case 'connect': this._showConnectScreen(); break; case 'readonly': this._handleReadonly(); break; case 'otp': this._showOtpScreen(); break; } } _handleExtension() { // Detect all available real extensions const availableExtensions = this._detectAllExtensions(); console.log(`Modal: Found ${availableExtensions.length} extensions:`, availableExtensions.map(e => e.displayName)); if (availableExtensions.length === 0) { console.log('Modal: No real extensions found'); this._showExtensionRequired(); } else if (availableExtensions.length === 1) { // Single extension - use it directly without showing choice UI console.log('Modal: Single extension detected, using it directly:', availableExtensions[0].displayName); this._tryExtensionLogin(availableExtensions[0].extension); } else { // Multiple extensions - show choice UI console.log('Modal: Multiple extensions detected, showing choice UI for', availableExtensions.length, 'extensions'); this._showExtensionChoice(availableExtensions); } } _detectAllExtensions() { const extensions = []; const seenExtensions = new Set(); // Track extensions by object reference to avoid duplicates // Extension locations to check (in priority order) const locations = [ { path: 'window.navigator?.nostr', name: 'navigator.nostr', displayName: 'Standard Extension (navigator.nostr)', icon: 'š', getter: () => window.navigator?.nostr }, { path: 'window.webln?.nostr', name: 'webln.nostr', displayName: 'Alby WebLN Extension', icon: 'ā”', getter: () => window.webln?.nostr }, { path: 'window.alby?.nostr', name: 'alby.nostr', displayName: 'Alby Extension (Direct)', icon: 'š', getter: () => window.alby?.nostr }, { path: 'window.nos2x', name: 'nos2x', displayName: 'nos2x Extension', icon: 'š', getter: () => window.nos2x }, { path: 'window.flamingo?.nostr', name: 'flamingo.nostr', displayName: 'Flamingo Extension', icon: 'š¦©', getter: () => window.flamingo?.nostr }, { path: 'window.mutiny?.nostr', name: 'mutiny.nostr', displayName: 'Mutiny Extension', icon: 'āļø', getter: () => window.mutiny?.nostr }, { path: 'window.nostrich?.nostr', name: 'nostrich.nostr', displayName: 'Nostrich Extension', icon: 'š¦', getter: () => window.nostrich?.nostr }, { path: 'window.getAlby?.nostr', name: 'getAlby.nostr', displayName: 'getAlby Extension', icon: 'š§', getter: () => window.getAlby?.nostr } ]; // Check each location for (const location of locations) { try { const obj = location.getter(); console.log(`Modal: Checking ${location.name}:`, !!obj, obj?.constructor?.name); if (obj && this._isRealExtension(obj) && !seenExtensions.has(obj)) { extensions.push({ name: location.name, displayName: location.displayName, icon: location.icon, extension: obj }); seenExtensions.add(obj); console.log(`Modal: ā Detected extension at ${location.name} (${obj.constructor?.name})`); } else if (obj) { console.log(`Modal: ā Filtered out ${location.name} (${obj.constructor?.name})`); } } catch (e) { // Location doesn't exist or can't be accessed console.log(`Modal: ${location.name} not accessible:`, e.message); } } // Also check window.nostr but be extra careful to avoid our library console.log('Modal: Checking window.nostr:', !!window.nostr, window.nostr?.constructor?.name); if (window.nostr && this._isRealExtension(window.nostr) && !seenExtensions.has(window.nostr)) { extensions.push({ name: 'window.nostr', displayName: 'Extension (window.nostr)', icon: 'š', extension: window.nostr }); seenExtensions.add(window.nostr); console.log(`Modal: ā Detected extension at window.nostr: ${window.nostr.constructor?.name}`); } else if (window.nostr) { console.log(`Modal: ā Filtered out window.nostr (${window.nostr.constructor?.name}) - likely our library`); } return extensions; } _isRealExtension(obj) { console.log(`Modal: EXTENSIVE DEBUG - _isRealExtension called with:`, obj); console.log(`Modal: Object type: ${typeof obj}`); console.log(`Modal: Object truthy: ${!!obj}`); if (!obj || typeof obj !== 'object') { console.log(`Modal: REJECT - Not an object`); return false; } console.log(`Modal: getPublicKey type: ${typeof obj.getPublicKey}`); console.log(`Modal: signEvent type: ${typeof obj.signEvent}`); // Must have required Nostr methods if (typeof obj.getPublicKey !== 'function' || typeof obj.signEvent !== 'function') { console.log(`Modal: REJECT - Missing required methods`); return false; } // Exclude NostrTools library object if (obj === window.NostrTools) { console.log(`Modal: REJECT - Is NostrTools object`); return false; } // Use the EXACT SAME logic as the comprehensive test (lines 804-809) // This is the key fix - match the comprehensive test's successful detection logic const constructorName = obj.constructor?.name; const objectKeys = Object.keys(obj); console.log(`Modal: Constructor name: "${constructorName}"`); console.log(`Modal: Object keys: [${objectKeys.join(', ')}]`); // COMPREHENSIVE TEST LOGIC - Accept anything with required methods that's not our specific library classes const isRealExtension = ( typeof obj.getPublicKey === 'function' && typeof obj.signEvent === 'function' && constructorName !== 'WindowNostr' && // Our library class constructorName !== 'NostrLite' // Our main class ); console.log(`Modal: Using comprehensive test logic:`); console.log(` Has getPublicKey: ${typeof obj.getPublicKey === 'function'}`); console.log(` Has signEvent: ${typeof obj.signEvent === 'function'}`); console.log(` Not WindowNostr: ${constructorName !== 'WindowNostr'}`); console.log(` Not NostrLite: ${constructorName !== 'NostrLite'}`); console.log(` Constructor: "${constructorName}"`); // Additional debugging for comparison const extensionPropChecks = { _isEnabled: !!obj._isEnabled, enabled: !!obj.enabled, kind: !!obj.kind, _eventEmitter: !!obj._eventEmitter, _scope: !!obj._scope, _requests: !!obj._requests, _pubkey: !!obj._pubkey, name: !!obj.name, version: !!obj.version, description: !!obj.description }; console.log(`Modal: Extension property analysis:`, extensionPropChecks); const hasExtensionProps = !!( obj._isEnabled || obj.enabled || obj.kind || obj._eventEmitter || obj._scope || obj._requests || obj._pubkey || obj.name || obj.version || obj.description ); const underscoreKeys = objectKeys.filter(key => key.startsWith('_')); const hexToUint8Keys = objectKeys.filter(key => key.startsWith('_hex')); console.log(`Modal: Underscore keys: [${underscoreKeys.join(', ')}]`); console.log(`Modal: _hex* keys: [${hexToUint8Keys.join(', ')}]`); console.log(`Modal: Additional analysis:`); console.log(` hasExtensionProps: ${hasExtensionProps}`); console.log(` hasLibraryMethod (_hexToUint8Array): ${objectKeys.includes('_hexToUint8Array')}`); console.log(`Modal: COMPREHENSIVE TEST LOGIC RESULT: ${isRealExtension ? 'ACCEPT' : 'REJECT'}`); console.log(`Modal: FINAL DECISION for ${constructorName}: ${isRealExtension ? 'ACCEPT' : 'REJECT'}`); return isRealExtension; } _showExtensionChoice(extensions) { this.modalBody.innerHTML = ''; const title = document.createElement('h3'); title.textContent = 'Choose Browser Extension'; title.style.cssText = ` margin: 0 0 16px 0; font-size: 18px; font-weight: 600; color: var(--nl-primary-color); font-family: var(--nl-font-family, 'Courier New', monospace); `; const description = document.createElement('p'); description.textContent = `Found ${extensions.length} Nostr extensions. Choose which one to use:`; description.style.cssText = ` margin-bottom: 20px; color: #666666; font-size: 14px; font-family: var(--nl-font-family, 'Courier New', monospace); `; this.modalBody.appendChild(title); this.modalBody.appendChild(description); // Create button for each extension extensions.forEach((ext, index) => { const button = document.createElement('button'); button.onclick = () => this._tryExtensionLogin(ext.extension); button.style.cssText = ` display: flex; align-items: center; width: 100%; padding: 16px; margin-bottom: 12px; background: var(--nl-secondary-color); color: var(--nl-primary-color); border: var(--nl-border-width) solid var(--nl-primary-color); border-radius: var(--nl-border-radius); cursor: pointer; transition: all 0.2s; text-align: left; font-family: var(--nl-font-family, 'Courier New', monospace); `; button.onmouseover = () => { button.style.borderColor = 'var(--nl-accent-color)'; button.style.background = 'var(--nl-secondary-color)'; }; button.onmouseout = () => { button.style.borderColor = 'var(--nl-primary-color)'; button.style.background = 'var(--nl-secondary-color)'; }; const iconDiv = document.createElement('div'); iconDiv.textContent = ext.icon; iconDiv.style.cssText = ` font-size: 24px; margin-right: 16px; width: 24px; text-align: center; `; const contentDiv = document.createElement('div'); contentDiv.style.cssText = 'flex: 1;'; const nameDiv = document.createElement('div'); nameDiv.textContent = ext.displayName; nameDiv.style.cssText = ` font-weight: 600; margin-bottom: 4px; color: var(--nl-primary-color); font-family: var(--nl-font-family, 'Courier New', monospace); `; const pathDiv = document.createElement('div'); pathDiv.textContent = ext.name; pathDiv.style.cssText = ` font-size: 12px; color: #666666; font-family: var(--nl-font-family, 'Courier New', monospace); `; contentDiv.appendChild(nameDiv); contentDiv.appendChild(pathDiv); button.appendChild(iconDiv); button.appendChild(contentDiv); this.modalBody.appendChild(button); }); // Add back button const backButton = document.createElement('button'); backButton.textContent = 'Back to Login Options'; backButton.onclick = () => this._renderLoginOptions(); backButton.style.cssText = this._getButtonStyle('secondary') + 'margin-top: 20px;'; this.modalBody.appendChild(backButton); } async _tryExtensionLogin(extensionObj) { try { // Show loading state this.modalBody.innerHTML = '
${nsec}`;
nsecDiv.style.cssText = 'margin-bottom: 16px; font-size: 14px;';
const npubDiv = document.createElement('div');
npubDiv.innerHTML = `Your Public Key:${window.NostrTools.nip19.npubEncode(pubkey)}`;
npubDiv.style.cssText = 'margin-bottom: 16px; font-size: 14px;';
const continueButton = document.createElement('button');
continueButton.textContent = 'Continue';
continueButton.onclick = () => this._setAuthMethod('local', { secret: nsec, pubkey });
continueButton.style.cssText = this._getButtonStyle();
this.modalBody.appendChild(title);
this.modalBody.appendChild(warningDiv);
this.modalBody.appendChild(nsecDiv);
this.modalBody.appendChild(npubDiv);
this.modalBody.appendChild(continueButton);
}
_setAuthMethod(method, options = {}) {
// Emit auth method selection
const event = new CustomEvent('nlMethodSelected', {
detail: { method, ...options }
});
window.dispatchEvent(event);
this.close();
}
_showError(message) {
this.modalBody.innerHTML = '';
const errorDiv = document.createElement('div');
errorDiv.style.cssText = 'background: #fee2e2; color: #dc2626; padding: 16px; border-radius: 6px; margin-bottom: 16px;';
errorDiv.innerHTML = `Error: ${message}`;
const backButton = document.createElement('button');
backButton.textContent = 'Back';
backButton.onclick = () => this._renderLoginOptions();
backButton.style.cssText = this._getButtonStyle('secondary');
this.modalBody.appendChild(errorDiv);
this.modalBody.appendChild(backButton);
}
_showExtensionRequired() {
this.modalBody.innerHTML = '';
const title = document.createElement('h3');
title.textContent = 'Browser Extension Required';
title.style.cssText = 'margin: 0 0 16px 0; font-size: 18px; font-weight: 600;';
const message = document.createElement('p');
message.textContent = 'Please install a Nostr browser extension like Alby or getflattr and refresh the page.';
message.style.cssText = 'margin-bottom: 20px; color: #6b7280;';
const backButton = document.createElement('button');
backButton.textContent = 'Back';
backButton.onclick = () => this._renderLoginOptions();
backButton.style.cssText = this._getButtonStyle('secondary');
this.modalBody.appendChild(title);
this.modalBody.appendChild(message);
this.modalBody.appendChild(backButton);
}
_showConnectScreen() {
this.modalBody.innerHTML = '';
const title = document.createElement('h3');
title.textContent = 'Connect to NIP-46 Remote Signer';
title.style.cssText = 'margin: 0 0 16px 0; font-size: 18px; font-weight: 600;';
const description = document.createElement('p');
description.textContent = 'Connect to a remote signer (bunker) server to use its keys for signing.';
description.style.cssText = 'margin-bottom: 20px; color: #6b7280; font-size: 14px;';
const formGroup = document.createElement('div');
formGroup.style.cssText = 'margin-bottom: 20px;';
const label = document.createElement('label');
label.textContent = 'Bunker Public Key:';
label.style.cssText = 'display: block; margin-bottom: 8px; font-weight: 500;';
const pubkeyInput = document.createElement('input');
pubkeyInput.type = 'text';
pubkeyInput.placeholder = 'bunker://pubkey?relay=..., bunker:hex, hex, or npub...';
pubkeyInput.style.cssText = `
width: 100%;
padding: 12px;
border: 1px solid #d1d5db;
border-radius: 6px;
margin-bottom: 12px;
font-family: monospace;
box-sizing: border-box;
`;
const urlLabel = document.createElement('label');
urlLabel.textContent = 'Remote URL (optional):';
urlLabel.style.cssText = 'display: block; margin-bottom: 8px; font-weight: 500;';
const urlInput = document.createElement('input');
urlInput.type = 'url';
urlInput.placeholder = 'ws://localhost:8080 (default)';
urlInput.style.cssText = `
width: 100%;
padding: 12px;
border: 1px solid #d1d5db;
border-radius: 6px;
margin-bottom: 16px;
box-sizing: border-box;
`;
// Users will enter the bunker URL manually from their bunker setup
const connectButton = document.createElement('button');
connectButton.textContent = 'Connect to Bunker';
connectButton.onclick = () => this._handleNip46Connect(pubkeyInput.value, urlInput.value);
connectButton.style.cssText = this._getButtonStyle();
const backButton = document.createElement('button');
backButton.textContent = 'Back';
backButton.onclick = () => this._renderLoginOptions();
backButton.style.cssText = this._getButtonStyle('secondary') + 'margin-top: 12px;';
formGroup.appendChild(label);
formGroup.appendChild(pubkeyInput);
formGroup.appendChild(urlLabel);
formGroup.appendChild(urlInput);
this.modalBody.appendChild(title);
this.modalBody.appendChild(description);
this.modalBody.appendChild(formGroup);
this.modalBody.appendChild(connectButton);
this.modalBody.appendChild(backButton);
}
_handleNip46Connect(bunkerPubkey, bunkerUrl) {
if (!bunkerPubkey || !bunkerPubkey.length) {
this._showError('Bunker pubkey is required');
return;
}
this._showNip46Connecting(bunkerPubkey, bunkerUrl);
this._performNip46Connect(bunkerPubkey, bunkerUrl);
}
_showNip46Connecting(bunkerPubkey, bunkerUrl) {
this.modalBody.innerHTML = '';
const title = document.createElement('h3');
title.textContent = 'Connecting to Remote Signer...';
title.style.cssText = 'margin: 0 0 16px 0; font-size: 18px; font-weight: 600; color: #059669;';
const description = document.createElement('p');
description.textContent = 'Establishing secure connection to your remote signer.';
description.style.cssText = 'margin-bottom: 20px; color: #6b7280;';
// Normalize bunker pubkey for display (= show original format if bunker: prefix)
const displayPubkey = bunkerPubkey.startsWith('bunker:') || bunkerPubkey.startsWith('npub') || bunkerPubkey.length === 64 ? bunkerPubkey : bunkerPubkey;
const bunkerInfo = document.createElement('div');
bunkerInfo.style.cssText = 'background: #f1f5f9; padding: 12px; border-radius: 6px; margin-bottom: 20px; font-size: 14px;';
bunkerInfo.innerHTML = `
Connecting to bunker:${displayPubkey}${bunkerUrl || 'ws://localhost:8080'}