0.0.1
This commit is contained in:
466
demo/chat.js
Normal file
466
demo/chat.js
Normal file
@@ -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://relay.logemedia.com'
|
||||
],
|
||||
csPubkey: 'PUBKEY_TO_RECEICE_MESSAGES' // 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 = `
|
||||
<div class="fixed bottom-4 right-4 sm:bottom-6 sm:right-6 z-[99999]">
|
||||
<button onclick="window.openChat()"
|
||||
class="bg-gradient-to-br from-[#fdad01] to-[#ff8c00] hover:from-[#ff8c00] hover:to-[#fdad01] text-white rounded-full p-4 sm:p-5 shadow-2xl transition-all transform hover:scale-110 active:scale-95"
|
||||
aria-label="Open chat"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="sm:w-7 sm:h-7">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Full chat box rendering with mobile responsiveness
|
||||
container.innerHTML = `
|
||||
<div class="fixed inset-4 sm:inset-auto sm:bottom-6 sm:right-6 z-[99999]">
|
||||
<div class="bg-white rounded-2xl shadow-2xl w-full h-full sm:w-96 sm:h-[600px] max-w-full flex flex-col overflow-hidden">
|
||||
<!-- Header -->
|
||||
<div class="bg-gradient-to-br from-[#fdad01] to-[#ff8c00] text-white p-4 sm:p-5">
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="font-bold text-base sm:text-lg">Instant Chat</h3>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<div class="w-2 h-2 rounded-full flex-shrink-0 ${state.connected ? 'bg-green-400 animate-pulse' : 'bg-red-400'}"></div>
|
||||
<span class="text-xs text-orange-100 truncate">
|
||||
${state.connected ? `P2P E2EE • ${state.relays.length} relays` : 'Connecting...'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onclick="window.closeChat()"
|
||||
class="hover:bg-white/20 p-2 rounded-lg transition-colors ml-2 flex-shrink-0"
|
||||
aria-label="Close chat"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Messages -->
|
||||
<div id="messages" class="flex-1 overflow-y-auto p-3 sm:p-4 space-y-3 bg-gradient-to-b from-gray-50 to-white">
|
||||
${state.messages.length === 0 ? `
|
||||
<div class="text-center text-gray-400 mt-8">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" class="mx-auto mb-3 opacity-50">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
|
||||
</svg>
|
||||
<p class="text-sm">Start a conversation</p>
|
||||
</div>
|
||||
` : state.messages.map(msg => {
|
||||
if (msg.sender === 'system') {
|
||||
return `
|
||||
<div class="flex justify-center">
|
||||
<div class="bg-orange-50 text-orange-700 text-xs px-3 py-2 rounded-full border border-orange-200">
|
||||
${escapeHtml(msg.text)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} else if (msg.sender === 'user') {
|
||||
return `
|
||||
<div class="flex justify-end">
|
||||
<div class="max-w-[85%] sm:max-w-xs">
|
||||
<div class="bg-gradient-to-br from-[#fdad01] to-[#ff8c00] text-white rounded-2xl rounded-tr-sm px-3 py-2 sm:px-4 sm:py-3 shadow-md text-sm sm:text-base">
|
||||
${escapeHtml(msg.text)}
|
||||
</div>
|
||||
<div class="text-xs text-gray-400 mt-1 text-right">${formatTime(msg.timestamp)}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} else if (msg.sender === 'cs') {
|
||||
return `
|
||||
<div class="flex justify-start">
|
||||
<div class="max-w-[85%] sm:max-w-xs">
|
||||
<div class="bg-white border border-gray-200 text-gray-800 rounded-2xl rounded-tl-sm px-3 py-2 sm:px-4 sm:py-3 shadow-md text-sm sm:text-base">
|
||||
<div class="text-xs font-semibold text-[#fdad01] mb-1">Loge Media Team</div>
|
||||
${escapeHtml(msg.text)}
|
||||
</div>
|
||||
<div class="text-xs text-gray-400 mt-1">${formatTime(msg.timestamp)}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
return '';
|
||||
}).join('')}
|
||||
</div>
|
||||
|
||||
<!-- Input -->
|
||||
<div class="border-t bg-white p-3 sm:p-4 safe-area-bottom">
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
id="message-input"
|
||||
type="text"
|
||||
value="${escapeHtml(state.inputMessage)}"
|
||||
placeholder="Type your message..."
|
||||
class="flex-1 px-3 py-2 sm:px-4 sm:py-3 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-[#fdad01] text-sm sm:text-base"
|
||||
${!state.connected ? 'disabled' : ''}
|
||||
>
|
||||
<button
|
||||
onclick="window.sendMessage()"
|
||||
${!state.connected || !state.inputMessage.trim() ? 'disabled' : ''}
|
||||
class="bg-gradient-to-br from-[#fdad01] to-[#ff8c00] hover:from-[#ff8c00] hover:to-[#fdad01] disabled:from-gray-400 disabled:to-gray-400 text-white px-4 py-2 sm:px-5 sm:py-3 rounded-xl transition-all disabled:cursor-not-allowed active:scale-95 flex-shrink-0"
|
||||
aria-label="Send message"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="22" y1="2" x2="11" y2="13"></line>
|
||||
<polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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();
|
||||
@@ -98,7 +98,7 @@
|
||||
<!-- GitHub Link -->
|
||||
<section class="text-center">
|
||||
<h2 class="text-2xl font-bold mb-4 text-gray-800">Ready to get started?</h2>
|
||||
<a href="https://github.com/yourusername/nostr-chat-widget"
|
||||
<a href="https://github.com/btcforplebs/nostr-chat-widget"
|
||||
class="inline-flex items-center gap-2 bg-gray-900 text-white px-8 py-4 rounded-xl font-semibold hover:bg-gray-800 transition">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
|
||||
@@ -117,11 +117,13 @@
|
||||
</footer>
|
||||
|
||||
<!-- Chat Widget -->
|
||||
<!-- Add the chat widget -->
|
||||
<!-- Add the chat widget python-->
|
||||
<script type="module" src="/chat.js"></script>
|
||||
|
||||
<!-- Add the chat widget node-->
|
||||
<script type="module" src="../src/chat.js"></script>
|
||||
|
||||
<!-- Add chat widget container -->
|
||||
<div id="chat-widget-root"></div>
|
||||
<div id="chat-widget-root"></div>
|
||||
</body>
|
||||
</html>
|
||||
24
readme.md
24
readme.md
@@ -6,7 +6,7 @@ This repository contains a lightweight demo that shows how to embed a Nostr chat
|
||||
|
||||
1. **Clone the repository**
|
||||
```bash
|
||||
git clone https://github.com/your-username/nostr-web-chat-plugin.git
|
||||
git clone https://github.com/btcforplebs/nostr-web-chat-plugin.git
|
||||
```
|
||||
|
||||
2. **Start the demo server** (works on macOS, Linux, Windows – Node 18+ is required)
|
||||
@@ -15,20 +15,24 @@ This repository contains a lightweight demo that shows how to embed a Nostr chat
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
The server will start at [http://localhost:5173](http://localhost:5173).
|
||||
The server will start the demo at [http://localhost:4000](http://localhost:4000/demo).
|
||||
|
||||
3. **Copy the widget snippet** – add this to the place in your HTML where you want the chat box to appear:
|
||||
## Website Embed (Node.js)
|
||||
|
||||
1. **copy chat.JS to nodeJS project**
|
||||
|
||||
/src/chat.js move to website /src folder
|
||||
|
||||
2. **Copy the widget snippet** – add this to the place in your HTML where you want the chat box to appear:
|
||||
|
||||
```html
|
||||
<div id="nostr-chat"></div>
|
||||
<script type="module" src="https://cdn.jsdelivr.net/npm/@nostrweb/chat-plugin@1.0.0/lib/index.js"></script>
|
||||
<script>
|
||||
const chat = new NostrChat({ target: '#nostr-chat', relay: 'wss://nostr.oxal.org' });
|
||||
</script>
|
||||
<!-- Add the chat widget node-->
|
||||
<script type="module" src="../src/chat.js"></script>
|
||||
|
||||
<!-- Add chat widget container -->
|
||||
<div id="chat-widget-root"></div>
|
||||
```
|
||||
|
||||
- `#nostr-chat` is the DOM element that will contain the widget.
|
||||
- Replace `relay` with your preferred Nostr relay if you wish.
|
||||
|
||||
4. **Open your page** in any browser. The chat widget should load automatically and allow users to send/receive messages.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user