diff --git a/package.json b/package.json index b9451fb..390bc40 100644 --- a/package.json +++ b/package.json @@ -22,10 +22,15 @@ }, "homepage": "https://github.com/btcforplebs/BTCforPlebs.com#readme", "dependencies": { + "@noble/secp256k1": "^3.0.0", "config": "^4.1.1", "cors": "^2.8.5", "express": "^5.1.0", - "node-fetch": "^2.7.0" + "lucide-react": "^0.544.0", + "node-fetch": "^3.3.2", + "nostr-tools": "^2.17.0", + "socket.io": "^4.8.1", + "ws": "^8.18.3" }, "devDependencies": { "concurrently": "^9.2.1" diff --git a/public/assets/css/main.css b/public/assets/css/main.css index 536814a..0491b21 100644 --- a/public/assets/css/main.css +++ b/public/assets/css/main.css @@ -332,6 +332,36 @@ small { transform: scale(1.1); /* Slight zoom on hover */ } + #chat-window { + bottom: 0 !important; + right: 0 !important; + width: 100vw !important; + height: 100vh !important; + border-radius: 0 !important; + max-width: 100% !important; + } + + #chat-header { + border-radius: 0 !important; + } + + #chat-input { + border-radius: 0 0 0 0 !important; + } + + #chat-bubble { + bottom: 16px !important; + right: 16px !important; + width: 50px; + height: 50px; + } + /* Safe area for mobile devices with notches */ +.safe-area-bottom { + padding-bottom: env(safe-area-inset-bottom); +} + + + html { scroll-behavior: smooth; } @@ -385,4 +415,199 @@ footer { font-size: 12px; margin-top: 20px; color: var(--text-color); -} \ No newline at end of file +} + +/* Nostr Chat Widget Styles */ + +/* Widget positioning and z-index management */ +#chat-widget-root > div { + pointer-events: auto !important; + position: fixed !important; + bottom: 1.5rem !important; + right: 1.5rem !important; + z-index: 99999 !important; +} + +/* Chat bubble button */ +#chat-bubble { + position: fixed !important; + bottom: 24px !important; + right: 24px !important; + width: 60px; + height: 60px; + background: #fdad01; + border: 2px solid #fdad01; + border-radius: 50%; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + transition: transform 0.2s; + z-index: 999999 !important; +} + +#chat-bubble:hover { + transform: scale(1.1); +} + +#chat-bubble.hidden { + display: none !important; +} + +/* Chat window container */ +#chat-window { + position: fixed !important; + bottom: 24px !important; + right: 24px !important; + width: 380px; + height: 600px; + background: white; + border: 2px solid #fdad01; + border-radius: 12px; + display: none; + flex-direction: column; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2); + z-index: 999999 !important; +} + +#chat-window.open { + display: flex !important; +} + +/* Chat header */ +#chat-header { + background: linear-gradient(to right, #fdad01, #ff9500); + color: white; + padding: 16px; + border-radius: 10px 10px 0 0; + display: flex; + justify-content: space-between; + align-items: center; +} + +#status { + font-size: 12px; + opacity: 0.9; + margin-top: 4px; +} + +#close-btn { + background: rgba(255, 255, 255, 0.2); + border: none; + color: white; + width: 32px; + height: 32px; + border-radius: 6px; + cursor: pointer; + font-size: 18px; +} + +/* Messages container */ +#messages { + flex: 1; + overflow-y: auto; + padding: 16px; + background: #f9fafb; +} + +/* Message bubbles */ +.message { + margin-bottom: 12px; + display: flex; +} + +.message.user { + justify-content: flex-end; +} + +.message.cs, +.message.system { + justify-content: flex-start; +} + +.message-bubble { + max-width: 75%; + padding: 10px 14px; + border-radius: 12px; + font-size: 14px; +} + +.message.user .message-bubble { + background: #fdad01; + color: white; +} + +.message.cs .message-bubble { + background: white; + border: 1px solid #e5e7eb; + color: #1f2937; +} + +.message.system .message-bubble { + background: #dbeafe; + color: #1e40af; + font-size: 13px; +} + +.message-time { + font-size: 11px; + opacity: 0.7; + margin-top: 4px; +} + +/* Input area */ +#chat-input { + padding: 16px; + border-top: 1px solid #e5e7eb; + background: white; + border-radius: 0 0 10px 10px; +} + +#input-container { + display: flex; + gap: 8px; +} + +#message-input { + flex: 1; + padding: 10px 14px; + border: 1px solid #d1d5db; + border-radius: 8px; + font-size: 14px; + outline: none; +} + +#message-input:focus { + border-color: #fdad01; +} + +#send-btn { + background: #fdad01; + color: white; + border: none; + padding: 10px 20px; + border-radius: 8px; + cursor: pointer; + font-weight: 600; +} + +#send-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Connecting animation */ +.connecting { + animation: pulse 1.5s ease-in-out infinite; +} + +@keyframes pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} diff --git a/public/assets/js/chat.js b/public/assets/js/chat.js new file mode 100644 index 0000000..6c789dd --- /dev/null +++ b/public/assets/js/chat.js @@ -0,0 +1,466 @@ + + import { + relayInit, + generatePrivateKey, + getPublicKey, + getEventHash, + signEvent, + nip19, + nip04 + } from 'nostr-tools'; + + // CONFIGURATION - Only need CS team's pubkey! + const CONFIG = { + relays: [ + 'wss://relay.damus.io', + 'wss://relay.primal.net', + 'wss://nos.lol', + 'wss://relay.btcforplebs.com', + 'wss://haven.btcforplebs.com' + ], + csPubkey: '75462f4dece4fbde54a535cfa09eb0d329bda090a9c2f9ed6b5f9d1d2fb6c15b' // Replace with actual pubkey + }; + + // State + let state = { + isOpen: false, + messages: [], + inputMessage: '', + myPrivKey: null, + myPubKey: null, + relays: [], + connected: false, + sessionId: null + }; + + // Generate or retrieve session key from localStorage + function getSessionKey() { + const stored = localStorage.getItem('nostr_chat_session'); + if (stored) { + try { + const session = JSON.parse(stored); + // Reuse if less than 24 hours old + if (Date.now() - session.created < 24 * 60 * 60 * 1000) { + return session.privKey; + } + } catch (e) {} + } + + // Generate new ephemeral key + const privKey = generatePrivateKey(); + localStorage.setItem('nostr_chat_session', JSON.stringify({ + privKey, + created: Date.now() + })); + return privKey; + } + + // Initialize + async function init() { + // Get or create session key + state.myPrivKey = getSessionKey(); + state.myPubKey = getPublicKey(state.myPrivKey); + state.sessionId = state.myPubKey.substring(0, 8); + + console.log('🔑 Session Identity:', nip19.npubEncode(state.myPubKey)); + + // Connect to relays + const relayPromises = CONFIG.relays.map(async (url) => { + try { + const relay = relayInit(url); + + relay.on('connect', () => { + console.log(`✓ Connected to ${url}`); + checkConnection(); + }); + + relay.on('disconnect', () => { + console.log(`✗ Disconnected from ${url}`); + }); + + await relay.connect(); + return relay; + } catch (error) { + console.error(`Failed: ${url}:`, error); + return null; + } + }); + + state.relays = (await Promise.all(relayPromises)).filter(r => r !== null); + + if (state.relays.length === 0) { + addMessage('system', '⚠️ Failed to connect to any relays'); + return; + } + + console.log(`✓ Connected to ${state.relays.length}/${CONFIG.relays.length} relays`); + + // Subscribe to replies from CS team + subscribeToReplies(); + + // Load any previous messages from this session + loadPreviousMessages(); + + state.connected = true; + render(); + } + + function checkConnection() { + const connected = state.relays.some(r => r.status === 1); // 1 = connected + state.connected = connected; + render(); + } + + // Subscribe to DMs from CS team + function subscribeToReplies() { + const filters = [{ + kinds: [4], + '#p': [state.myPubKey], + authors: [CONFIG.csPubkey], + since: Math.floor(Date.now() / 1000) - 86400 // Last 24 hours + }]; + + console.log('🔔 Subscribing to CS team replies...'); + + state.relays.forEach(relay => { + const sub = relay.sub(filters); + + sub.on('event', (event) => { + handleIncomingMessage(event); + }); + + sub.on('eose', () => { + console.log(`✓ Subscribed: ${relay.url}`); + }); + }); + } + + // Load previous messages from localStorage + 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) {} + } + } + + // Save messages to localStorage + function saveMessages() { + localStorage.setItem(`nostr_chat_messages_${state.sessionId}`, JSON.stringify(state.messages)); + } + + // Handle incoming DM from CS team + async function handleIncomingMessage(event) { + try { + // Check for duplicates + if (state.messages.find(m => m.id === event.id)) { + return; + } + + console.log('📨 Received message from CS team'); + + // Decrypt + 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); + + // Notification + if (!document.hasFocus()) { + document.title = '💬 New message!'; + setTimeout(() => { + document.title = 'Nostr Support Chat'; + }, 3000); + } + } catch (error) { + console.error('Error decrypting message:', error); + } + } + + // Send message to CS team + async function sendMessage() { + if (!state.inputMessage.trim()) return; + + const messageText = state.inputMessage; + + try { + console.log('🔐 Encrypting and sending...'); + + // Encrypt + const encrypted = await nip04.encrypt( + state.myPrivKey, + CONFIG.csPubkey, + messageText + ); + + // Create event + 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); + + // Publish to all relays + let published = 0; + for (const relay of state.relays) { + try { + await relay.publish(event); + published++; + console.log(`✓ Published to ${relay.url}`); + } catch (err) { + console.error(`✗ Failed: ${relay.url}:`, err); + } + } + + if (published === 0) { + addMessage('system', '⚠️ Failed to send - no relay connections'); + return; + } + + console.log(`✓ Published to ${published}/${state.relays.length} relays`); + + // Add to local messages + const message = { + id: event.id, + text: messageText, + sender: 'user', + timestamp: new Date().toISOString() + }; + + addMessage('user', messageText, message); + state.inputMessage = ''; + render(); + + } catch (error) { + console.error('Error sending:', error); + addMessage('system', '⚠️ Failed to send message'); + } + } + + 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('messages'); + if (container) { + 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 + }); + } + +// Render function with mobile responsiveness +function render() { + const container = document.getElementById('chat-widget-root'); + + if (!container) return; + + if (!state.isOpen) { + container.innerHTML = ` +
Start a conversation
+