From 125b1a6702e91f8d099312f44ba28ee81c922a5f Mon Sep 17 00:00:00 2001 From: BTCforPlebs Date: Thu, 9 Oct 2025 09:10:07 -0400 Subject: [PATCH] Nostr Chat --- package.json | 7 +- public/assets/css/main.css | 227 +++++++++++++++++- public/assets/js/chat.js | 466 +++++++++++++++++++++++++++++++++++++ public/assets/js/config.js | 8 - public/index.html | 15 ++ 5 files changed, 713 insertions(+), 10 deletions(-) create mode 100644 public/assets/js/chat.js 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 = ` +
+ +
+ `; + return; + } + + // Full chat box rendering with mobile responsiveness + container.innerHTML = ` +
+
+ +
+
+
+

Instant Nostr Chat

+
+
+ + ${state.connected ? `P2P E2EE • ${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)} +
+
${formatTime(msg.timestamp)}
+
+
+ `; + } else if (msg.sender === 'cs') { + return ` +
+
+
+
Loge Media Team
+ ${escapeHtml(msg.text)} +
+
${formatTime(msg.timestamp)}
+
+
+ `; + } + return ''; + }).join('')} +
+ + +
+
+ + +
+
+
+
+ `; + + const messageInput = document.getElementById('message-input'); + if (messageInput) { + messageInput.addEventListener('input', (e) => { + state.inputMessage = e.target.value; + const sendButton = document.querySelector('button[onclick="window.sendMessage()"]'); + if (sendButton) { + sendButton.disabled = !state.connected || !e.target.value.trim(); + } + }); + messageInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + sendMessage(); + } + }); + + // Auto-scroll to bottom on mobile + const messagesContainer = document.getElementById('messages'); + if (messagesContainer && state.messages.length > 0) { + setTimeout(() => { + messagesContainer.scrollTop = messagesContainer.scrollHeight; + }, 100); + } + + messageInput.focus(); + } +} + +// Global functions +window.openChat = async () => { + state.isOpen = true; + render(); + if (state.relays.length === 0) { + await init(); + } +}; + +window.closeChat = () => { + state.isOpen = false; + render(); +}; + +window.sendMessage = sendMessage; + +// Initial render +render(); diff --git a/public/assets/js/config.js b/public/assets/js/config.js index 8a14acb..c6d5b28 100644 --- a/public/assets/js/config.js +++ b/public/assets/js/config.js @@ -8,11 +8,3 @@ const config = { // Debug mode - enable logging in development debug: window.location.hostname === 'localhost' }; - -// Set up logging -if (config.debug) { - console.log('Running in development mode'); - console.log('API Base URL:', config.apiBaseUrl); -} else { - console.log = function() {}; // Disable console.log in production -} \ No newline at end of file diff --git a/public/index.html b/public/index.html index 5319db4..db469b3 100644 --- a/public/index.html +++ b/public/index.html @@ -20,6 +20,7 @@ + @@ -75,6 +76,20 @@ 🟢 = online | 🔴 = offline | ⚪️ = external site

+ + + + +
+ + +