Initial commit: Nostr chat widget

This commit is contained in:
2025-10-06 11:32:37 -04:00
commit 4f3403f34e
8 changed files with 1447 additions and 0 deletions

36
.gitignore vendored Normal file
View File

@@ -0,0 +1,36 @@
# Dependencies
node_modules/
package-lock.json
yarn.lock
# Environment variables
.env
.env.local
.env.production
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Logs
logs/
*.log
npm-debug.log*
# Build
dist/
build/
# Testing
coverage/
# Temporary files
tmp/
temp/

226
chat.css Normal file
View File

@@ -0,0 +1,226 @@
/* 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;
}
}
/* Mobile responsive styles */
@media (max-width: 600px) {
#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);
}

343
config_docs.md Normal file
View File

@@ -0,0 +1,343 @@
# Configuration Guide
This guide walks you through configuring the Nostr Chat Widget for your website.
## Table of Contents
- [Basic Setup](#basic-setup)
- [Getting Your Nostr Keys](#getting-your-nostr-keys)
- [Relay Configuration](#relay-configuration)
- [Customization](#customization)
- [Receiving Messages](#receiving-messages)
## Basic Setup
### Step 1: Include Dependencies
Add these to your HTML `<head>`:
```html
<!-- Tailwind CSS for styling -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Import map for nostr-tools -->
<script type="importmap">
{
"imports": {
"nostr-tools": "https://esm.sh/nostr-tools@1.17.0"
}
}
</script>
```
### Step 2: Add the Widget
Before your closing `</body>` tag:
```html
<!-- Chat widget script -->
<script type="module" src="path/to/chat.js"></script>
<!-- Chat widget container -->
<div id="chat-widget-root"></div>
```
### Step 3: Configure Your Public Key
Open `chat.js` and locate the `CONFIG` object:
```javascript
const CONFIG = {
relays: [
'wss://relay.damus.io',
'wss://relay.primal.net',
'wss://nos.lol',
'wss://relay.btcforplebs.com',
'wss://relay.logemedia.com'
],
csPubkey: 'YOUR_PUBLIC_KEY_HERE' // Replace with your hex public key
};
```
## Getting Your Nostr Keys
### Option 1: Using an Existing Nostr Client
If you already use Nostr:
1. Open your Nostr client (Damus, Amethyst, Snort, etc.)
2. Go to Settings → Keys/Security
3. Copy your **public key** (NOT your private key)
4. If it starts with `npub1`, convert it to hex format (see below)
### Option 2: Generate New Keys
For a fresh customer support identity:
1. Install a Nostr client:
- Desktop: [Nostr.band](https://nostr.band), [Nostrudel](https://nostrudel.ninja)
- Mobile: [Damus (iOS)](https://damus.io), [Amethyst (Android)](https://github.com/vitorpamplona/amethyst)
2. Create a new account
3. **IMPORTANT**: Securely save your private key (nsec)
4. Copy your public key for the config
### Converting npub to Hex
If your public key starts with `npub1`:
**Online Tool:**
- Visit [nostr.band/tools](https://nostr.band/tools)
- Paste your npub
- Copy the hex version
**Using JavaScript:**
```javascript
import { nip19 } from 'nostr-tools';
const hex = nip19.decode('npub1...').data;
console.log(hex);
```
## Relay Configuration
### Default Relays
The widget connects to multiple relays for reliability:
```javascript
relays: [
'wss://relay.damus.io', // General purpose, reliable
'wss://relay.primal.net', // Popular, good uptime
'wss://nos.lol', // Community favorite
'wss://relay.btcforplebs.com', // Bitcoin-focused
'wss://relay.logemedia.com' // Custom relay
]
```
### Choosing Relays
**Good relays have:**
- High uptime (99%+)
- Good geographic distribution
- Support for kind 4 events (DMs)
- Read/write permissions
**Popular Relay Lists:**
- [nostr.watch](https://nostr.watch) - Relay monitoring
- [relay.tools](https://relay.tools) - Relay explorer
### Running Your Own Relay
For maximum control, run your own Nostr relay:
**Quick Setup:**
```bash
# Using nostr-rs-relay (Rust)
docker run -d -p 8080:8080 scsibug/nostr-rs-relay
# Using strfry (C++)
# See: https://github.com/hoytech/strfry
```
Then add to your config:
```javascript
relays: [
'wss://your-relay.example.com',
// ... plus backup public relays
]
```
## Customization
### Colors
The widget uses Tailwind classes. Change colors by replacing:
**Primary gradient** (orange):
```javascript
// Find in chat.js:
from-[#fdad01] to-[#ff8c00]
// Replace with your brand colors:
from-[#4F46E5] to-[#7C3AED] // Purple
from-[#10B981] to-[#059669] // Green
from-[#EF4444] to-[#DC2626] // Red
```
**Connection indicator**:
```javascript
// Green dot when connected:
bg-green-400
// Change to match your theme:
bg-blue-400
bg-purple-400
```
### Position
Change the widget position in `chat.js`:
```javascript
// Bottom-right (default)
className="fixed bottom-4 right-4 sm:bottom-6 sm:right-6"
// Bottom-left
className="fixed bottom-4 left-4 sm:bottom-6 sm:left-6"
// Top-right
className="fixed top-4 right-4 sm:top-6 sm:right-6"
```
### Branding
**Team Name:**
```javascript
// Find in chat.js render function:
<div class="text-xs font-semibold text-[#fdad01] mb-1">Loge Media Team</div>
// Replace with:
<div class="text-xs font-semibold text-[#fdad01] mb-1">Your Team Name</div>
```
**Header Title:**
```javascript
<h3 class="font-bold text-base sm:text-lg">Instant Chat</h3>
// Change to:
<h3 class="font-bold text-base sm:text-lg">Need Help?</h3>
```
## Receiving Messages
### Setting Up Your Client
1. **Import Your Private Key**
- Open your Nostr client
- Go to Settings → Import Key
- Paste your private key (nsec or hex)
- **NEVER share your private key**
2. **Enable DM Notifications**
- Settings → Notifications
- Enable "Direct Messages"
- Enable push notifications (mobile)
3. **Monitor for Messages**
- New chat sessions will appear as DMs
- Each session has a unique temporary public key
- Reply directly - users see responses instantly
### Recommended Clients
**Desktop/Web:**
- [Nostr.band](https://nostr.band) - Comprehensive web client
- [Nostrudel](https://nostrudel.ninja) - Feature-rich
- [Snort.social](https://snort.social) - Clean interface
**Mobile:**
- [Damus (iOS)](https://damus.io) - Native iOS app
- [Amethyst (Android)](https://github.com/vitorpamplona/amethyst) - Powerful Android client
**Multi-device Tips:**
- Use the same private key across devices
- Messages sync automatically via relays
- Consider a dedicated device/account for support
## Advanced Configuration
### Session Duration
Change the 24-hour session expiry:
```javascript
// In getSessionKey() function:
if (Date.now() - session.created < 24 * 60 * 60 * 1000) {
// Change to 48 hours:
if (Date.now() - session.created < 48 * 60 * 60 * 1000) {
// Change to 12 hours:
if (Date.now() - session.created < 12 * 60 * 60 * 1000) {
```
### Multiple Support Accounts
Support multiple team members:
```javascript
const CONFIG = {
relays: [...],
csPubkeys: [
'team-member-1-hex-pubkey',
'team-member-2-hex-pubkey',
'team-member-3-hex-pubkey'
]
};
// Then update subscribeToReplies():
authors: CONFIG.csPubkeys
```
### Custom Welcome Message
Add an automatic welcome message:
```javascript
// In init() function, after subscribeToReplies():
setTimeout(() => {
addMessage('cs', 'Hi! How can we help you today?');
}, 500);
```
## Troubleshooting
### Widget Not Appearing
- Check browser console for errors
- Verify Tailwind CSS is loaded
- Ensure import map is correct
- Check `#chat-widget-root` div exists
### Messages Not Sending
- Verify relay connections in console
- Check public key is in hex format
- Ensure relays support kind 4 (DMs)
- Try different relays
### Not Receiving Replies
- Confirm correct private key in client
- Check relay overlap (client and widget)
- Verify DM notifications enabled
- Check client is connected to relays
### Mobile Issues
- Clear browser cache
- Check responsive CSS is applied
- Test on actual device, not just emulator
- Verify safe-area-inset for notched devices
## Security Best Practices
1. **Never expose private keys**
- Only use public keys in client code
- Store private keys securely (password manager)
- Never commit keys to version control
2. **Use HTTPS**
- Serve widget over secure connection
- Some relays require wss:// (secure websockets)
3. **Rate limiting**
- Consider implementing rate limits
- Monitor for spam/abuse
- Block abusive session keys if needed
4. **Session management**
- Sessions expire automatically
- No persistent user tracking
- Messages stored only in localStorage
## Support
Need help? Issues? Contributions?
- **GitHub Issues**: [Report a bug](https://github.com/yourusername/nostr-chat-widget/issues)
- **Documentation**: [Full docs](https://github.com/yourusername/nostr-chat-widget)
- **Nostr**: DM us on Nostr for support

146
demo/index.html Normal file
View File

@@ -0,0 +1,146 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Nostr Chat Widget Demo</title>
<script src="https://cdn.tailwindcss.com"></script>
<script type="importmap">
{
"imports": {
"nostr-tools": "https://esm.sh/nostr-tools@1.17.0"
}
}
</script>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
}
.gradient-bg {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.feature-card {
transition: transform 0.2s;
}
.feature-card:hover {
transform: translateY(-4px);
}
</style>
</head>
<body class="bg-gray-50">
<!-- Header -->
<header class="gradient-bg text-white py-20">
<div class="container mx-auto px-4 text-center">
<h1 class="text-5xl font-bold mb-4">Nostr Chat Widget</h1>
<p class="text-xl opacity-90">Privacy-first customer support powered by Nostr</p>
<div class="mt-8 flex gap-4 justify-center">
<span class="bg-white/20 px-4 py-2 rounded-full text-sm">🔐 E2E Encrypted</span>
<span class="bg-white/20 px-4 py-2 rounded-full text-sm">🌐 Decentralized</span>
<span class="bg-white/20 px-4 py-2 rounded-full text-sm">🚫 No Backend</span>
</div>
</div>
</header>
<!-- Main Content -->
<main class="container mx-auto px-4 py-16">
<!-- Demo Section -->
<section class="max-w-4xl mx-auto mb-20">
<div class="bg-gradient-to-r from-purple-500 to-pink-500 text-white p-8 rounded-2xl shadow-xl text-center">
<h2 class="text-3xl font-bold mb-4">Try it now!</h2>
<p class="text-lg mb-6">Click the orange chat button in the bottom-right corner to start a conversation</p>
<div class="inline-flex items-center gap-2 bg-white/20 px-6 py-3 rounded-full">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<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>
<span>Look for the chat widget →</span>
</div>
</div>
</section>
<!-- Features Grid -->
<section class="max-w-6xl mx-auto mb-20">
<h2 class="text-3xl font-bold text-center mb-12 text-gray-800">Key Features</h2>
<div class="grid md:grid-cols-3 gap-8">
<div class="feature-card bg-white p-6 rounded-xl shadow-lg">
<div class="text-4xl mb-4">🔐</div>
<h3 class="text-xl font-bold mb-2">End-to-End Encrypted</h3>
<p class="text-gray-600">All messages are encrypted using NIP-04 before transmission. Your conversations stay private.</p>
</div>
<div class="feature-card bg-white p-6 rounded-xl shadow-lg">
<div class="text-4xl mb-4">🌐</div>
<h3 class="text-xl font-bold mb-2">Decentralized</h3>
<p class="text-gray-600">Powered by Nostr relays. No single point of failure or control.</p>
</div>
<div class="feature-card bg-white p-6 rounded-xl shadow-lg">
<div class="text-4xl mb-4">🚫</div>
<h3 class="text-xl font-bold mb-2">No Backend Required</h3>
<p class="text-gray-600">Entirely client-side. Just drop in the code and you're ready to go.</p>
</div>
<div class="feature-card bg-white p-6 rounded-xl shadow-lg">
<div class="text-4xl mb-4">📱</div>
<h3 class="text-xl font-bold mb-2">Mobile Responsive</h3>
<p class="text-gray-600">Works seamlessly on desktop, tablet, and mobile devices.</p>
</div>
<div class="feature-card bg-white p-6 rounded-xl shadow-lg">
<div class="text-4xl mb-4"></div>
<h3 class="text-xl font-bold mb-2">Lightweight</h3>
<p class="text-gray-600">Minimal dependencies. Fast loading and smooth performance.</p>
</div>
<div class="feature-card bg-white p-6 rounded-xl shadow-lg">
<div class="text-4xl mb-4">🔑</div>
<h3 class="text-xl font-bold mb-2">Ephemeral Sessions</h3>
<p class="text-gray-600">Auto-generated session keys with 24-hour expiry for enhanced privacy.</p>
</div>
</div>
</section>
<!-- Installation -->
<section class="max-w-4xl mx-auto mb-20">
<h2 class="text-3xl font-bold text-center mb-8 text-gray-800">Quick Installation</h2>
<div class="bg-gray-900 text-gray-100 p-6 rounded-xl overflow-x-auto">
<pre class="text-sm"><code>&lt;!-- Add Tailwind CSS --&gt;
&lt;script src="https://cdn.tailwindcss.com"&gt;&lt;/script&gt;
&lt;!-- Add import map for nostr-tools --&gt;
&lt;script type="importmap"&gt;
{
"imports": {
"nostr-tools": "https://esm.sh/nostr-tools@1.17.0"
}
}
&lt;/script&gt;
&lt;!-- Add the chat widget --&gt;
&lt;script type="module" src="path/to/chat.js"&gt;&lt;/script&gt;
&lt;!-- Add chat widget container --&gt;
&lt;div id="chat-widget-root"&gt;&lt;/div&gt;</code></pre>
</div>
</section>
<!-- 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"
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"/>
</svg>
View on GitHub
</a>
</section>
</main>
<!-- Footer -->
<footer class="bg-gray-900 text-white py-8 mt-20">
<div class="container mx-auto px-4 text-center">
<p class="text-gray-400">Built with ⚡ by Loge Media</p>
<p class="text-sm text-gray-500 mt-2">Powered by the Nostr protocol</p>
</div>
</footer>
<!-- Chat Widget -->
<script type="module" src="../src/chat.js"></script>
<div id="chat-widget-root"></div>
</body>
</html>

21
license Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Loge Media
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

169
nostr_chat_readme.md Normal file
View File

@@ -0,0 +1,169 @@
# Nostr Chat Widget
A lightweight, privacy-focused chat widget powered by the Nostr protocol. Features end-to-end encryption, decentralized relay connections, and zero server dependencies.
![License](https://img.shields.io/badge/license-MIT-blue.svg)
![Nostr](https://img.shields.io/badge/protocol-Nostr-purple.svg)
## ✨ Features
- 🔐 **End-to-End Encrypted** - Messages encrypted using NIP-04
- 🌐 **Decentralized** - Connects to multiple Nostr relays
- 🚫 **No Backend Required** - Entirely client-side
- 📱 **Mobile Responsive** - Works seamlessly on all devices
-**Lightweight** - Minimal dependencies
- 🔑 **Ephemeral Keys** - Auto-generated session keys (24hr expiry)
- 💾 **Session Persistence** - Messages saved locally per session
## 🚀 Quick Start
### 1. Add Required Files
Include the chat widget files in your HTML:
```html
<!-- Add Tailwind CSS for styling -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Add import map for nostr-tools -->
<script type="importmap">
{
"imports": {
"nostr-tools": "https://esm.sh/nostr-tools@1.17.0"
}
}
</script>
<!-- Add the chat widget -->
<script type="module" src="path/to/chat.js"></script>
<!-- Add chat widget container -->
<div id="chat-widget-root"></div>
```
### 2. Configure Your Public Key
Edit the `CONFIG` object in `chat.js`:
```javascript
const CONFIG = {
relays: [
'wss://relay.damus.io',
'wss://relay.primal.net',
'wss://nos.lol'
],
csPubkey: 'YOUR_PUBLIC_KEY_HERE' // Your team's Nostr public key (hex format)
};
```
### 3. Done!
The chat widget will appear as a floating button in the bottom-right corner of your page.
## 📋 Configuration Options
| Option | Type | Description |
|--------|------|-------------|
| `relays` | Array | List of Nostr relay URLs to connect to |
| `csPubkey` | String | Your customer support team's public key (hex format) |
### Getting Your Public Key
1. Create a Nostr identity using any Nostr client
2. Export your public key in hex format (not npub)
3. Add it to the `csPubkey` field
**Tip:** You can convert npub to hex at [nostr.band/tools](https://nostr.band/tools)
## 🎨 Customization
### Styling
The widget uses Tailwind CSS utility classes. To customize colors, search for these classes in `chat.js`:
- Primary color: `from-[#fdad01] to-[#ff8c00]` (orange gradient)
- Change to your brand colors
### Mobile Behavior
On mobile devices, the chat expands to fullscreen automatically. Customize this in the CSS:
```css
@media (max-width: 600px) {
/* Adjust mobile styles here */
}
```
## 🔒 How It Works
1. **Session Creation**: When a user opens the chat, an ephemeral keypair is generated and stored locally
2. **Message Encryption**: All messages are encrypted using NIP-04 (end-to-end encryption)
3. **Relay Publishing**: Encrypted messages are published to multiple Nostr relays
4. **Real-time Updates**: The widget subscribes to replies from your team's public key
5. **Local Storage**: Messages persist locally for 24 hours per session
## 📦 Dependencies
- [nostr-tools](https://github.com/nbd-wtf/nostr-tools) - Nostr protocol implementation
- [Tailwind CSS](https://tailwindcss.com/) - Styling framework
## 🛠️ Development
### Local Testing
1. Clone the repository
2. Open `demo/index.html` in a browser
3. Configure your public key in `chat.js`
### Browser Compatibility
- Chrome/Edge: ✅
- Firefox: ✅
- Safari: ✅
- Mobile browsers: ✅
**Note:** Requires ES6 module support
## 📱 Receiving Messages
To receive and respond to chat messages, you'll need a Nostr client:
### Recommended Clients
- **Desktop**: [Nostr.band](https://nostr.band), [Nostrudel](https://nostrudel.ninja)
- **Mobile**: [Damus (iOS)](https://damus.io), [Amethyst (Android)](https://github.com/vitorpamplona/amethyst)
- **Web**: [Snort.social](https://snort.social), [Iris.to](https://iris.to)
### Setup Instructions
1. Import your private key into a Nostr client
2. Watch for DM notifications from new chat sessions
3. Reply directly from the client - messages appear instantly in the widget
## 🔐 Security Considerations
- **Ephemeral Keys**: Each session generates a new keypair (24hr expiry)
- **No User Data**: No tracking, cookies, or personal data collection
- **E2E Encryption**: All messages encrypted before transmission
- **Relay Privacy**: Messages distributed across multiple relays
## 📄 License
MIT License - See LICENSE file for details
## 🤝 Contributing
Contributions welcome! Please feel free to submit a Pull Request.
## 💬 Support
- **Issues**: [GitHub Issues](https://github.com/yourusername/nostr-chat-widget/issues)
- **Nostr**: Contact via Nostr DM
## 🙏 Credits
Built with [Nostr](https://nostr.com) protocol and [nostr-tools](https://github.com/nbd-wtf/nostr-tools)
---
**Made with ⚡ by Loge Media**

40
package_json.json Normal file
View File

@@ -0,0 +1,40 @@
{
"name": "nostr-chat-widget",
"version": "1.0.0",
"description": "A lightweight, privacy-focused chat widget powered by the Nostr protocol with end-to-end encryption",
"main": "src/chat.js",
"type": "module",
"scripts": {
"dev": "npx live-server demo --port=8080",
"test": "echo \"No tests specified yet\" && exit 0"
},
"keywords": [
"nostr",
"chat",
"widget",
"e2ee",
"encrypted",
"decentralized",
"p2p",
"privacy"
],
"author": "Loge Media",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/btcforplebs/nostr-chat-widget.git"
},
"bugs": {
"url": "https://github.com/btcforplebs/nostr-chat-widget/issues"
},
"homepage": "https://github.com/btcforplebs/nostr-chat-widget#readme",
"dependencies": {
"nostr-tools": "^1.17.0"
},
"devDependencies": {
"live-server": "^1.2.2"
},
"engines": {
"node": ">=14.0.0"
}
}

466
src/chat.js Normal file
View 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();