Compare commits

...

10 Commits

Author SHA1 Message Date
Logen
2c81078e77 docker 2025-10-28 09:19:25 -04:00
Logen
0714fcfb7a nip49-decrypt - Checklinks - icons 2025-10-21 15:43:29 -04:00
Logen
56bc8e2b92 nip49-decrypt - Checklinks - icons 2025-10-21 15:43:23 -04:00
640be15ac0 swapout 2025-10-10 08:23:56 -04:00
3fc9ed9c37 WIP 2025-10-10 05:32:26 -04:00
597d2f7a2c inject fix 2025-10-09 22:47:36 -04:00
4aa2c76e77 , 2025-10-09 22:22:10 -04:00
cd9dd6805f chat everywhere 2025-10-09 22:12:51 -04:00
36f1a0d86d chat widget cleanup 2025-10-09 22:06:29 -04:00
7a165d2cf4 minify 2025-10-09 18:31:40 -04:00
25 changed files with 8857 additions and 195 deletions

BIN
.DS_Store vendored

Binary file not shown.

3
.gitignore vendored
View File

@@ -4,3 +4,6 @@ BTCforPlebs Website Videos/*
node_modules/*
package-lock.json
public/assets/data/link-status.json
public/local.html
public/nostr-chat-widget-mini.js
public/nostr-chat-widget-mini.js

18
Dockerfile Normal file
View File

@@ -0,0 +1,18 @@
# Use an official lightweight Node image
FROM node:20-alpine
# Set the working directory inside the container
WORKDIR /app
# Copy package files and install dependencies
COPY package*.json ./
RUN npm install
# Copy the rest of the project files
COPY . .
# Expose the port your app uses
EXPOSE 9000
# Default command to start your site
CMD ["npm", "run", "both"]

View File

@@ -14,10 +14,11 @@ const links = [
"https://nosotros.btcforplebs.com",
"https://mint.btcforplebs.com",
"https://cashu.btcforplebs.com",
"https://shipyard.btcforplebs.com",
"https://live.btcforplebs.com",
"https://explorer.btcforplebs.com",
"https://nsec.btcforplebs.com",
"https://flotilla.btcforplebs.com"
"https://flotilla.btcforplebs.com",
"https://nutstash.btcforplebs.com",
"https://jumble.btcforplebs.com"
];
@@ -25,9 +26,9 @@ const links = [
app.use((req, res, next) => {
const origin = req.headers.origin || 'unknown';
console.log(`Request: ${req.method} ${req.url} | Origin: ${origin}`);
res.header("Access-Control-Allow-Headers", "Content-Type");
res.header("Access-Control-Allow-Origin", "*");
res.header("Access-Control-Allow-Methods", "GET, OPTIONS");
res.header("Access-Control-Allow-Headers", "Content-Type");
if (req.method === 'OPTIONS') {
return res.sendStatus(200);
@@ -39,8 +40,17 @@ app.get("/", (req, res) => {
res.json({ status: "Local link status server running" });
});
const cache = { data: null, timestamp: 0 };
app.get("/api/link-status", async (req, res) => {
console.log("🔄 Checking link statuses...");
const now = Date.now();
const tenMinutes = 10 * 60 * 1000;
if (cache.data && (now - cache.timestamp < tenMinutes)) {
console.log("🔁 Serving from cache");
return res.json(cache.data);
}
console.log("🔄 Checking link status and refreshing cache...");
const results = {};
const agent = new https.Agent({ rejectUnauthorized: false });
@@ -84,6 +94,10 @@ app.get("/api/link-status", async (req, res) => {
}
}
// Update cache
cache.data = results;
cache.timestamp = now;
res.json(results);
});

11
docker-compose.yml Normal file
View File

@@ -0,0 +1,11 @@
version: "3.8"
services:
web:
build: .
ports:
- "9000:9000"
volumes:
- .:/app
restart: always
command: npm run both

View File

@@ -109,6 +109,13 @@
</section>
<footer id="footer"></footer>
<script src="/nostr-chat-widget.js"
data-nostr-pubkey="75462f4dece4fbde54a535cfa09eb0d329bda090a9c2f9ed6b5f9d1d2fb6c15b"
data-brand-name="Chat with BTCforPlebs"
data-color="#fdad01"
data-color-secondary="#222222">
</script>
<div id="back-to-top"><a href="#top" title="Back to Top">🔝</a></div>
</body>
</html>

2237
public/dashboard/dev.html Normal file

File diff suppressed because it is too large Load Diff

BIN
public/dashboard/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 269 KiB

2650
public/dashboard/index.html Normal file

File diff suppressed because it is too large Load Diff

43
public/dashboard/sw.js Normal file
View File

@@ -0,0 +1,43 @@
// sw.js - Save this file in the same directory as your HTML
const CACHE_NAME = 'nostr-dm-v1';
const urlsToCache = [
'/',
'/index.html'
];
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => cache.addAll(urlsToCache))
);
});
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request)
.then(response => response || fetch(event.request))
);
});
self.addEventListener('notificationclick', event => {
event.notification.close();
event.waitUntil(
clients.openWindow('/')
);
});
self.addEventListener('push', event => {
const data = event.data ? event.data.json() : {};
const title = data.title || 'New Nostr DM';
const options = {
body: data.body || 'You have a new message',
icon: 'data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' width=\'192\' height=\'192\' viewBox=\'0 0 192 192\'%3E%3Crect width=\'192\' height=\'192\' fill=\'%23fdad01\' rx=\'24\'/%3E%3Ctext x=\'50%25\' y=\'50%25\' font-size=\'90\' fill=\'%23000\' text-anchor=\'middle\' dominant-baseline=\'middle\'%3E📬%3C/text%3E%3C/svg%3E',
badge: 'data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' width=\'96\' height=\'96\' viewBox=\'0 0 96 96\'%3E%3Ccircle cx=\'48\' cy=\'48\' r=\'48\' fill=\'%23fdad01\'/%3E%3Ctext x=\'50%25\' y=\'50%25\' font-size=\'50\' text-anchor=\'middle\' dominant-baseline=\'middle\'%3E📬%3C/text%3E%3C/svg%3E',
vibrate: [200, 100, 200],
tag: 'nostr-dm'
};
event.waitUntil(
self.registration.showNotification(title, options)
);
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

View File

@@ -11,16 +11,17 @@
<!-- Open Graph Meta Tags -->
<meta property="og:title" content="BTCforPlebs">
<meta property="og:description" content="A place to help ordinary people learn about Bitcoin">
<meta property="og:image" content="/images/thumb.jpeg">
<meta property="og:image" content="images/thumb.jpeg">
<meta property="og:url" content="https://btcforplebs.com">
<meta name="twitter:card" content="summary_large_image">
<!-- favicon -->
<link rel="icon" href="/images/favicon.png" type="image/png">
<link rel="icon" href="images/favicon.png" type="image/png">
<!-- scripts -->
<script src="/assets/js/scripts.js"></script>
<link rel="stylesheet" href="/assets/css/main.css">
<script src="assets/js/scripts.js"></script>
<link rel="stylesheet" href="assets/css/main.css">
<link rel="apple-touch-icon" href="images/apple-touch-icon.png">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.6.0/css/all.min.css" rel="stylesheet">
</head>
<body>
@@ -43,19 +44,21 @@
<div class="links" id="folder2">
<a href="https://lightning.btcforplebs.com" target="_blank" class="prefetch">Lightning <span class="status-emoji" data-url="https://lightning.btcforplebs.com"></span></a>
<a href="https://mempool.btcforplebs.com" target="_blank" class="prefetch">Mempool <span class="status-emoji" data-url="https://mempool.btcforplebs.com"></span></a>
<a href="https://explorer.btcforplebs.com" target="_blank" class="prefetch">Explorer <span class="status-emoji" data-url="https://explorer.btcforplebs.com"></span></a>
<a href="https://bitview.space/" target="_blank" class="prefetch">Bitview.space ⚪️ </a>
<a href="https://bitfeed.live" class="prefetch">Bitfeed.Live ⚪️ </a>
<a href="https://hodl.camp" class="prefetch">Hodl.Camp ⚪️ </a>
<a href="https://bitview.space" class="prefetch">Bitview.Space ⚪️ </a>
</div>
<!-- Nostr Links -->
<button class="button" id="nostr-folder-btn" onclick="toggleFolder('folder1', 'nostr-folder-btn')">Use Nostr <span style="color: #F7931A;"></span></button>
<div class="links" id="folder1">
<a href="https://nsec.btcforplebs.com" target="_blank" class="prefetch">Nsec Bunker <span class="status-emoji" data-url="https://nsec.btcforplebs.com"></span></a>
<a href="https://bloom.btcforplebs.com" target="_blank" class="prefetch">Bloom <span class="status-emoji" data-url="https://bloom.btcforplebs.com"></span></a>
<a href="https://nostrudel.btcforplebs.com" target="_blank" class="prefetch">Nostrudel <span class="status-emoji" data-url="https://nostrudel.btcforplebs.com"></span></a>
<a href="https://nosotros.btcforplebs.com" target="_blank" class="prefetch">Nosotros <span class="status-emoji" data-url="https://nosotros.btcforplebs.com"></span></a>
<a href="https://flotilla.btcforplebs.com" target="_blank" class="prefetch">Flotilla <span class="status-emoji" data-url="https://flotilla.btcforplebs.com"></span></a>
<a href="https://Jumble.btcforplebs.com" target="_blank" class="prefetch">Jumble <span class="status-emoji" data-url="https://jumble.btcforplebs.com"></span></a>
<a href="https://bloom.btcforplebs.com" target="_blank" class="prefetch">Bloom <span class="status-emoji" data-url="https://bloom.btcforplebs.com"></span></a>
<a href="https://shipyard.btcforplebs.com" target="_blank" class="prefetch">Shipyard <span class="status-emoji" data-url="https://shipyard.btcforplebs.com"></span></a>
<a href="/relay" target="_blank" class="prefetch">Relay <span class="status-emoji" data-url="https://relay.btcforplebs.com"></span></a>
<a href="https://nostrapps.com" target="_blank">NostrApps.com ⚪️ </a>
</div>
@@ -63,7 +66,8 @@
<!-- Cashu Links -->
<button class="button" id="cashu-folder-btn" onclick="toggleFolder('folder3', 'cashu-folder-btn')">Use Cashu <span style="color: #F7931A;"></span></button>
<div class="links" id="folder3">
<a href="https://cashu.btcforplebs.com" class="prefetch">Cashu Web App <span class="status-emoji" data-url="https://cashu.btcforplebs.com"></span></a>
<a href="https://cashu.btcforplebs.com" class="prefetch">Cashu Wallet Web App <span class="status-emoji" data-url="https://cashu.btcforplebs.com"></span></a>
<a href="https://nutstash.btcforplebs.com" class="prefetch">Nutstash Wallet Web App <span class="status-emoji" data-url="https://nutstash.btcforplebs.com"></span></a>
<a href="/mint" class="prefetch">Cashu Mint <span class="status-emoji" data-url="https://mint.btcforplebs.com"></span></a>
<a href="https://macadamia.cash" class="prefetch">Macadamia (iOS) ⚪️ </a>
<a href="https://minibits.cash" class="prefetch">Minibits (Android) ⚪️ </a>
@@ -77,7 +81,11 @@
</div>
<script src="https://btcforplebs.com/nostr-chat-widget.js" data-nostr-pubkey="75462f4dece4fbde54a535cfa09eb0d329bda090a9c2f9ed6b5f9d1d2fb6c15b" data-brand-name="Chat with BTCforPlebs" data-color="#fdad01" data-color-secondary="#8e30eb">
<script src="https://btcforplebs.com/nostr-chat-widget.js"
data-nostr-pubkey="75462f4dece4fbde54a535cfa09eb0d329bda090a9c2f9ed6b5f9d1d2fb6c15b"
data-brand-name="Chat with BTCforPlebs"
data-color="#fdad01"
data-color-secondary="#222222">
</script>
<script>

View File

@@ -188,7 +188,12 @@
</section>
</div>
<script src="/nostr-chat-widget.js"
data-nostr-pubkey="75462f4dece4fbde54a535cfa09eb0d329bda090a9c2f9ed6b5f9d1d2fb6c15b"
data-brand-name="Chat with BTCforPlebs"
data-color="#fdad01"
data-color-secondary="#222222">
</script>
<script>
// --- 2. Dropdown navigation ---------------------------------------------
window.navigateToSection = function (select) {
@@ -208,7 +213,8 @@
if (sectionId) {
document.getElementById(sectionId).scrollIntoView({ behavior: 'smooth' });
}
}</script>
}
</script>
</html>

View File

@@ -62,5 +62,11 @@
}
}
};</script>
<script src="/nostr-chat-widget.js"
data-nostr-pubkey="75462f4dece4fbde54a535cfa09eb0d329bda090a9c2f9ed6b5f9d1d2fb6c15b"
data-brand-name="Chat with BTCforPlebs"
data-color="#fdad01"
data-color-secondary="#222222">
</script>
</body>
</html>

View File

@@ -44,6 +44,12 @@
<div id="back-to-top">
<a href="#top" title="Back to Top">🔝</a>
</div>
<script src="/nostr-chat-widget.js"
data-nostr-pubkey="75462f4dece4fbde54a535cfa09eb0d329bda090a9c2f9ed6b5f9d1d2fb6c15b"
data-brand-name="Chat with BTCforPlebs"
data-color="#fdad01"
data-color-secondary="#222222">
</script>
<script>
// --- 2. Dropdown navigation ---------------------------------------------

View File

@@ -105,6 +105,12 @@
</div>
</div>
<div id="footer"></div>
<script src="/nostr-chat-widget.js"
data-nostr-pubkey="75462f4dece4fbde54a535cfa09eb0d329bda090a9c2f9ed6b5f9d1d2fb6c15b"
data-brand-name="Chat with BTCforPlebs"
data-color="#fdad01"
data-color-secondary="#222222">
</script>
<a href="#top" title="Back to Top">🔝</a>
<script>function navigateToSection(select) {
const sectionId = select.value;

View File

@@ -44,6 +44,12 @@
<a href="/index.html" class="button">Home</a>
</div>
<script src="/nostr-chat-widget.js"
data-nostr-pubkey="75462f4dece4fbde54a535cfa09eb0d329bda090a9c2f9ed6b5f9d1d2fb6c15b"
data-brand-name="Chat with BTCforPlebs"
data-color="#fdad01"
data-color-secondary="#222222">
</script>
<div id="footer"></div>

View File

@@ -8,36 +8,48 @@
<link rel="stylesheet" href="/assets/css/main.css">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.6.0/css/all.min.css" rel="stylesheet">
<style>
/* Override a bit to make it super retro */
body {font-family: "Courier New", monospace; background:#111; color:#ecf0f1;}
h1{color:#f39c12;}
a {color:#8e30eb;}
h1{color:#8e30eb;}
.center{max-width:800px;margin:auto;padding:2rem;text-align:left;background:rgba(0,0,0,.8);border-radius:8px;}
.btn{display:inline-block;margin:10px 0;padding:.7rem 1.5rem;background:#e67e22;color:#fff;border-radius:4px;text-decoration:none;font-weight:bold;}
.quicklinks{margin-top:1.5rem;}
.btn{display:inline-block;margin:10px 0;padding:.7rem 1.5rem;background:#8e30eb;color:#fff;border-radius:4px;text-decoration:none;font-weight:bold;}
</style>
</head>
<body>
<div class="center">
<h1>BTCforPlebs Cashu Mint</h1>
<p>Welcome to the BTCforPlebs Cashu Mint! Mint up to 100,000 sats per session but remember, no lifesaving amounts. If you hit a snag, ping us on Nostr or email <a href="mailto:mint@btcforplebs.com">mint@btcforplebs.com</a>.</p>
<p>Welcome to the BTCforPlebs Cashu Mint! Mint up to 100,000 sats but remember, no lifesaving amounts. If you hit a snag, ping us on Nostr</p>
<h2>Quick Links</h2>
<div class="quicklinks">
<a href="https://cashu.space" class="btn">cashu.space</a>
<a href="https://cashu.me" class="btn">cashu.me (external host)</a>
<a href="https://cashu.btcforplebs.com" class="btn">cashu.me (BTCforPlebs host)</a>
<a href="https://btcforplebs.com/cashu" class="btn">btcforplebs.com/cashu</a>
<li><a href="https://cashu.space" class="btn">cashu.space</a>
Learn about the nuts of Cashu and how it works. <br> </br></li>
<li><a href="https://cashu.me" class="btn">cashu.me (external host)</a>
A Cashu PWA wallet that if feature rich! <br> </br></li>
<li><a href="https://cashu.btcforplebs.com" class="btn">cashu.me (BTCforPlebs host)</a>
Our own hosted version of cashu.me <br></br></li>
<li><a href="https://btcforplebs.com/learn-cashu" class="btn">Learn Cashu</a>
Learn what Cashu is and how to use it from BTCforPlebs<br> </br></li>
</div>
<h2>Minting Policy</h2>
<br></br>
<ul>
<li>Maximum 100,000 sats per mint session.</li>
<li>Do not mint your life savings.</li>
<li>Contact us on <a href="https://nosotros.btcforplebs.com/nprofile1qydhwumn8ghj7un9d3shjtnzw33kvmmjwpkx2cnn9e3k7mgprdmhxue69uhksctkv4hzucn5vdnx7unsd3jkyuewvdhk6qgkwaehxw309aex2mrp0yh8qunfd4skctnwv46qz9rhwden5te0wfjkccte9ejxzmt4wvhxjmcqyp65vt6danj0hhj5556ulgy7krfjn0dqjz5u970ddd0e68f0kmq4kju00y6">Nostr</a></li>
<li>Payments are processed via Cashu. See the <a href="/cashu/docs">documentation</a> for details.</li>
</ul>
<h2>FAQ</h2>
<p>For frequently asked questions, visit our <a href="/cashu/faq">FAQ page</a> or join our Nostr channel.</p>
<footer style="margin-top:2rem;color:#777;text-align:center;">
© 2025 BTCforPlebs Mint responsibly.
</footer>
<script src="https://btcforplebs.com/nostr-chat-widget.js"
data-nostr-pubkey="75462f4dece4fbde54a535cfa09eb0d329bda090a9c2f9ed6b5f9d1d2fb6c15b"
data-brand-name="Chat with BTCforPlebs"
data-color="#8e30eb"
data-color-secondary="#000000">
</script>
</div>
</body>
</html>

View File

@@ -0,0 +1 @@
pong

File diff suppressed because one or more lines are too long

View File

@@ -1,15 +1,13 @@
/**
* Nostr Chat Widget - Embeddable Version
* Nostr Chat Widget - Embeddable Version (Glassmorphism Design)
*
* HOST THIS FILE on your server (e.g., https://btcforplebs.com/chat-widget.js)
*
* USERS EMBED IT WITH:
* EMBED IT WITH:
* <script src="https://btcforplebs.com/nostr-chat-widget.js"
data-nostr-pubkey="YOUR_PUBKEY"
data-brand-name="My Company"
data-color="#8e30eb">
data-color-secondary="#ff8c00">
</script>
* data-nostr-pubkey="YOUR_PUBKEY_HEX_FORMAT"
* data-brand-name="My Company"
* data-color="#8e30eb"
* data-color-secondary="#ff8c00">
* </script>
*/
(function() {
@@ -18,35 +16,118 @@
// 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';
const primaryColor = scriptTag.getAttribute('data-color-primary') || '#fdad01';
const secondaryColor = scriptTag.getAttribute('data-color-secondary') || '#ff8c00';
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',
'wss://relay.logemedia.com'
'wss://relay.btcforplebs.com'
];
// Inject Tailwind CSS
const tailwindLink = document.createElement('link');
tailwindLink.href = 'https://cdn.tailwindcss.com';
tailwindLink.rel = 'stylesheet';
document.head.appendChild(tailwindLink);
// 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
// Inject custom styles with glassmorphism
const style = document.createElement('style');
style.textContent = `
.safe-area-bottom {
padding-bottom: max(1rem, env(safe-area-inset-bottom)) !important;
padding-bottom: max(env(safe-area-inset-bottom), 1rem);
}
#nostr-chat-widget-root > div {
pointer-events: auto !important;
z-index: 99999 !important;
}
.glass-morphism {
background: rgba(255, 255, 255, 0.08);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.15);
}
.glass-morphism-light {
background: rgba(255, 255, 255, 0.03);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.glass-input {
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);
}
.glass-input:focus {
background: rgba(255, 255, 255, 0.25);
border: 1px solid rgba(255, 255, 255, 0.4);
}
.glass-input::placeholder {
color: rgba(255, 255, 255, 0.6);
}
.mobile-input-container {
position: sticky;
bottom: 0;
left: 0;
right: 0;
background: transparent;
}
@media (max-width: 640px) {
html.chat-open {
overflow: hidden;
position: fixed;
width: 100%;
height: 100%;
}
body.chat-open {
overflow: hidden;
position: fixed;
width: 100%;
height: 100%;
}
#nostr-chat-widget-root .chat-window-mobile {
position: fixed !important;
top: 0 !important;
left: 0 !important;
right: 0 !important;
bottom: 0 !important;
width: 100% !important;
height: 100vh !important;
height: 100dvh !important;
max-height: 100vh !important;
max-height: 100dvh !important;
border-radius: 0 !important;
display: flex !important;
flex-direction: column !important;
}
#nostr-chat-widget-root .chat-header-mobile {
flex-shrink: 0 !important;
}
#nostr-chat-widget-root .chat-messages-mobile {
flex: 1 1 0% !important;
overflow-y: auto !important;
overflow-x: hidden !important;
-webkit-overflow-scrolling: touch !important;
min-height: 0 !important;
overscroll-behavior: contain !important;
padding-bottom: 1rem !important;
}
#nostr-chat-widget-root .mobile-input-container {
position: sticky !important;
bottom: 0 !important;
flex-shrink: 0 !important;
z-index: 10 !important;
backdrop-filter: blur(20px) !important;
-webkit-backdrop-filter: blur(20px) !important;
padding-bottom: max(env(safe-area-inset-bottom), 1.5rem) !important;
}
}
`;
document.head.appendChild(style);
@@ -83,7 +164,8 @@
relays: ${JSON.stringify(DEFAULT_RELAYS)},
csPubkey: '${csPubkey}',
brandName: '${brandName}',
primaryColor: '${primaryColor}'
primaryColor: '${primaryColor}',
secondaryColor: '${secondaryColor}'
};
let state = {
@@ -117,14 +199,26 @@
}
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', () => {
@@ -134,6 +228,11 @@
relay.on('disconnect', () => {
console.log(\`✗ Disconnected from \${url}\`);
checkConnection();
});
relay.on('error', (err) => {
console.error(\`❌ Relay error \${url}:\`, err);
});
await relay.connect();
@@ -146,13 +245,13 @@
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');
addMessage('system', '⚠️ Failed to connect to any relays. Check console for details.');
return;
}
console.log(\`✓ Connected to \${state.relays.length}/\${CONFIG.relays.length} relays\`);
subscribeToReplies();
loadPreviousMessages();
@@ -241,8 +340,24 @@
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...');
@@ -265,37 +380,48 @@
event.sig = signEvent(event, state.myPrivKey);
let published = 0;
for (const relay of state.relays) {
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\`);
const message = {
id: event.id,
text: messageText,
sender: 'user',
timestamp: new Date().toISOString()
};
addMessage('user', messageText, message);
state.inputMessage = '';
render();
// 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);
addMessage('system', '⚠️ Failed to send message');
// 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();
}
}
@@ -317,8 +443,13 @@
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);
}
@@ -346,7 +477,8 @@
container.innerHTML = \`
<div class="fixed bottom-4 right-4 sm:bottom-6 sm:right-6 z-[99999]">
<button onclick="window.NostrChat.open()"
class="bg-gradient-to-br from-[\${CONFIG.primaryColor}] to-[#ff8c00] hover:from-[#ff8c00] hover:to-[\${CONFIG.primaryColor}] text-white rounded-full p-4 sm:p-5 shadow-2xl transition-all transform hover:scale-110 active:scale-95"
style="background: linear-gradient(to bottom right, \${CONFIG.primaryColor}, \${CONFIG.secondaryColor});"
class="hover:opacity-90 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">
@@ -359,16 +491,16 @@
}
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">
<div class="bg-gradient-to-br from-[\${CONFIG.primaryColor}] to-[#ff8c00] text-white p-4 sm:p-5">
<div class="fixed inset-0 sm:inset-auto sm:bottom-6 sm:right-6 z-[99999]">
<div class="glass-morphism chat-window-mobile rounded-none sm:rounded-2xl shadow-2xl w-full h-full sm:w-96 sm:h-[600px] max-w-full flex flex-col overflow-hidden">
<div style="background: linear-gradient(to bottom right, \${CONFIG.primaryColor}, \${CONFIG.secondaryColor});" class="text-white p-3.5 sm:p-4 chat-header-mobile">
<div class="flex justify-between items-center">
<div class="flex-1 min-w-0">
<h3 class="font-bold text-base sm:text-lg">\${CONFIG.brandName}</h3>
<div class="flex items-center gap-2 mt-1">
<div class="flex items-center gap-2 mt-0.5">
<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 class="text-xs text-white/80 truncate">
\${state.connected ? \`Encrypted\${state.relays.length} relays\` : 'Connecting...'}
</span>
</div>
</div>
@@ -385,9 +517,9 @@
</div>
</div>
<div id="nostr-messages" class="flex-1 overflow-y-auto p-3 sm:p-4 space-y-3 bg-gradient-to-b from-gray-50 to-white">
<div id="nostr-messages" class="flex-1 overflow-y-auto p-3 sm:p-3.5 space-y-3 glass-morphism-light chat-messages-mobile">
\${state.messages.length === 0 ? \`
<div class="text-center text-gray-400 mt-8">
<div class="text-center text-white/60 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>
@@ -397,7 +529,7 @@
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">
<div class="bg-orange-100/40 backdrop-blur-sm text-orange-800 text-xs px-3 py-2 rounded-full border border-orange-300/50">
\${escapeHtml(msg.text)}
</div>
</div>
@@ -406,10 +538,10 @@
return \`
<div class="flex justify-end">
<div class="max-w-[85%] sm:max-w-xs">
<div class="bg-gradient-to-br from-[\${CONFIG.primaryColor}] 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">
<div style="background: linear-gradient(to bottom right, \${CONFIG.primaryColor}, \${CONFIG.secondaryColor});" class="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 class="text-xs text-white/60 mt-1 text-right">Sent \${formatTime(msg.timestamp)}</div>
</div>
</div>
\`;
@@ -417,11 +549,10 @@
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" style="color: \${CONFIG.primaryColor}">\${CONFIG.brandName}</div>
<div style="background: linear-gradient(to bottom right, #9ca3af, #6b7280);" class="text-white rounded-2xl rounded-tl-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">\${formatTime(msg.timestamp)}</div>
<div class="text-xs text-white/60 mt-1">\${formatTime(msg.timestamp)}</div>
</div>
</div>
\`;
@@ -430,21 +561,21 @@
}).join('')}
</div>
<div class="border-t bg-white p-3 sm:p-4 pb-safe">
<div class="flex gap-2">
<div class="p-3 sm:p-3.5 sm:mb-2 pb-6 flex-shrink-0 safe-area-bottom mobile-input-container">
<div class="glass-input rounded-xl p-2 flex gap-2 items-center">
<input
id="nostr-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 text-sm sm:text-base"
style="focus:ring-color: \${CONFIG.primaryColor}"
class="flex-1 bg-transparent px-2 py-1.5 focus:outline-none text-sm sm:text-base text-white placeholder-white/60"
\${!state.connected ? 'disabled' : ''}
>
<button
onclick="window.NostrChat.send()"
\${!state.connected || !state.inputMessage.trim() ? 'disabled' : ''}
class="bg-gradient-to-br from-[\${CONFIG.primaryColor}] to-[#ff8c00] hover:from-[#ff8c00] hover:to-[\${CONFIG.primaryColor}] 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"
style="background: linear-gradient(to bottom right, \${CONFIG.primaryColor}, \${CONFIG.secondaryColor});"
class="hover:opacity-90 disabled:opacity-40 text-white p-2 rounded-lg 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">
@@ -481,14 +612,22 @@
}, 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();
@@ -496,6 +635,9 @@
},
close: () => {
state.isOpen = false;
// Restore body scroll on mobile
document.documentElement.classList.remove('chat-open');
document.body.classList.remove('chat-open');
render();
},
send: sendMessage
@@ -508,7 +650,6 @@
document.body.appendChild(widgetScript);
})();
/*!
* fill-range <https://github.com/jonschlinkert/fill-range>
*

View File

@@ -0,0 +1,544 @@
/**
* Nostr Chat Widget - Embeddable Version (Glassmorphism Design)
*
* EMBED IT WITH:
* <script src="https://btcforplebs.com/nostr-chat-widget.js"
* data-nostr-pubkey="YOUR_PUBKEY_HEX_FORMAT"
* data-brand-name="My Company"
* data-color="#8e30eb"
* data-color-secondary="#ff8c00">
* </script>
*/
(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';
viewportMeta.content = 'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no';
document.head.appendChild(viewportMeta);
}
// Inject custom styles with glassmorphism
const style = document.createElement('style');
style.textContent = `
.safe-area-bottom {
padding-bottom: env(safe-area-inset-bottom);
}
#nostr-chat-widget-root > div {
pointer-events: auto !important;
z-index: 99999 !important;
}
.glass-morphism {
background: rgba(255, 255, 255, 0.08);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.15);
}
.glass-morphism-light {
background: rgba(255, 255, 255, 0.03);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.glass-input {
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);
}
.glass-input:focus {
background: rgba(255, 255, 255, 0.25);
border: 1px solid rgba(255, 255, 255, 0.4);
}
.glass-input::placeholder {
color: rgba(255, 255, 255, 0.6);
}
@media (max-width: 640px) {
#nostr-chat-widget-root .chat-window-mobile {
position: fixed !important;
top: 0 !important;
left: 0 !important;
right: 0 !important;
bottom: 0 !important;
width: 100% !important;
height: 100% !important;
max-height: 100vh !important;
border-radius: 0 !important;
}
}
`;
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() {
state.myPrivKey = getSessionKey();
state.myPubKey = getPublicKey(state.myPrivKey);
state.sessionId = state.myPubKey.substring(0, 8);
console.log('🔑 Session Identity:', nip19.npubEncode(state.myPubKey));
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\`);
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;
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;
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\`);
// 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);
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('nostr-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
});
}
function render() {
const container = document.getElementById('nostr-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.NostrChat.open()"
style="background: linear-gradient(to bottom right, \${CONFIG.primaryColor}, \${CONFIG.secondaryColor});"
class="hover:opacity-90 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;
}
container.innerHTML = \`
<div class="fixed inset-0 sm:inset-auto sm:bottom-6 sm:right-6 z-[99999]">
<div class="glass-morphism chat-window-mobile rounded-none sm:rounded-2xl shadow-2xl w-full h-full sm:w-96 sm:h-[600px] max-w-full flex flex-col overflow-hidden">
</div>
<div style="background: linear-gradient(to bottom right, ${CONFIG.primaryColor}, ${CONFIG.secondaryColor});" class="text-white py-4 px-5 sm:px-6 rounded-t-2xl">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-2">
<img src="/assets/logo.svg" alt="logo" class="h-5 w-5 sm:h-6 sm:w-6 rounded" />
<h3 class="font-semibold text-sm sm:text-base">${CONFIG.brandName}</h3>
</div>
<button onclick="window.NostrChat.close()" class="p-2 hover:bg-white/20 rounded-md transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" 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"/></svg>
</button>
</div>
<div style="background: linear-gradient(to bottom right, ${CONFIG.primaryColor}, ${CONFIG.secondaryColor});" class="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-center text-white/60 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-100/40 backdrop-blur-sm text-orange-800 text-xs px-3 py-2 rounded-full border border-orange-300/50">
\${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 style="background: linear-gradient(to bottom right, \${CONFIG.primaryColor}, \${CONFIG.secondaryColor});" class="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-white/60 mt-1 text-right">Sent \${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 style="background: linear-gradient(to bottom right, #9ca3af, #6b7280);" class="text-white rounded-2xl rounded-tl-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-white/60 mt-1">\${formatTime(msg.timestamp)}</div>
</div>
</div>
\`;
}
return '';
}).join('')}
</div>
<div class="p-3 sm:p-3.5 mb-2 flex-shrink-0 safe-area-bottom">
<div class="glass-input rounded-xl p-2 flex gap-2 items-center">
<input
id="nostr-message-input"
type="text"
value="\${escapeHtml(state.inputMessage)}"
placeholder="Type your message..."
class="flex-1 bg-transparent px-2 py-1.5 focus:outline-none text-sm sm:text-base text-white placeholder-white/60"
\${!state.connected ? 'disabled' : ''}
>
<button
onclick="window.NostrChat.send()"
\${!state.connected || !state.inputMessage.trim() ? 'disabled' : ''}
style="background: linear-gradient(to bottom right, \${CONFIG.primaryColor}, \${CONFIG.secondaryColor});"
class="hover:opacity-90 disabled:opacity-40 text-white p-2 rounded-lg 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('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);
}
messageInput.focus();
}
}
// Expose global API
window.NostrChat = {
open: async () => {
state.isOpen = true;
render();
if (state.relays.length === 0) {
await init();
}
},
close: () => {
state.isOpen = false;
render();
},
send: sendMessage
};
// Initial render
render();
`;
document.body.appendChild(widgetScript);
})();

File diff suppressed because one or more lines are too long

View File

@@ -1,52 +1,13 @@
/**
* Nostr Chat Widget - Embeddable Version
*
*
*
*
*
*
*
*
EMBED IT WITH:
<script src="https://btcforplebs.com/nostr-chat-widget.js"
data-nostr-pubkey="YOUR_PUBKEY_HEX_FORMAT"
data-brand-name="My Company"
data-color="#8e30eb"
data-color-secondary="#ff8c00">
</script>
COLOR OPTIONS WITH:
<!-- Purple gradient -->
<script src="https://btcforplebs.com/nostr-chat-widget.js"
data-nostr-pubkey="YOUR_PUBKEY_HEX_FORMAT"
data-brand-name="My Company"
data-color-primary="#8e30eb"
data-color-secondary="#5a1e9e"></script>
<!-- Blue gradient -->
<script src="https://btcforplebs.com/nostr-chat-widget.js"
data-nostr-pubkey="YOUR_PUBKEY_HEX_FORMAT"
data-brand-name="My Company"
data-color-primary="#3b82f6"
data-color-secondary="#1d4ed8"></script>
<!-- Green gradient -->
<script src="https://btcforplebs.com/nostr-chat-widget.js"
data-nostr-pubkey="YOUR_PUBKEY_HEX_FORMAT"
data-brand-name="My Company"
data-color-primary="#10b981"
data-color-secondary="#059669"></script>
*
*
*
*
*
*
*
*
*
* Nostr Chat Widget - Embeddable Version (Glassmorphism Design)
*
* EMBED IT WITH:
* <script src="https://btcforplebs.com/nostr-chat-widget.js"
* data-nostr-pubkey="YOUR_PUBKEY_HEX_FORMAT"
* data-brand-name="My Company"
* data-color="#8e30eb"
* data-color-secondary="#ff8c00">
* </script>
*/
(function() {
@@ -67,22 +28,106 @@ EMBED IT WITH:
'wss://relay.btcforplebs.com'
];
// Inject Tailwind CSS
const tailwindLink = document.createElement('link');
tailwindLink.href = 'https://cdn.tailwindcss.com';
tailwindLink.rel = 'stylesheet';
document.head.appendChild(tailwindLink);
// 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
// Inject custom styles with glassmorphism
const style = document.createElement('style');
style.textContent = `
.safe-area-bottom {
padding-bottom: env(safe-area-inset-bottom);
padding-bottom: max(env(safe-area-inset-bottom), 1rem);
}
#nostr-chat-widget-root > div {
pointer-events: auto !important;
z-index: 99999 !important;
}
.glass-morphism {
background: rgba(255, 255, 255, 0.08);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.15);
}
.glass-morphism-light {
background: rgba(255, 255, 255, 0.03);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.glass-input {
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);
}
.glass-input:focus {
background: rgba(255, 255, 255, 0.25);
border: 1px solid rgba(255, 255, 255, 0.4);
}
.glass-input::placeholder {
color: rgba(255, 255, 255, 0.6);
}
.mobile-input-container {
position: sticky;
bottom: 0;
left: 0;
right: 0;
background: transparent;
}
@media (max-width: 640px) {
html.chat-open {
overflow: hidden;
position: fixed;
width: 100%;
height: 100%;
}
body.chat-open {
overflow: hidden;
position: fixed;
width: 100%;
height: 100%;
}
#nostr-chat-widget-root .chat-window-mobile {
position: fixed !important;
top: 0 !important;
left: 0 !important;
right: 0 !important;
bottom: 0 !important;
width: 100% !important;
height: 100vh !important;
height: 100dvh !important;
max-height: 100vh !important;
max-height: 100dvh !important;
border-radius: 0 !important;
display: flex !important;
flex-direction: column !important;
}
#nostr-chat-widget-root .chat-header-mobile {
flex-shrink: 0 !important;
}
#nostr-chat-widget-root .chat-messages-mobile {
flex: 1 1 0% !important;
overflow-y: auto !important;
overflow-x: hidden !important;
-webkit-overflow-scrolling: touch !important;
min-height: 0 !important;
overscroll-behavior: contain !important;
padding-bottom: 1rem !important;
}
#nostr-chat-widget-root .mobile-input-container {
position: sticky !important;
bottom: 0 !important;
flex-shrink: 0 !important;
z-index: 10 !important;
backdrop-filter: blur(20px) !important;
-webkit-backdrop-filter: blur(20px) !important;
padding-bottom: max(env(safe-area-inset-bottom), 1.5rem) !important;
}
}
`;
document.head.appendChild(style);
@@ -154,14 +199,26 @@ EMBED IT WITH:
}
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', () => {
@@ -171,6 +228,11 @@ EMBED IT WITH:
relay.on('disconnect', () => {
console.log(\`✗ Disconnected from \${url}\`);
checkConnection();
});
relay.on('error', (err) => {
console.error(\`❌ Relay error \${url}:\`, err);
});
await relay.connect();
@@ -183,13 +245,13 @@ EMBED IT WITH:
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');
addMessage('system', '⚠️ Failed to connect to any relays. Check console for details.');
return;
}
console.log(\`✓ Connected to \${state.relays.length}/\${CONFIG.relays.length} relays\`);
subscribeToReplies();
loadPreviousMessages();
@@ -278,8 +340,24 @@ EMBED IT WITH:
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...');
@@ -302,37 +380,48 @@ EMBED IT WITH:
event.sig = signEvent(event, state.myPrivKey);
let published = 0;
for (const relay of state.relays) {
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\`);
const message = {
id: event.id,
text: messageText,
sender: 'user',
timestamp: new Date().toISOString()
};
addMessage('user', messageText, message);
state.inputMessage = '';
render();
// 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);
addMessage('system', '⚠️ Failed to send message');
// 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();
}
}
@@ -354,8 +443,13 @@ EMBED IT WITH:
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);
}
@@ -397,16 +491,16 @@ EMBED IT WITH:
}
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">
<div style="background: linear-gradient(to bottom right, \${CONFIG.primaryColor}, \${CONFIG.secondaryColor});" class="text-white p-4 sm:p-5">
<div class="fixed inset-0 sm:inset-auto sm:bottom-6 sm:right-6 z-[99999]">
<div class="glass-morphism chat-window-mobile rounded-none sm:rounded-2xl shadow-2xl w-full h-full sm:w-96 sm:h-[600px] max-w-full flex flex-col overflow-hidden">
<div style="background: linear-gradient(to bottom right, \${CONFIG.primaryColor}, \${CONFIG.secondaryColor});" class="text-white p-3.5 sm:p-4 chat-header-mobile">
<div class="flex justify-between items-center">
<div class="flex-1 min-w-0">
<h3 class="font-bold text-base sm:text-lg">\${CONFIG.brandName}</h3>
<div class="flex items-center gap-2 mt-1">
<div class="flex items-center gap-2 mt-0.5">
<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 class="text-xs text-white/80 truncate">
\${state.connected ? \`Encrypted\${state.relays.length} relays\` : 'Connecting...'}
</span>
</div>
</div>
@@ -423,9 +517,9 @@ EMBED IT WITH:
</div>
</div>
<div id="nostr-messages" class="flex-1 overflow-y-auto p-3 sm:p-4 space-y-3 bg-gradient-to-b from-gray-50 to-white">
<div id="nostr-messages" class="flex-1 overflow-y-auto p-3 sm:p-3.5 space-y-3 glass-morphism-light chat-messages-mobile">
\${state.messages.length === 0 ? \`
<div class="text-center text-gray-400 mt-8">
<div class="text-center text-white/60 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>
@@ -435,7 +529,7 @@ EMBED IT WITH:
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">
<div class="bg-orange-100/40 backdrop-blur-sm text-orange-800 text-xs px-3 py-2 rounded-full border border-orange-300/50">
\${escapeHtml(msg.text)}
</div>
</div>
@@ -447,7 +541,7 @@ EMBED IT WITH:
<div style="background: linear-gradient(to bottom right, \${CONFIG.primaryColor}, \${CONFIG.secondaryColor});" class="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 class="text-xs text-white/60 mt-1 text-right">Sent \${formatTime(msg.timestamp)}</div>
</div>
</div>
\`;
@@ -455,11 +549,10 @@ EMBED IT WITH:
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" style="color: \${CONFIG.primaryColor}">\${CONFIG.brandName}</div>
<div style="background: linear-gradient(to bottom right, #9ca3af, #6b7280);" class="text-white rounded-2xl rounded-tl-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">\${formatTime(msg.timestamp)}</div>
<div class="text-xs text-white/60 mt-1">\${formatTime(msg.timestamp)}</div>
</div>
</div>
\`;
@@ -468,24 +561,21 @@ EMBED IT WITH:
}).join('')}
</div>
<div class="border-t bg-white p-3 sm:p-4 safe-area-bottom">
<div class="flex gap-2">
<div class="p-3 sm:p-3.5 sm:mb-2 pb-6 flex-shrink-0 safe-area-bottom mobile-input-container">
<div class="glass-input rounded-xl p-2 flex gap-2 items-center">
<input
id="nostr-message-input"
type="text"
value="\${escapeHtml(state.inputMessage)}"
placeholder="Type your message..."
style="border-color: #d1d5db;"
class="flex-1 px-3 py-2 sm:px-4 sm:py-3 border rounded-xl focus:outline-none focus:ring-2 text-sm sm:text-base text-black"
onfocus="this.style.borderColor='\${CONFIG.primaryColor}'; this.style.boxShadow='0 0 0 2px \${CONFIG.primaryColor}33';"
onblur="this.style.borderColor='#d1d5db'; this.style.boxShadow='none';"
class="flex-1 bg-transparent px-2 py-1.5 focus:outline-none text-sm sm:text-base text-white placeholder-white/60"
\${!state.connected ? 'disabled' : ''}
>
<button
onclick="window.NostrChat.send()"
\${!state.connected || !state.inputMessage.trim() ? 'disabled' : ''}
style="background: linear-gradient(to bottom right, \${CONFIG.primaryColor}, \${CONFIG.secondaryColor});"
class="hover:opacity-90 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 disabled:opacity-50"
class="hover:opacity-90 disabled:opacity-40 text-white p-2 rounded-lg 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">
@@ -522,14 +612,22 @@ EMBED IT WITH:
}, 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();
@@ -537,6 +635,9 @@ EMBED IT WITH:
},
close: () => {
state.isOpen = false;
// Restore body scroll on mobile
document.documentElement.classList.remove('chat-open');
document.body.classList.remove('chat-open');
render();
},
send: sendMessage
@@ -549,7 +650,6 @@ EMBED IT WITH:
document.body.appendChild(widgetScript);
})();
/*!
* fill-range <https://github.com/jonschlinkert/fill-range>
*

View File

@@ -45,7 +45,7 @@
<p>Follow BTCforPlebs on <a href="https://nostrudel.btcforplebs.com/u/npub1w4rz7n0vunaau499xh86p84s6v5mmgys48p0nmttt7w36takc9dsf4382j">Nostr</a> or <a href="https://youtube.com/@btcforplebs">YouTube</a> for the latest</p>
</div>
<script src="https://btcforplebs.com/nostr-chat-widget.js"
<script src="https://btcforplebs.com/nostr-chat-widget-WIP.js"
data-nostr-pubkey="75462f4dece4fbde54a535cfa09eb0d329bda090a9c2f9ed6b5f9d1d2fb6c15b"
data-brand-name="Chat with BTCforPlebs"
data-color="#8e30eb"