/** * Nostr Chat Widget - Embeddable Version (Glassmorphism Design) * * EMBED IT WITH: * */ (function () { 'use strict'; // Get configuration from script tag const scriptTag = document.currentScript; const csPubkey = scriptTag.getAttribute('data-nostr-pubkey') || 'PUBKEY_TO_RECEICE_MESSAGES'; const brandName = scriptTag.getAttribute('data-brand-name') || 'Support Team Messaging'; const primaryColor = scriptTag.getAttribute('data-color') || '#fdad01'; const secondaryColor = scriptTag.getAttribute('data-color-secondary') || '#000000'; // Default relay configuration const DEFAULT_RELAYS = [ 'wss://relay.damus.io', 'wss://relay.primal.net', 'wss://nos.lol', 'wss://relay.btcforplebs.com' ]; // Add viewport meta tag for mobile optimization let viewportMeta = document.querySelector('meta[name="viewport"]'); if (!viewportMeta) { viewportMeta = document.createElement('meta'); viewportMeta.name = 'viewport'; document.head.appendChild(viewportMeta); } viewportMeta.content = 'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover'; // Inject custom styles with glassmorphism const style = document.createElement('style'); style.textContent = ` :root { --nc-primary: ${primaryColor}; --nc-secondary: ${secondaryColor}; --nc-bg-glass: rgba(255, 255, 255, 0.08); --nc-bg-glass-light: rgba(255, 255, 255, 0.03); --nc-border-glass: rgba(255, 255, 255, 0.15); --nc-text-primary: #ffffff; --nc-text-secondary: rgba(255, 255, 255, 0.8); --nc-text-muted: rgba(255, 255, 255, 0.6); } .nc-root { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; line-height: 1.5; } .nc-root * { box-sizing: border-box; } .nc-launcher { position: fixed; bottom: 1rem; right: 1rem; z-index: 99999; } .nc-launcher-btn { background: linear-gradient(to bottom right, var(--nc-primary), var(--nc-secondary)); color: white; border-radius: 9999px; padding: 1rem; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); transition: all 0.3s ease; transform: scale(1); border: none; cursor: pointer; display: flex; align-items: center; justify-content: center; } .nc-launcher-btn:hover { opacity: 0.9; transform: scale(1.1); } .nc-launcher-btn:active { transform: scale(0.95); } .nc-window-wrapper { position: fixed; inset: 0; z-index: 99999; pointer-events: none; } .nc-window { pointer-events: auto; background: var(--nc-bg-glass); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); border: 1px solid var(--nc-border-glass); box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); display: flex; flex-direction: column; overflow: hidden; width: 100%; height: 100%; } .nc-header { background: linear-gradient(to bottom right, var(--nc-primary), var(--nc-secondary)); color: white; padding: 0.875rem 1rem; flex-shrink: 0; } .nc-header-content { display: flex; justify-content: space-between; align-items: center; } .nc-header-info { flex: 1; min-width: 0; } .nc-title { font-weight: 700; font-size: 1rem; margin: 0; } .nc-status { display: flex; align-items: center; gap: 0.5rem; margin-top: 0.125rem; } .nc-status-dot { width: 0.5rem; height: 0.5rem; border-radius: 9999px; flex-shrink: 0; } .nc-status-dot.connected { background-color: #4ade80; animation: nc-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; } .nc-status-dot.disconnected { background-color: #f87171; } .nc-status-text { font-size: 0.75rem; color: var(--nc-text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .nc-close-btn { background: transparent; border: none; color: white; padding: 0.5rem; border-radius: 0.5rem; cursor: pointer; transition: background-color 0.2s; display: flex; align-items: center; justify-content: center; } .nc-close-btn:hover { background-color: rgba(255, 255, 255, 0.2); } .nc-messages { flex: 1; overflow-y: auto; padding: 0.75rem; background: var(--nc-bg-glass-light); display: flex; flex-direction: column; gap: 0.75rem; -webkit-overflow-scrolling: touch; overscroll-behavior: contain; } .nc-empty-state { text-align: center; color: var(--nc-text-muted); margin-top: 2rem; } .nc-msg-row { display: flex; width: 100%; } .nc-msg-row.system { justify-content: center; } .nc-msg-row.user { justify-content: flex-end; } .nc-msg-row.cs { justify-content: flex-start; } .nc-msg-system { background-color: rgba(255, 237, 213, 0.4); backdrop-filter: blur(4px); color: #9a3412; font-size: 0.75rem; padding: 0.5rem 0.75rem; border-radius: 9999px; border: 1px solid rgba(253, 186, 116, 0.5); } .nc-msg-bubble-wrapper { max-width: 85%; } .nc-msg-bubble { padding: 0.5rem 0.75rem; border-radius: 1rem; color: white; font-size: 0.875rem; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); word-break: break-word; } .nc-msg-bubble.user { background: linear-gradient(to bottom right, var(--nc-primary), var(--nc-secondary)); border-top-right-radius: 0.125rem; } .nc-msg-bubble.cs { background: linear-gradient(to bottom right, #9ca3af, #6b7280); border-top-left-radius: 0.125rem; } .nc-msg-time { font-size: 0.75rem; color: var(--nc-text-muted); margin-top: 0.25rem; } .nc-msg-time.right { text-align: right; } .nc-input-area { padding: 0.75rem; padding-bottom: max(env(safe-area-inset-bottom), 1.5rem); background: transparent; backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); position: sticky; bottom: 0; z-index: 10; } .nc-input-wrapper { background: rgba(255, 255, 255, 0.2); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); border: 1px solid rgba(255, 255, 255, 0.3); border-radius: 0.75rem; padding: 0.5rem; display: flex; gap: 0.5rem; align-items: center; } .nc-input-wrapper:focus-within { background: rgba(255, 255, 255, 0.25); border-color: rgba(255, 255, 255, 0.4); } .nc-input-field { flex: 1; background: transparent; border: none; color: white; padding: 0.375rem 0.5rem; font-size: 0.875rem; outline: none; } .nc-input-field::placeholder { color: var(--nc-text-muted); } .nc-send-btn { background: linear-gradient(to bottom right, var(--nc-primary), var(--nc-secondary)); color: white; border: none; padding: 0.5rem; border-radius: 0.5rem; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: opacity 0.2s; } .nc-send-btn:hover:not(:disabled) { opacity: 0.9; } .nc-send-btn:disabled { opacity: 0.4; cursor: not-allowed; } @keyframes nc-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } } /* Desktop Overrides */ @media (min-width: 640px) { .nc-launcher { bottom: 1.5rem; right: 1.5rem; } .nc-launcher-btn { padding: 1.25rem; } .nc-window-wrapper { inset: auto; bottom: 1.5rem; right: 1.5rem; } .nc-window { width: 24rem; height: 600px; max-height: calc(100vh - 3rem); border-radius: 1rem; } .nc-header { padding: 1rem; } .nc-title { font-size: 1.125rem; } .nc-messages { padding: 0.875rem; } .nc-msg-bubble-wrapper { max-width: 20rem; } .nc-msg-bubble { padding: 0.75rem 1rem; font-size: 1rem; } .nc-input-area { padding: 0.875rem; padding-bottom: 0.875rem; } .nc-input-field { font-size: 1rem; } } `; document.head.appendChild(style); // Create widget container const widgetRoot = document.createElement('div'); widgetRoot.id = 'nostr-chat-widget-root'; document.body.appendChild(widgetRoot); // Import map for nostr-tools const importMap = document.createElement('script'); importMap.type = 'importmap'; importMap.textContent = JSON.stringify({ imports: { 'nostr-tools': 'https://esm.sh/nostr-tools@1.17.0' } }); document.head.appendChild(importMap); // Main widget script const widgetScript = document.createElement('script'); widgetScript.type = 'module'; widgetScript.textContent = ` import { relayInit, generatePrivateKey, getPublicKey, getEventHash, signEvent, nip19, nip04 } from 'nostr-tools'; const CONFIG = { relays: ${JSON.stringify(DEFAULT_RELAYS)}, csPubkey: '${csPubkey}', brandName: '${brandName}', primaryColor: '${primaryColor}', secondaryColor: '${secondaryColor}' }; let state = { isOpen: false, messages: [], inputMessage: '', myPrivKey: null, myPubKey: null, relays: [], connected: false, sessionId: null }; function getSessionKey() { const stored = localStorage.getItem('nostr_chat_session'); if (stored) { try { const session = JSON.parse(stored); if (Date.now() - session.created < 24 * 60 * 60 * 1000) { return session.privKey; } } catch (e) {} } const privKey = generatePrivateKey(); localStorage.setItem('nostr_chat_session', JSON.stringify({ privKey, created: Date.now() })); return privKey; } async function init() { // Check for crypto.subtle availability (requires HTTPS) if (!window.crypto || !window.crypto.subtle) { state.connected = false; addMessage('system', '⚠️ Secure connection required. Please use HTTPS.'); console.error('crypto.subtle not available. Page must be served over HTTPS.'); render(); return; } state.myPrivKey = getSessionKey(); state.myPubKey = getPublicKey(state.myPrivKey); state.sessionId = state.myPubKey.substring(0, 8); console.log('🔑 Session Identity:', nip19.npubEncode(state.myPubKey)); console.log('📱 User Agent:', navigator.userAgent); console.log('🌐 Connecting to relays...'); const relayPromises = CONFIG.relays.map(async (url) => { try { console.log(\`Attempting: \${url}\`); const relay = relayInit(url); relay.on('connect', () => { console.log(\`✓ Connected to \${url}\`); checkConnection(); }); relay.on('disconnect', () => { console.log(\`✗ Disconnected from \${url}\`); checkConnection(); }); relay.on('error', (err) => { console.error(\`❌ Relay error \${url}:\`, err); }); await relay.connect(); return relay; } catch (error) { console.error(\`Failed: \${url}:\`, error); return null; } }); state.relays = (await Promise.all(relayPromises)).filter(r => r !== null); console.log(\`✓ Connected to \${state.relays.length}/\${CONFIG.relays.length} relays\`); if (state.relays.length === 0) { addMessage('system', '⚠️ Failed to connect to any relays. Check console for details.'); return; } subscribeToReplies(); loadPreviousMessages(); state.connected = true; render(); } function checkConnection() { const connected = state.relays.some(r => r.status === 1); state.connected = connected; render(); } function subscribeToReplies() { const filters = [{ kinds: [4], '#p': [state.myPubKey], authors: [CONFIG.csPubkey], since: Math.floor(Date.now() / 1000) - 86400 }]; console.log('🔔 Subscribing to replies...'); state.relays.forEach(relay => { const sub = relay.sub(filters); sub.on('event', (event) => { handleIncomingMessage(event); }); sub.on('eose', () => { console.log(\`✓ Subscribed: \${relay.url}\`); }); }); } function loadPreviousMessages() { const stored = localStorage.getItem(\`nostr_chat_messages_\${state.sessionId}\`); if (stored) { try { const messages = JSON.parse(stored); messages.forEach(msg => state.messages.push(msg)); render(); } catch (e) {} } } function saveMessages() { localStorage.setItem(\`nostr_chat_messages_\${state.sessionId}\`, JSON.stringify(state.messages)); } async function handleIncomingMessage(event) { try { if (state.messages.find(m => m.id === event.id)) { return; } console.log('📨 Received message'); const decryptedText = await nip04.decrypt( state.myPrivKey, event.pubkey, event.content ); const message = { id: event.id, text: decryptedText, sender: 'cs', timestamp: new Date(event.created_at * 1000).toISOString() }; addMessage('cs', decryptedText, message); if (!document.hasFocus()) { const originalTitle = document.title; document.title = '💬 New message!'; setTimeout(() => { document.title = originalTitle; }, 3000); } } catch (error) { console.error('Error decrypting message:', error); } } async function sendMessage() { if (!state.inputMessage.trim()) return; if (!state.connected || state.relays.length === 0) { addMessage('system', '⚠️ Not connected to relays. Please wait...'); return; } const messageText = state.inputMessage; state.inputMessage = ''; // Show message immediately (optimistic UI) const tempMessage = { id: 'temp_' + Date.now(), text: messageText, sender: 'user', timestamp: new Date().toISOString() }; state.messages.push(tempMessage); render(); scrollToBottom(); try { console.log('🔐 Encrypting and sending...'); const encrypted = await nip04.encrypt( state.myPrivKey, CONFIG.csPubkey, messageText ); let event = { kind: 4, created_at: Math.floor(Date.now() / 1000), tags: [['p', CONFIG.csPubkey]], content: encrypted, pubkey: state.myPubKey }; event.id = getEventHash(event); event.sig = signEvent(event, state.myPrivKey); let published = 0; const publishPromises = state.relays.map(async (relay) => { try { await relay.publish(event); published++; console.log(\`✓ Published to \${relay.url}\`); return true; } catch (err) { console.error(\`✗ Failed: \${relay.url}:\`, err); return false; } }); await Promise.all(publishPromises); if (published === 0) { // Remove the temp message const msgIndex = state.messages.findIndex(m => m.id === tempMessage.id); if (msgIndex !== -1) { state.messages.splice(msgIndex, 1); } addMessage('system', '⚠️ Failed to send - no relay connections'); return; } console.log(\`✓ Published to \${published}/\${state.relays.length} relays\`); // Update temp message with real ID const msgIndex = state.messages.findIndex(m => m.id === tempMessage.id); if (msgIndex !== -1) { state.messages[msgIndex].id = event.id; } saveMessages(); } catch (error) { console.error('Error sending:', error); // Remove the temp message on error const msgIndex = state.messages.findIndex(m => m.id === tempMessage.id); if (msgIndex !== -1) { state.messages.splice(msgIndex, 1); } addMessage('system', '⚠️ Failed to send: ' + error.message); render(); } } function addMessage(sender, text, fullMessage = null) { const msg = fullMessage || { id: Date.now().toString(), text, sender, timestamp: new Date().toISOString() }; state.messages.push(msg); saveMessages(); render(); scrollToBottom(); } function scrollToBottom() { setTimeout(() => { const container = document.getElementById('nostr-messages'); if (container) { // Smooth scroll on desktop, instant on mobile for better keyboard handling if (window.innerWidth >= 640) { container.scrollTo({ top: container.scrollHeight, behavior: 'smooth' }); } else { container.scrollTop = container.scrollHeight; } } }, 100); } function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } function formatTime(timestamp) { const date = new Date(timestamp); return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }); } function render() { const container = document.getElementById('nostr-chat-widget-root'); if (!container) return; if (!state.isOpen) { container.innerHTML = \`
\`; return; } container.innerHTML = \`

\${CONFIG.brandName}

\${state.connected ? \`Encrypted • \${state.relays.length} relays\` : 'Connecting...'}
\${state.messages.length === 0 ? \`

Start a conversation

\` : state.messages.map(msg => { if (msg.sender === 'system') { return \`
\${escapeHtml(msg.text)}
\`; } else if (msg.sender === 'user') { return \`
\${escapeHtml(msg.text)}
Sent \${formatTime(msg.timestamp)}
\`; } else if (msg.sender === 'cs') { return \`
\${escapeHtml(msg.text)}
\${formatTime(msg.timestamp)}
\`; } return ''; }).join('')}
\`; const messageInput = document.getElementById('nostr-message-input'); if (messageInput) { messageInput.addEventListener('input', (e) => { state.inputMessage = e.target.value; const sendButton = document.querySelector('button[onclick="window.NostrChat.send()"]'); if (sendButton) { sendButton.disabled = !state.connected || !e.target.value.trim(); } }); messageInput.addEventListener('keypress', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } }); const messagesContainer = document.getElementById('nostr-messages'); if (messagesContainer && state.messages.length > 0) { setTimeout(() => { messagesContainer.scrollTop = messagesContainer.scrollHeight; }, 100); } // Only auto-focus on desktop if (window.innerWidth >= 640) { messageInput.focus(); } } } // Expose global API window.NostrChat = { open: async () => { state.isOpen = true; // Prevent body scroll on mobile if (window.innerWidth < 640) { document.documentElement.classList.add('chat-open'); document.body.classList.add('chat-open'); } render(); if (state.relays.length === 0) { await init(); } }, close: () => { state.isOpen = false; // Restore body scroll on mobile document.documentElement.classList.remove('chat-open'); document.body.classList.remove('chat-open'); render(); }, send: sendMessage }; // Initial render render(); `; document.body.appendChild(widgetScript); })();