Files
BTCforPlebs.com/public/dashboard/index.html
2025-10-21 15:43:29 -04:00

2650 lines
94 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="theme-color" content="#1a1a2e">
<title>Nostr DM Dashboard</title>
<link rel="apple-touch-icon" sizes="512x512" href="./icon.png">
<meta name="apple-mobile-web-app-title" content="Nostr DMs">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<script src="https://unpkg.com/nostr-tools@2.7.2/lib/nostr.bundle.js"></script>
<style>
/* ============================================
CSS VARIABLES - All colors and values in one place
============================================ */
:root {
--primary: #fdad01;
--primary-dark: #ff9500;
--primary-gradient: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
--bg-dark: #1a1a2e;
--bg-darker: #16213e;
--bg-overlay: rgba(30, 30, 40, 0.7);
--bg-panel: rgba(25, 25, 35, 0.6);
--bg-header: rgba(20, 20, 30, 0.6);
--bg-input: rgba(40, 40, 50, 0.6);
--text-primary: #ffffff;
--text-secondary: rgba(255, 255, 255, 0.7);
--text-dim: rgba(255, 255, 255, 0.45);
--text-dimmer: rgba(255, 255, 255, 0.3);
--border: rgba(255, 255, 255, 0.08);
--border-light: rgba(255, 255, 255, 0.12);
--radius: 12px;
--radius-lg: 16px;
--radius-xl: 20px;
--shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 20px 60px rgba(0, 0, 0, 0.5);
}
/* ============================================
BASE STYLES
============================================ */
*, *::before, *::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
height: 100%;
overflow: hidden;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg-dark);
color: var(--text-primary);
height: 100%;
min-height: 100vh;
min-height: -webkit-fill-available;
overflow: hidden;
-webkit-text-size-adjust: 100%;
touch-action: manipulation;
}
/* ============================================
CONTAINER
============================================ */
.container {
background: linear-gradient(135deg, var(--bg-dark) 0%, var(--bg-darker) 100%);
width: 100%;
height: 100%;
min-height: 100vh;
min-height: -webkit-fill-available;
display: flex;
flex-direction: column;
position: relative;
overflow-y: auto;
}
@media (min-width: 768px) {
body {
padding: 20px;
background: linear-gradient(135deg, var(--bg-dark) 0%, var(--bg-darker) 100%);
}
.container {
background: var(--bg-overlay);
backdrop-filter: blur(20px);
border: 1px solid var(--border);
box-shadow: var(--shadow-lg);
border-radius: 24px;
width: 95vw;
height: 95vh;
max-width: 1600px;
margin: 0 auto;
min-height: unset;
}
}
/* ============================================
HEADER
============================================ */
.header {
background: var(--bg-header);
backdrop-filter: blur(10px);
padding: 16px 20px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.header h1 {
font-size: 18px;
font-weight: 600;
letter-spacing: -0.5px;
}
.header-actions {
display: flex;
gap: 8px;
align-items: center;
}
@media (min-width: 768px) {
.header { padding: 20px 24px; }
.header h1 { font-size: 20px; }
}
/* ============================================
BUTTONS
============================================ */
.btn {
background: var(--primary-gradient);
color: #000;
border: none;
padding: 12px 20px;
border-radius: var(--radius);
font-size: 14px;
font-weight: 700;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 4px 16px rgba(253, 173, 1, 0.3);
}
.btn:hover {
transform: translateY(-1px);
box-shadow: 0 6px 20px rgba(253, 173, 1, 0.4);
}
.btn:active {
transform: translateY(0);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.btn-secondary {
background: rgba(60, 60, 70, 0.8);
color: var(--text-primary);
border: 1px solid var(--border-light);
box-shadow: none;
}
.btn-secondary:hover {
background: rgba(70, 70, 80, 0.9);
box-shadow: var(--shadow);
}
.btn-small {
padding: 8px 16px;
font-size: 13px;
}
.btn-icon {
padding: 10px;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
}
@media (min-width: 768px) {
.btn { padding: 14px 28px; font-size: 15px; }
.btn-small { padding: 10px 20px; font-size: 14px; }
}
/* ============================================
INPUTS
============================================ */
input[type="text"] {
width: 100%;
padding: 12px 16px;
border: 1px solid var(--border-light);
border-radius: var(--radius);
font-size: 14px;
background: var(--bg-input);
color: var(--text-primary);
transition: all 0.3s ease;
}
input::placeholder {
color: var(--text-dimmer);
}
input:focus {
outline: none;
border-color: rgba(253, 173, 1, 0.5);
background: rgba(40, 40, 50, 0.9);
box-shadow: 0 0 0 3px rgba(253, 173, 1, 0.1);
}
/* ============================================
LOGIN SECTION
============================================ */
.login-section {
padding: 40px 20px;
text-align: center;
background: var(--bg-panel);
border-radius: var(--radius-lg);
margin: 20px;
}
.login-section h2 {
margin-bottom: 10px;
font-size: 24px;
}
.input-group {
margin-bottom: 20px;
text-align: left;
}
.input-group label {
display: block;
margin-bottom: 8px;
color: var(--text-secondary);
font-weight: 500;
}
.instructions {
background: rgba(253, 173, 1, 0.1);
border-left: 4px solid var(--primary);
padding: 15px;
margin: 20px 0;
border-radius: 8px;
text-align: left;
color: var(--text-secondary);
}
.instructions strong {
color: var(--primary);
}
.instructions a {
color: var(--primary);
text-decoration: none;
}
.instructions a:hover {
text-decoration: underline;
}
.instructions ol {
margin: 10px 0 10px 20px;
}
/* ============================================
DASHBOARD
============================================ */
.dashboard {
display: none;
flex: 1;
overflow: hidden;
flex-direction: column;
}
.dashboard.active {
display: flex;
}
.dashboard-content {
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
position: relative;
}
@media (min-width: 768px) {
.dashboard-content {
flex-direction: row;
}
}
/* ============================================
SIDEBAR
============================================ */
.sidebar {
width: 100%;
display: flex;
flex-direction: column;
background: var(--bg-panel);
backdrop-filter: blur(10px);
height: 100%;
position: absolute;
inset: 0;
z-index: 2;
transition: transform 0.3s ease;
}
.sidebar.hidden-mobile {
transform: translateX(-100%);
}
@media (min-width: 768px) {
.sidebar {
width: 380px;
min-width: 380px;
border-right: 1px solid var(--border);
position: relative;
transform: none !important;
}
}
.sidebar-header {
padding: 16px 20px;
border-bottom: 1px solid var(--border);
background: var(--bg-header);
flex-shrink: 0;
}
.sidebar-header h2 {
font-size: 18px;
margin-bottom: 12px;
font-weight: 600;
letter-spacing: -0.5px;
}
@media (min-width: 768px) {
.sidebar-header {
padding: 24px 24px 20px;
}
.sidebar-header h2 {
font-size: 20px;
margin-bottom: 16px;
}
}
.conversations-list {
flex: 1;
overflow-y: auto;
}
.conversations-list::-webkit-scrollbar {
width: 6px;
}
.conversations-list::-webkit-scrollbar-track {
background: transparent;
}
.conversations-list::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
border-radius: 3px;
}
.conversation-item {
padding: 16px 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
cursor: pointer;
transition: background 0.2s ease;
position: relative;
}
.conversation-item::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background: var(--primary);
transform: scaleY(0);
transition: transform 0.2s ease;
}
.conversation-item:hover {
background: rgba(40, 40, 50, 0.4);
}
.conversation-item.active {
background: rgba(253, 173, 1, 0.08);
}
.conversation-item.active::before {
transform: scaleY(1);
}
.conversation-item .contact-name {
font-weight: 600;
margin-bottom: 6px;
font-size: 14px;
display: flex;
align-items: center;
justify-content: space-between;
}
.conversation-item .last-message {
font-size: 13px;
color: var(--text-dim);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.conversation-item .timestamp {
font-size: 11px;
color: var(--text-dimmer);
margin-top: 4px;
}
.unread-badge {
background: var(--primary);
color: #000;
font-size: 11px;
font-weight: 700;
padding: 4px 10px;
border-radius: 12px;
min-width: 24px;
text-align: center;
}
@media (min-width: 768px) {
.conversation-item {
padding: 20px 24px;
}
.conversation-item .contact-name {
font-size: 15px;
margin-bottom: 8px;
}
.conversation-item .last-message {
font-size: 14px;
}
.conversation-item .timestamp {
font-size: 12px;
margin-top: 6px;
}
}
/* ============================================
CHAT AREA
============================================ */
.chat-area {
flex: 1;
display: flex;
flex-direction: column;
background: rgba(20, 20, 30, 0.3);
position: absolute;
inset: 0;
z-index: 3;
transform: translateX(100%);
transition: transform 0.3s ease;
}
.chat-area.visible-mobile {
transform: translateX(0);
}
@media (min-width: 768px) {
.chat-area {
position: relative;
transform: none !important;
}
}
.chat-header {
padding: 16px 20px;
border-bottom: 1px solid var(--border);
background: var(--bg-header);
backdrop-filter: blur(10px);
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
}
.back-button {
background: rgba(60, 60, 70, 0.8);
border: 1px solid var(--border-light);
color: var(--text-primary);
padding: 8px 12px;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
transition: background 0.2s;
}
.back-button:hover {
background: rgba(70, 70, 80, 0.9);
}
@media (min-width: 768px) {
.back-button { display: none; }
.chat-header { padding: 24px 32px; }
}
.contact-info h3 {
font-size: 16px;
margin-bottom: 4px;
font-weight: 600;
letter-spacing: -0.3px;
}
.npub {
font-size: 11px;
color: var(--text-dimmer);
font-family: 'SF Mono', Monaco, monospace;
}
@media (min-width: 768px) {
.contact-info h3 {
font-size: 18px;
margin-bottom: 6px;
}
.npub {
font-size: 13px;
}
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 20px 16px;
background: rgba(18, 18, 28, 0.4);
display: flex;
flex-direction: column;
gap: 12px;
}
.chat-messages::-webkit-scrollbar {
width: 8px;
}
.chat-messages::-webkit-scrollbar-track {
background: transparent;
}
.chat-messages::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
border-radius: 4px;
}
@media (min-width: 768px) {
.chat-messages {
padding: 32px;
gap: 16px;
}
}
.message {
padding: 14px 16px;
border-radius: var(--radius-lg);
max-width: 85%;
word-wrap: break-word;
animation: slideIn 0.3s ease;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.message.sent {
background: rgba(60, 60, 70, 0.7);
backdrop-filter: blur(10px);
border: 1px solid var(--border-light);
margin-left: auto;
align-self: flex-end;
}
.message.received {
background: var(--primary-gradient);
color: #000;
box-shadow: 0 4px 20px rgba(253, 173, 1, 0.25);
font-weight: 500;
align-self: flex-start;
}
.message .content {
font-size: 15px;
line-height: 1.5;
}
.message .time {
font-size: 11px;
opacity: 0.5;
margin-top: 8px;
font-weight: 500;
}
.message.received .time {
opacity: 0.6;
}
@media (min-width: 768px) {
.message {
padding: 16px 20px;
border-radius: 18px;
max-width: 65%;
}
}
.chat-input-area {
padding: 16px 20px;
background: var(--bg-header);
backdrop-filter: blur(10px);
border-top: 1px solid var(--border);
display: flex;
gap: 10px;
align-items: center;
flex-shrink: 0;
}
.chat-input-area input {
font-size: 16px; /* Prevents zoom on iOS */
}
@media (min-width: 768px) {
.chat-input-area {
padding: 24px 32px;
gap: 12px;
}
.chat-input-area input {
padding: 16px 20px;
border-radius: 14px;
}
}
/* ============================================
UTILITY CLASSES
============================================ */
.hidden {
display: none !important;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: rgba(255, 255, 255, 0.35);
gap: 16px;
}
.empty-state h3 {
color: rgba(255, 255, 255, 0.5);
font-size: 20px;
font-weight: 600;
}
.empty-state-icon {
font-size: 64px;
opacity: 0.3;
}
.spinner {
border: 3px solid rgba(255, 255, 255, 0.1);
border-top: 3px solid var(--primary);
border-radius: 50%;
width: 20px;
height: 20px;
animation: spin 1s linear infinite;
display: inline-block;
margin-right: 10px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.loading-overlay {
position: absolute;
inset: 0;
background: rgba(25, 25, 35, 0.95);
backdrop-filter: blur(10px);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
font-size: 15px;
color: rgba(255, 255, 255, 0.6);
}
.status {
padding: 10px 20px;
font-size: 13px;
text-align: center;
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
}
.status.success {
background: #d4edda;
color: #155724;
}
.status.error {
background: #f8d7da;
color: #721c24;
}
.cache-info {
font-size: 11px;
color: var(--text-dimmer);
margin-top: 8px;
text-align: center;
}
.notification-badge {
background: #ff3b30;
color: white;
font-size: 11px;
font-weight: 700;
padding: 2px 6px;
border-radius: 10px;
min-width: 18px;
text-align: center;
position: absolute;
top: -4px;
right: -4px;
}
/* ============================================
MODAL
============================================ */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.8);
backdrop-filter: blur(10px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 20px;
}
.modal {
background: rgba(30, 30, 40, 0.95);
border: 1px solid var(--border);
border-radius: var(--radius-xl);
padding: 32px;
max-width: 500px;
width: 100%;
box-shadow: var(--shadow-lg);
}
.modal h3 {
color: var(--primary);
margin-bottom: 16px;
font-size: 22px;
}
.modal p {
color: var(--text-secondary);
margin-bottom: 12px;
line-height: 1.6;
}
.modal .btn-group {
display: flex;
gap: 12px;
margin-top: 24px;
}
.modal .btn-group .btn {
flex: 1;
}
</style>
</head>
<body>
<div class="container">
<div id="status" class="status hidden"></div>
<div id="loginSection" class="login-section">
<h2>📬 Nostr Dashboard</h2>
<p style="color: rgba(255, 255, 255, 0.7); margin-bottom: 30px;">Connect with your Nostr bunker to access your DMs</p>
<div class="input-group">
<label for="bunkerInput">Bunker URI</label>
<input type="text" id="bunkerInput" placeholder="bunker://pubkey?relay=wss://...&secret=...">
</div>
<button class="btn" onclick="connectWithBunker()" id="connectBtn">Connect to Bunker</button>
<div id="bunkerWaiting" class="hidden" style="margin-top: 20px; padding: 20px; background: rgba(253, 173, 1, 0.1); border-radius: 12px; text-align: center;">
<div class="spinner"></div>
<p style="color: rgba(255, 255, 255, 0.8); margin-top: 15px; font-size: 15px;">Waiting for approval from your remote signer...</p>
<p style="color: rgba(255, 255, 255, 0.5); margin-top: 8px; font-size: 13px;">Check your bunker device to approve this connection</p>
</div>
<div class="instructions">
<strong>How to connect:</strong>
<ol>
<li>Get your bunker URI from your remote signer (like <a href="https://nsec.app" target="_blank">nsec.app</a>, Amber, or <a href="https://nsec.btcforplebs.com" target="_blank">nsec.btcforplebs.com</a>)</li>
<li>Paste the bunker:// URI above</li>
<li>Click "Connect to Bunker"</li>
<li>Approve the connection request on your remote signer device</li>
<li>Your DMs will load automatically once approved</li>
</ol>
<div style="margin-top: 15px; padding: 12px; background: rgba(253, 173, 1, 0.15); border-radius: 8px;">
<strong style="color: #fdad01;">💡 What's a bunker?</strong>
<p style="margin-top: 8px; font-size: 13px;">A bunker is a remote signer that keeps your private keys secure. Your keys never leave the bunker - all signing happens remotely via encrypted Nostr messages.</p>
</div>
</div>
</div>
<div id="dashboard" class="dashboard">
<div class="header">
<h1 id="dashboardHeader">Dashboard</h1>
<div class="header-actions">
<button class="btn btn-secondary btn-small" onclick="showNotificationSettings()" id="notifBtn" title="Notification Settings">
<span id="notifIcon">🔔</span>
</button>
<!-- Notifications Panel -->
<div id="notificationsPanel" class="hidden" style="position: absolute; top: 50px; right: 20px; width: 300px; max-height: 400px; overflow-y: auto; background: rgba(20,20,30,0.95); backdrop-filter: blur(10px); border: 1px solid rgba(255,255,255,0.1); border-radius: 12px; padding: 12px; z-index: 20;">
<ul id="notifList" style="list-style: none; margin: 0; padding: 0;"></ul>
</div>
<button class="btn btn-secondary btn-small" onclick="disconnect()">Logout</button>
</div>
</div>
<div class="dashboard-content">
<div class="sidebar">
<div class="sidebar-header">
<h2>Conversations</h2>
<input type="text" class="search-box" id="searchBox" placeholder="Search conversations..." oninput="filterConversations()">
<div class="cache-info" id="cacheInfo"></div>
<button class="btn btn-small btn-secondary" id="markAllReadBtn" style="margin-top: 10px; width: 100%;" onclick="markAllAsRead()">Mark All as Read</button>
</div>
<div class="conversations-list" id="conversationsList">
<div class="loading-overlay">
<div class="spinner"></div>
Loading DMs...
</div>
</div>
</div>
<div class="chat-area">
<div id="emptyState" class="empty-state">
<div class="empty-state-icon">💬</div>
<h3>Select a conversation</h3>
<p>Choose a contact from the sidebar to view messages</p>
</div>
<div id="chatView" class="hidden" style="display: flex; flex-direction: column; height: 100%;">
<div class="chat-header">
<button class="back-button" onclick="backToConversations()">
<span></span>
<span>Back</span>
</button>
<div class="contact-info">
<h3 id="chatContactName">Contact</h3>
<div class="npub" id="chatContactNpub"></div>
</div>
<div style="width: 60px;"></div>
</div>
<div class="chat-messages" id="chatMessages"></div>
<div class="chat-input-area">
<input type="text" id="messageInput" placeholder="Type a message..." onkeypress="handleKeyPress(event)">
<button class="btn" onclick="sendDM()">Send</button>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
const { SimplePool, nip19, getPublicKey, generateSecretKey, finalizeEvent, nip44, nip04 } = window.NostrTools;
// ============================================
// 🔧 STATE MANAGEMENT
// ============================================
class AppState {
constructor() {
this.pool = null;
this.userPubkey = null;
this.userPrivkey = null;
this.nip46Connection = null;
this.currentRelays = [
'wss://relay.primal.net',
'wss://relay.btcforplebs.com',
'wss://relay.damus.io',
'wss://nos.lol'
];
this.subscriptions = [];
this.conversations = new Map();
this.currentChatPubkey = null;
this.lastSyncTimestamp = 0;
this.selectedConversationIndex = -1;
this.conversationsList = [];
this.profileCache = new Map();
this.isReconnecting = false;
this.reconnectAttempts = 0;
this.healthCheckInterval = null;
this.autoSaveInterval = null;
this.pendingMessages = new Set();
this.connectionActive = false;
this.renderThrottle = null;
}
reset() {
this.closeAllSubscriptions();
if (this.renderThrottle) clearTimeout(this.renderThrottle);
if (this.pool) this.pool.close(this.currentRelays);
if (this.nip46Connection) this.nip46Connection.close();
if (this.healthCheckInterval) clearInterval(this.healthCheckInterval);
if (this.autoSaveInterval) clearInterval(this.autoSaveInterval);
this.pool = null;
this.userPubkey = null;
this.userPrivkey = null;
this.nip46Connection = null;
this.subscriptions = [];
this.conversations.clear();
this.currentChatPubkey = null;
this.lastSyncTimestamp = 0;
this.profileCache.clear();
this.pendingMessages.clear();
this.connectionActive = false;
}
closeAllSubscriptions() {
this.subscriptions.forEach(sub => {
try {
sub.close();
} catch (e) {
console.error('Error closing subscription:', e);
}
});
this.subscriptions = [];
}
}
const state = new AppState();
// ============================================
// 🔐 CACHE ENCRYPTION
// ============================================
const CACHE_VERSION = 2;
const CACHE_KEY_PREFIX = 'nostr_dm_cache_';
const MAX_CACHE_AGE = 7 * 24 * 60 * 60 * 1000;
const CacheEncryption = {
async deriveKey(pubkey) {
const encoder = new TextEncoder();
const keyMaterial = await crypto.subtle.importKey(
'raw',
encoder.encode(pubkey),
'PBKDF2',
false,
['deriveKey']
);
const salt = encoder.encode('nostr-dm-cache-v1');
return crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt: salt,
iterations: 100000,
hash: 'SHA-256'
},
keyMaterial,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt']
);
},
async encrypt(data, pubkey) {
try {
const key = await this.deriveKey(pubkey);
const encoder = new TextEncoder();
const dataBuffer = encoder.encode(JSON.stringify(data));
const iv = crypto.getRandomValues(new Uint8Array(12));
const encryptedBuffer = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv: iv },
key,
dataBuffer
);
const combined = new Uint8Array(iv.length + encryptedBuffer.byteLength);
combined.set(iv, 0);
combined.set(new Uint8Array(encryptedBuffer), iv.length);
return btoa(String.fromCharCode(...combined));
} catch (error) {
console.error('Encryption failed:', error);
throw error;
}
},
async decrypt(encryptedData, pubkey) {
try {
const key = await this.deriveKey(pubkey);
const combined = new Uint8Array(
atob(encryptedData).split('').map(c => c.charCodeAt(0))
);
const iv = combined.slice(0, 12);
const data = combined.slice(12);
const decryptedBuffer = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: iv },
key,
data
);
const decoder = new TextDecoder();
return JSON.parse(decoder.decode(decryptedBuffer));
} catch (error) {
console.error('Decryption failed:', error);
throw error;
}
}
};
// ============================================
// 💾 CACHE MANAGEMENT
// ============================================
async function saveMessagesToCache(pubkey) {
try {
const cacheData = {
version: CACHE_VERSION,
timestamp: Date.now(),
conversations: Array.from(state.conversations.entries()).map(([key, value]) => ({
pubkey: key,
messages: value.messages,
lastMessage: value.lastMessage,
unread: value.unread,
lastReadTimestamp: value.lastReadTimestamp || 0
})),
lastSyncTimestamp: state.lastSyncTimestamp
};
const encrypted = await CacheEncryption.encrypt(cacheData, pubkey);
localStorage.setItem(CACHE_KEY_PREFIX + pubkey + '_v' + CACHE_VERSION, encrypted);
console.log('💾🔒 Saved', state.conversations.size, 'conversations to encrypted cache');
updateCacheInfo();
} catch (error) {
console.error('Failed to save cache:', error);
}
}
async function loadMessagesFromCache(pubkey) {
try {
const cached = localStorage.getItem(CACHE_KEY_PREFIX + pubkey + '_v' + CACHE_VERSION);
if (!cached) {
console.log('No cache found');
return false;
}
const cacheData = await CacheEncryption.decrypt(cached, pubkey);
if (cacheData.version !== CACHE_VERSION) {
console.log('Cache version mismatch');
return false;
}
const cacheAge = Date.now() - cacheData.timestamp;
if (cacheAge > MAX_CACHE_AGE) {
console.log('Cache too old');
return false;
}
state.conversations.clear();
cacheData.conversations.forEach(conv => {
state.conversations.set(conv.pubkey, {
messages: conv.messages,
lastMessage: conv.lastMessage,
unread: conv.unread || 0,
lastReadTimestamp: conv.lastReadTimestamp || 0
});
});
state.lastSyncTimestamp = cacheData.lastSyncTimestamp || 0;
console.log('✅🔓 Loaded', state.conversations.size, 'conversations from encrypted cache');
console.log('Last sync:', new Date(state.lastSyncTimestamp * 1000).toLocaleString());
// Force render on mobile after cache load
setTimeout(() => {
renderConversations();
updateCacheInfo();
}, 100);
return true;
} catch (error) {
console.error('Failed to load cache:', error);
return false;
}
}
function updateCacheInfo() {
const info = document.getElementById('cacheInfo');
if (!info) return;
if (state.conversations.size > 0) {
const msgCount = Array.from(state.conversations.values())
.reduce((sum, conv) => sum + conv.messages.length, 0);
const lastSync = state.lastSyncTimestamp > 0
? new Date(state.lastSyncTimestamp * 1000).toLocaleTimeString()
: 'Never';
info.textContent = `${state.conversations.size} chats • ${msgCount} messages • Last sync: ${lastSync}`;
} else {
info.textContent = '';
}
}
// ============================================
// 👤 PROFILE MANAGEMENT
// ============================================
function getProfileCacheKey(pubkey) {
return `nostr_profile_${pubkey}`;
}
async function fetchProfile(pubkey) {
const cached = state.profileCache.get(pubkey);
if (cached) return cached;
try {
const stored = localStorage.getItem(getProfileCacheKey(pubkey));
if (stored) {
const profile = JSON.parse(stored);
const age = Date.now() - profile.timestamp;
if (age < 24 * 60 * 60 * 1000) {
state.profileCache.set(pubkey, profile);
return profile;
}
}
} catch (e) {
console.error('Error loading cached profile:', e);
}
try {
const profileData = await new Promise((resolve) => {
let resolved = false;
const timeout = setTimeout(() => {
if (!resolved) {
resolved = true;
resolve(null);
}
}, 5000);
const sub = state.pool.subscribeMany(
state.currentRelays,
[{ kinds: [0], authors: [pubkey], limit: 1 }],
{
onevent: (event) => {
if (!resolved) {
resolved = true;
clearTimeout(timeout);
sub.close();
try {
const content = JSON.parse(event.content);
resolve(content);
} catch (e) {
resolve(null);
}
}
},
oneose: () => {
if (!resolved) {
resolved = true;
clearTimeout(timeout);
resolve(null);
}
}
}
);
});
if (profileData) {
const profile = {
name: profileData.name || profileData.display_name || null,
displayName: profileData.display_name || profileData.name || null,
picture: profileData.picture || null,
nip05: profileData.nip05 || null,
timestamp: Date.now()
};
state.profileCache.set(pubkey, profile);
try {
localStorage.setItem(getProfileCacheKey(pubkey), JSON.stringify(profile));
} catch (e) {
console.error('Error saving profile to cache:', e);
}
return profile;
}
} catch (error) {
console.error('Error fetching profile:', error);
}
return null;
}
function getDisplayName(pubkey) {
const profile = state.profileCache.get(pubkey);
if (profile && (profile.displayName || profile.name)) {
return profile.displayName || profile.name;
}
const npub = nip19.npubEncode(pubkey);
return npub.substring(0, 12) + '...' + npub.substring(npub.length - 4);
}
async function fetchAllProfiles() {
const pubkeys = Array.from(state.conversations.keys());
console.log('Fetching profiles for', pubkeys.length, 'contacts...');
const batchSize = 5;
for (let i = 0; i < pubkeys.length; i += batchSize) {
const batch = pubkeys.slice(i, i + batchSize);
await Promise.all(batch.map(pubkey => fetchProfile(pubkey)));
renderConversations();
}
console.log('✅ Profile fetching complete');
}
// ============================================
// 🔌 NIP-46 CONNECTION
// ============================================
class NIP46Client {
constructor(appName) {
this.localPrivkey = generateSecretKey();
this.localPubkey = getPublicKey(this.localPrivkey);
this.appName = appName;
this.secret = this.generateSecret();
this.relays = state.currentRelays;
this.pendingRequests = new Map();
this.remotePubkey = null;
this.connected = false;
this.conversationKey = null;
this.pool = null;
this.sub = null;
}
generateSecret() {
return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
}
async handleResponse(event) {
if (!this.remotePubkey) {
this.remotePubkey = event.pubkey;
this.conversationKey = nip44.v2.utils.getConversationKey(this.localPrivkey, this.remotePubkey);
}
if (event.pubkey !== this.remotePubkey) return;
try {
const decrypted = nip44.v2.decrypt(event.content, this.conversationKey);
const response = JSON.parse(decrypted);
if (response.result === this.secret || response.result === 'ack') {
this.connected = true;
return;
}
const pending = this.pendingRequests.get(response.id);
if (pending) {
this.pendingRequests.delete(response.id);
if (response.error) {
pending.reject(new Error(response.error));
} else {
pending.resolve(response.result);
}
}
} catch (error) {
console.error('Decrypt error:', error);
}
}
async sendRequest(method, params) {
if (!this.connected) throw new Error('Not connected');
const id = Math.random().toString(36).substring(7);
const request = { id, method, params };
const encrypted = nip44.v2.encrypt(JSON.stringify(request), this.conversationKey);
const event = finalizeEvent({
kind: 24133,
created_at: Math.floor(Date.now() / 1000),
tags: [['p', this.remotePubkey]],
content: encrypted
}, this.localPrivkey);
await this.pool.publish(this.relays, event);
return new Promise((resolve, reject) => {
this.pendingRequests.set(id, { resolve, reject });
setTimeout(() => {
if (this.pendingRequests.has(id)) {
this.pendingRequests.delete(id);
reject(new Error('Timeout'));
}
}, 30000);
});
}
async getPublicKey() {
return await this.sendRequest('get_public_key', []);
}
async nip04Encrypt(pubkey, plaintext) {
return await this.sendRequest('nip04_encrypt', [pubkey, plaintext]);
}
async nip04Decrypt(pubkey, ciphertext) {
return await this.sendRequest('nip04_decrypt', [pubkey, ciphertext]);
}
async signEvent(event) {
const signed = await this.sendRequest('sign_event', [JSON.stringify(event)]);
return JSON.parse(signed);
}
close() {
if (this.sub) this.sub.close();
if (this.pool) this.pool.close(this.relays);
}
}
// ============================================
// 🔄 CONNECTION MANAGEMENT
// ============================================
async function reconnectAndSync() {
if (state.isReconnecting) {
console.log('Already reconnecting, skipping...');
return;
}
state.isReconnecting = true;
state.reconnectAttempts++;
const MAX_RECONNECT_ATTEMPTS = 3;
try {
console.log(`🔌 Reconnect attempt ${state.reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS}`);
showStatus('Reconnecting...', 'info');
// Close old subscriptions
state.closeAllSubscriptions();
// Recreate pool
if (state.nip46Connection && state.nip46Connection.pool) {
try {
await state.nip46Connection.pool.close(state.currentRelays);
} catch (e) {
console.error('Error closing old pool:', e);
}
state.nip46Connection.pool = new SimplePool();
state.pool = state.nip46Connection.pool;
} else if (state.pool) {
try {
await state.pool.close(state.currentRelays);
} catch (e) {
console.error('Error closing pool:', e);
}
state.pool = new SimplePool();
}
// Sync messages
const syncFilters = [
{ kinds: [4], authors: [state.userPubkey], since: state.lastSyncTimestamp, limit: 200 },
{ kinds: [4], '#p': [state.userPubkey], since: state.lastSyncTimestamp, limit: 200 }
];
console.log('📡 Fetching messages since:', new Date(state.lastSyncTimestamp * 1000).toLocaleString());
let newMessageCount = 0;
let syncComplete = false;
let pendingDecrypts = 0;
const syncSub = state.pool.subscribeMany(
state.currentRelays,
syncFilters,
{
onevent: async (event) => {
pendingDecrypts++;
const wasNew = await processDM(event);
pendingDecrypts--;
if (wasNew) {
newMessageCount++;
console.log(`💬 New message #${newMessageCount}`);
}
if (event.created_at > state.lastSyncTimestamp) {
state.lastSyncTimestamp = event.created_at;
}
},
oneose: () => {
console.log(`✅ Sync complete, waiting for ${pendingDecrypts} decryptions...`);
const checkCompletion = setInterval(() => {
if (pendingDecrypts === 0) {
clearInterval(checkCompletion);
syncComplete = true;
// Update timestamp to now
const now = Math.floor(Date.now() / 1000);
if (state.lastSyncTimestamp < now) {
state.lastSyncTimestamp = now;
}
if (newMessageCount > 0) {
renderConversations();
if (state.currentChatPubkey) {
renderChat(state.currentChatPubkey);
}
saveMessagesToCache(state.userPubkey);
showStatus(`Synced ${newMessageCount} new messages`, 'success');
} else {
showStatus('Up to date', 'success');
}
// Keep subscription open
state.subscriptions.push(syncSub);
state.connectionActive = true;
state.reconnectAttempts = 0;
state.isReconnecting = false;
console.log('🎯 Now listening for real-time messages...');
}
}, 100);
}
}
);
// Timeout handler
setTimeout(() => {
if (!syncComplete) {
console.warn('⚠️ Sync timeout');
syncSub.close();
if (state.reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
setTimeout(() => reconnectAndSync(), 2000 * state.reconnectAttempts);
} else {
showStatus('Connection issues, please refresh', 'error');
state.isReconnecting = false;
state.reconnectAttempts = 0;
state.connectionActive = false;
}
}
}, 30000);
} catch (error) {
console.error('Reconnect error:', error);
state.isReconnecting = false;
state.connectionActive = false;
if (state.reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
setTimeout(() => reconnectAndSync(), 2000 * state.reconnectAttempts);
} else {
showStatus('Connection failed, please refresh', 'error');
state.reconnectAttempts = 0;
}
}
}
function startHealthCheck() {
if (state.healthCheckInterval) {
clearInterval(state.healthCheckInterval);
}
state.healthCheckInterval = setInterval(() => {
if (!state.userPubkey || document.visibilityState !== 'visible') return;
const timeSinceLastSync = Math.floor(Date.now() / 1000) - state.lastSyncTimestamp;
// If no updates in 5 minutes while visible, reconnect
if (timeSinceLastSync > 300 && state.connectionActive) {
console.log('⚠️ No updates in 5 minutes, reconnecting...');
reconnectAndSync();
}
}, 60000); // Check every minute
}
// ============================================
// 💬 MESSAGE PROCESSING
// ============================================
async function processDM(event) {
// Prevent duplicate processing
if (state.pendingMessages.has(event.id)) {
return false;
}
state.pendingMessages.add(event.id);
try {
const otherPubkey = event.pubkey === state.userPubkey
? event.tags.find(t => t[0] === 'p')?.[1]
: event.pubkey;
if (!otherPubkey) {
state.pendingMessages.delete(event.id);
return false;
}
let decryptedContent;
try {
if (state.nip46Connection) {
decryptedContent = await state.nip46Connection.nip04Decrypt(otherPubkey, event.content);
} else {
decryptedContent = await nip04.decrypt(state.userPrivkey, otherPubkey, event.content);
}
} catch (decryptError) {
console.error('Decryption failed for event', event.id, ':', decryptError);
state.pendingMessages.delete(event.id);
return false;
}
if (!state.conversations.has(otherPubkey)) {
state.conversations.set(otherPubkey, {
messages: [],
lastMessage: null,
unread: 0,
lastReadTimestamp: 0
});
}
const conversation = state.conversations.get(otherPubkey);
if (conversation.messages.find(m => m.id === event.id)) {
state.pendingMessages.delete(event.id);
return false;
}
const message = {
id: event.id,
content: decryptedContent,
timestamp: event.created_at,
from: event.pubkey,
isSent: event.pubkey === state.userPubkey
};
const index = conversation.messages.findIndex(m => m.timestamp > message.timestamp);
if (index === -1) {
conversation.messages.push(message);
} else {
conversation.messages.splice(index, 0, message);
}
conversation.lastMessage = conversation.messages[conversation.messages.length - 1];
if (!message.isSent && otherPubkey !== state.currentChatPubkey && message.timestamp > conversation.lastReadTimestamp) {
conversation.unread++;
}
state.pendingMessages.delete(event.id);
return true;
} catch (err) {
console.error('DM processing error:', err);
state.pendingMessages.delete(event.id);
return false;
}
}
async function processDM(event) {
// Prevent duplicate processing
if (state.pendingMessages.has(event.id)) {
return false;
}
state.pendingMessages.add(event.id);
try {
const otherPubkey = event.pubkey === state.userPubkey
? event.tags.find(t => t[0] === 'p')?.[1]
: event.pubkey;
if (!otherPubkey) {
state.pendingMessages.delete(event.id);
return false;
}
let decryptedContent;
try {
if (state.nip46Connection) {
decryptedContent = await state.nip46Connection.nip04Decrypt(otherPubkey, event.content);
} else {
decryptedContent = await nip04.decrypt(state.userPrivkey, otherPubkey, event.content);
}
} catch (decryptError) {
console.error('Decryption failed for event', event.id, ':', decryptError);
state.pendingMessages.delete(event.id);
return false;
}
if (!state.conversations.has(otherPubkey)) {
state.conversations.set(otherPubkey, {
messages: [],
lastMessage: null,
unread: 0,
lastReadTimestamp: 0
});
}
if (conversation.messages.find(m => m.id === event.id + '_temp')) {
// Replace temp message with real one
const tempIndex = conversation.messages.findIndex(m => m.id === event.id + '_temp');
conversation.messages[tempIndex] = {
id: event.id,
content: decryptedContent,
timestamp: event.created_at,
from: event.pubkey,
isSent: event.pubkey === state.userPubkey
};
} else {
// Regular message insertion
conversation.messages.push({
id: event.id,
content: decryptedContent,
timestamp: event.created_at,
from: event.pubkey,
isSent: event.pubkey === state.userPubkey
});
}
const conversation = state.conversations.get(otherPubkey);
if (conversation.messages.find(m => m.id === event.id)) {
state.pendingMessages.delete(event.id);
return false;
}
const message = {
id: event.id,
content: decryptedContent,
timestamp: event.created_at,
from: event.pubkey,
isSent: event.pubkey === state.userPubkey
};
const index = conversation.messages.findIndex(m => m.timestamp > message.timestamp);
if (index === -1) {
conversation.messages.push(message);
} else {
conversation.messages.splice(index, 0, message);
}
conversation.lastMessage = conversation.messages[conversation.messages.length - 1];
if (!message.isSent && otherPubkey !== state.currentChatPubkey && message.timestamp > conversation.lastReadTimestamp) {
conversation.unread++;
}
// Immediately render this conversation update
scheduleRender();
state.pendingMessages.delete(event.id);
return true;
} catch (err) {
console.error('DM processing error:', err);
state.pendingMessages.delete(event.id);
return false;
}
}
// Throttled render to update UI as messages come in
function scheduleRender() {
if (state.renderThrottle) return;
state.renderThrottle = setTimeout(() => {
renderConversations();
updateCacheInfo();
state.renderThrottle = null;
}, 300); // Render every 300ms max during sync
}
// ============================================
// 📤 SENDING MESSAGES
// ============================================
async function sendDM() {
const input = document.getElementById('messageInput');
const content = input.value.trim();
if (!content || !state.currentChatPubkey) return;
try {
showStatus('Sending...', 'info');
let encryptedContent;
if (state.nip46Connection) {
encryptedContent = await state.nip46Connection.nip04Encrypt(state.currentChatPubkey, content);
} else {
encryptedContent = await nip04.encrypt(state.userPrivkey, state.currentChatPubkey, content);
}
const event = {
kind: 4,
created_at: Math.floor(Date.now() / 1000),
tags: [['p', state.currentChatPubkey]],
content: encryptedContent
};
let signedEvent;
if (state.nip46Connection) {
signedEvent = await state.nip46Connection.signEvent(event);
} else {
signedEvent = finalizeEvent(event, state.userPrivkey);
}
// Insert temporary message with "sending…" timestamp
if (!state.conversations.has(state.currentChatPubkey)) {
state.conversations.set(state.currentChatPubkey, {
messages: [],
lastMessage: null,
unread: 0,
lastReadTimestamp: 0
});
}
const conversation = state.conversations.get(state.currentChatPubkey);
const tempMessage = {
id: signedEvent.id + '_temp',
content,
timestamp: null, // unknown until relay echoes
from: state.userPubkey,
isSent: true,
sending: true // flag for UI
};
conversation.messages.push(tempMessage);
conversation.lastMessage = tempMessage;
renderChat(state.currentChatPubkey);
renderConversations();
input.value = '';
// Publish to relays
await state.pool.publish(state.currentRelays, signedEvent);
showStatus('Sent!', 'success');
} catch (error) {
console.error('Send error:', error);
showStatus('Failed to send: ' + error.message, 'error');
}
}
// ============================================
// 🎨 UI RENDERING
// ============================================
function renderConversations() {
console.log('🎨 renderConversations called, conversations:', state.conversations.size);
const listEl = document.getElementById('conversationsList');
if (!listEl) {
console.error('❌ conversationsList element not found!');
return;
}
const loading = listEl.querySelector('.loading-overlay');
if (loading) {
console.log('Removing loading overlay');
loading.remove();
}
if (state.conversations.size === 0) {
console.log('No conversations to display');
listEl.innerHTML = '<div style="text-align: center; color: #999; padding: 20px;">No conversations yet</div>';
return;
}
const sorted = Array.from(state.conversations.entries())
.sort((a, b) => {
const timeA = a[1].lastMessage?.timestamp || 0;
const timeB = b[1].lastMessage?.timestamp || 0;
return timeB - timeA;
});
console.log('Sorted conversations:', sorted.length);
state.conversationsList = sorted.map(([pubkey]) => pubkey);
const html = sorted.map(([pubkey, conv], index) => {
const displayName = getDisplayName(pubkey);
const npub = nip19.npubEncode(pubkey);
const shortNpub = npub.substring(0, 12) + '...' + npub.substring(npub.length - 4);
const lastMsg = conv.lastMessage;
const time = lastMsg ? new Date(lastMsg.timestamp * 1000).toLocaleString() : '';
const preview = lastMsg ? (lastMsg.content.substring(0, 50) + (lastMsg.content.length > 50 ? '...' : '')) : 'No messages';
const unreadBadge = conv.unread > 0 ? `<span class="unread-badge">${conv.unread}</span>` : '';
const active = state.currentChatPubkey === pubkey ? 'active' : '';
const profile = state.profileCache.get(pubkey);
const hasName = profile && (profile.displayName || profile.name);
const subtitle = hasName ? `<div style="font-size: 11px; color: rgba(255, 255, 255, 0.35); margin-top: 2px;">${escapeHtml(shortNpub)}</div>` : '';
return `
<div class="conversation-item ${active}" onclick="openChat('${pubkey}')" data-index="${index}">
<div class="contact-name">${escapeHtml(displayName)}${unreadBadge}</div>
${subtitle}
<div class="last-message">${escapeHtml(preview)}</div>
<div class="timestamp">${time}</div>
</div>
`;
}).join('');
console.log('Setting innerHTML with', sorted.length, 'items');
listEl.innerHTML = html;
// Force reflow on mobile
if (window.innerWidth < 768) {
listEl.style.display = 'none';
listEl.offsetHeight; // Force reflow
listEl.style.display = '';
}
if (state.selectedConversationIndex >= 0) {
highlightSelectedConversation();
}
updateCacheInfo();
console.log('✅ renderConversations complete');
}
function renderChat(pubkey) {
const messagesEl = document.getElementById('chatMessages');
if (!messagesEl) {
console.error('chatMessages element not found');
return;
}
const conversation = state.conversations.get(pubkey);
if (!conversation) {
messagesEl.innerHTML = '<div style="text-align: center; color: #999; padding: 20px;">No messages yet</div>';
return;
}
messagesEl.innerHTML = conversation.messages.map(msg => {
const msgClass = msg.isSent ? 'sent' : 'received';
const time = new Date(msg.timestamp * 1000).toLocaleTimeString();
return `
<div class="message ${msgClass}">
<div class="content">${escapeHtml(msg.content)}</div>
<div class="time">${time}</div>
</div>
`;
}).join('');
messagesEl.scrollTop = messagesEl.scrollHeight;
}
function openChat(pubkey) {
console.log('Opening chat with', pubkey);
state.currentChatPubkey = pubkey;
const conversation = state.conversations.get(pubkey);
state.selectedConversationIndex = state.conversationsList.indexOf(pubkey);
if (conversation) {
conversation.unread = 0;
if (conversation.lastMessage) {
conversation.lastReadTimestamp = conversation.lastMessage.timestamp;
}
saveMessagesToCache(state.userPubkey);
renderConversations();
}
const emptyState = document.getElementById('emptyState');
const chatView = document.getElementById('chatView');
if (emptyState) emptyState.classList.add('hidden');
if (chatView) chatView.classList.remove('hidden');
if (window.innerWidth < 768) {
const sidebar = document.querySelector('.sidebar');
const chatArea = document.querySelector('.chat-area');
if (sidebar) sidebar.classList.add('hidden-mobile');
if (chatArea) chatArea.classList.add('visible-mobile');
}
const displayName = getDisplayName(pubkey);
const npub = nip19.npubEncode(pubkey);
const contactName = document.getElementById('chatContactName');
const contactNpub = document.getElementById('chatContactNpub');
if (contactName) contactName.textContent = displayName;
if (contactNpub) contactNpub.textContent = npub;
renderChat(pubkey);
setTimeout(() => {
const input = document.getElementById('messageInput');
if (input) input.focus();
}, 100);
}
function backToConversations() {
const sidebar = document.querySelector('.sidebar');
const chatArea = document.querySelector('.chat-area');
if (sidebar) sidebar.classList.remove('hidden-mobile');
if (chatArea) chatArea.classList.remove('visible-mobile');
state.currentChatPubkey = null;
state.selectedConversationIndex = -1;
highlightSelectedConversation();
}
// ============================================
// 🔐 BUNKER CONNECTION
// ============================================
async function connectWithBunker() {
const bunkerInput = document.getElementById('bunkerInput');
if (!bunkerInput) {
console.error('bunkerInput not found');
return;
}
const bunkerUri = bunkerInput.value.trim();
if (!bunkerUri || !bunkerUri.startsWith('bunker://')) {
showStatus('Please enter a valid bunker:// URI', 'error');
return;
}
try {
showStatus('Parsing bunker URI...', 'info');
const waitingEl = document.getElementById('bunkerWaiting');
const connectBtn = document.getElementById('connectBtn');
if (waitingEl) waitingEl.classList.remove('hidden');
if (connectBtn) connectBtn.disabled = true;
const url = new URL(bunkerUri);
const remotePubkey = url.hostname || url.pathname.replace('//', '');
const relays = url.searchParams.getAll('relay');
const secret = url.searchParams.get('secret');
if (!remotePubkey) {
throw new Error('Invalid bunker URI: missing pubkey');
}
if (relays.length === 0) {
relays.push(...state.currentRelays);
}
console.log('Connecting to bunker:', { remotePubkey, relays, secret });
state.nip46Connection = new NIP46Client('Nostr DM Dashboard');
state.nip46Connection.remotePubkey = remotePubkey;
state.nip46Connection.relays = relays;
state.nip46Connection.pool = new SimplePool();
state.nip46Connection.conversationKey = nip44.v2.utils.getConversationKey(
state.nip46Connection.localPrivkey,
remotePubkey
);
showStatus('Sending connect request...', 'info');
const connectRequest = {
id: 'connect_' + Math.random().toString(36).substring(7),
method: 'connect',
params: [
state.nip46Connection.localPubkey,
secret || '',
'nip04_encrypt,nip04_decrypt,sign_event:4,get_public_key',
{
name: 'Nostr DM Dashboard',
url: 'https://yourdashboard.example.com',
image: 'https://yourdashboard.example.com/icon.png'
}
]
};
const encrypted = nip44.v2.encrypt(
JSON.stringify(connectRequest),
state.nip46Connection.conversationKey
);
const connectEvent = finalizeEvent({
kind: 24133,
created_at: Math.floor(Date.now() / 1000),
tags: [['p', remotePubkey]],
content: encrypted
}, state.nip46Connection.localPrivkey);
await state.nip46Connection.pool.publish(relays, connectEvent);
console.log('Connect request sent, waiting for approval...');
const response = await new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('Connection timeout - no approval from bunker'));
}, 120000);
state.nip46Connection.pendingRequests.set(connectRequest.id, { resolve, reject });
state.nip46Connection.sub = state.nip46Connection.pool.subscribeMany(
relays,
[{
kinds: [24133],
'#p': [state.nip46Connection.localPubkey],
since: Math.floor(Date.now() / 1000) - 10
}],
{
onevent: async (event) => {
if (event.pubkey !== remotePubkey) return;
try {
const decrypted = nip44.v2.decrypt(event.content, state.nip46Connection.conversationKey);
const response = JSON.parse(decrypted);
console.log('Received response:', response);
if (response.id === connectRequest.id) {
if (response.result === 'auth_url' && response.error) {
console.log('Auth URL received:', response.error);
showStatus('Approve connection on your bunker device...', 'info');
return;
}
if (response.result === 'ack') {
clearTimeout(timeout);
state.nip46Connection.connected = true;
resolve(response);
return;
}
}
const pending = state.nip46Connection.pendingRequests.get(response.id);
if (pending) {
state.nip46Connection.pendingRequests.delete(response.id);
if (response.error) {
pending.reject(new Error(response.error));
} else {
pending.resolve(response.result);
}
}
} catch (error) {
console.error('Error handling bunker response:', error);
}
}
}
);
});
console.log('Connection approved!', response);
state.userPubkey = await state.nip46Connection.getPublicKey();
state.pool = state.nip46Connection.pool;
const sessionData = {
bunkerUri: bunkerUri,
remotePubkey: remotePubkey,
relays: relays,
localPrivkey: Array.from(state.nip46Connection.localPrivkey).map(b => b.toString(16).padStart(2, '0')).join(''),
localPubkey: state.nip46Connection.localPubkey,
userPubkey: state.userPubkey,
timestamp: Date.now()
};
localStorage.setItem('bunker_session', JSON.stringify(sessionData));
console.log('💾 Saved bunker session');
if (waitingEl) waitingEl.classList.add('hidden');
showStatus('Connected to bunker successfully!', 'success');
await initDashboard();
} catch (error) {
console.error('Bunker connection error:', error);
const waitingEl = document.getElementById('bunkerWaiting');
const connectBtn = document.getElementById('connectBtn');
if (waitingEl) waitingEl.classList.add('hidden');
if (connectBtn) connectBtn.disabled = false;
showStatus('Failed to connect: ' + error.message, 'error');
}
}
// ============================================
// 🔄 SESSION MANAGEMENT
// ============================================
async function restoreSession() {
try {
const savedSession = localStorage.getItem('bunker_session');
if (!savedSession) {
console.log('No saved session found');
return false;
}
const sessionData = JSON.parse(savedSession);
const sessionAge = Date.now() - sessionData.timestamp;
const oneWeek = 7 * 24 * 60 * 60 * 1000;
if (sessionAge > oneWeek) {
console.log('Session expired (older than 1 week)');
localStorage.removeItem('bunker_session');
return false;
}
console.log('🔄 Restoring session from ' + new Date(sessionData.timestamp).toLocaleString());
showStatus('Restoring session...', 'info');
state.nip46Connection = new NIP46Client('Nostr DM Dashboard');
state.nip46Connection.remotePubkey = sessionData.remotePubkey;
state.nip46Connection.relays = sessionData.relays;
state.nip46Connection.localPubkey = sessionData.localPubkey;
state.nip46Connection.localPrivkey = new Uint8Array(
sessionData.localPrivkey.match(/.{1,2}/g).map(byte => parseInt(byte, 16))
);
state.nip46Connection.pool = new SimplePool();
state.nip46Connection.conversationKey = nip44.v2.utils.getConversationKey(
state.nip46Connection.localPrivkey,
sessionData.remotePubkey
);
state.nip46Connection.connected = true;
const nip46Sub = state.nip46Connection.pool.subscribeMany(
sessionData.relays,
[{
kinds: [24133],
'#p': [state.nip46Connection.localPubkey],
since: Math.floor(Date.now() / 1000) - 60
}],
{
onevent: async (event) => {
await state.nip46Connection.handleResponse(event);
}
}
);
state.nip46Connection.sub = nip46Sub;
state.userPubkey = sessionData.userPubkey;
state.pool = state.nip46Connection.pool;
state.currentRelays = sessionData.relays;
showStatus('Session restored successfully!', 'success');
await initDashboard();
return true;
} catch (error) {
console.error('Failed to restore session:', error);
localStorage.removeItem('bunker_session');
showStatus('Session restore failed, please log in again', 'error');
return false;
}
}
async function loadDMs() {
console.log('📬 Starting DM subscription...');
console.log('Current conversations:', state.conversations.size);
showStatus('Syncing messages...', 'info');
const overlay = document.querySelector('.loading-overlay');
if (overlay) {
overlay.classList.remove('hidden');
const msg = overlay.querySelector('div:nth-child(2)');
if (msg) msg.textContent = 'Syncing messages...';
}
// Get current time for real-time subscription
const now = Math.floor(Date.now() / 1000);
// Historical messages filter
const historicalFilters = state.lastSyncTimestamp > 0 ? [
{ kinds: [4], authors: [state.userPubkey], since: state.lastSyncTimestamp, limit: 500 },
{ kinds: [4], '#p': [state.userPubkey], since: state.lastSyncTimestamp, limit: 500 }
] : [
{ kinds: [4], authors: [state.userPubkey], limit: 200 },
{ kinds: [4], '#p': [state.userPubkey], limit: 200 }
];
// Real-time filter - no 'since' limit, catches everything going forward
const realtimeFilters = [
{ kinds: [4], authors: [state.userPubkey], since: now },
{ kinds: [4], '#p': [state.userPubkey], since: now }
];
console.log('Last sync timestamp:', state.lastSyncTimestamp, new Date(state.lastSyncTimestamp * 1000).toLocaleString());
let eventCount = 0;
let initialSyncComplete = false;
// Historical sync subscription
const historicalSub = state.pool.subscribeMany(
state.currentRelays,
historicalFilters,
{
onevent: async (event) => {
eventCount++;
await processDM(event);
if (event.created_at > state.lastSyncTimestamp) {
state.lastSyncTimestamp = event.created_at;
}
},
oneose: () => {
console.log('⏹️ Initial sync complete');
console.log('Events received:', eventCount);
console.log('Conversations after sync:', state.conversations.size);
// Final render after sync
renderConversations();
// Update timestamp
if (state.lastSyncTimestamp < now) {
state.lastSyncTimestamp = now;
}
initialSyncComplete = true;
state.connectionActive = true;
saveMessagesToCache(state.userPubkey);
if (overlay) overlay.classList.add('hidden');
showStatus(eventCount > 0 ? `Synced ${eventCount} messages` : 'Up to date', 'success');
fetchAllProfiles();
console.log('✅ Now listening for real-time messages');
}
}
);
// Real-time subscription - catches all new messages including your own sent from other instances
const realtimeSub = state.pool.subscribeMany(
state.currentRelays,
realtimeFilters,
{
onevent: async (event) => {
console.log('📨 Real-time message:', event.pubkey === state.userPubkey ? 'from me' : 'to me');
const wasNew = await processDM(event);
if (wasNew) {
const eventOtherPubkey = event.pubkey === state.userPubkey
? event.tags.find(t => t[0] === 'p')?.[1]
: event.pubkey;
// Update current chat if it's the active conversation
if (state.currentChatPubkey === eventOtherPubkey) {
renderChat(state.currentChatPubkey);
}
// Save after each new message
saveMessagesToCache(state.userPubkey);
}
if (event.created_at > state.lastSyncTimestamp) {
state.lastSyncTimestamp = event.created_at;
}
}
}
);
state.subscriptions.push(historicalSub);
state.subscriptions.push(realtimeSub);
// Auto-save every 30 seconds
if (!state.autoSaveInterval) {
state.autoSaveInterval = setInterval(() => {
if (state.userPubkey) {
saveMessagesToCache(state.userPubkey);
}
}, 30000);
}
}
async function initDashboard() {
console.log('🎯 Initializing dashboard...');
const loginSection = document.getElementById('loginSection');
const dashboardEl = document.getElementById('dashboard');
if (!loginSection || !dashboardEl) {
console.error('Required elements not found!');
return;
}
loginSection.style.display = 'none';
dashboardEl.style.display = 'flex';
dashboardEl.classList.add('active');
console.log('✅ Dashboard visible, loading DMs...');
console.log('User pubkey:', state.userPubkey);
console.log('Relays:', state.currentRelays);
try {
const profile = await fetchProfile(state.userPubkey);
const displayName = (profile && (profile.displayName || profile.name)) || 'Account';
const headerEl = document.getElementById('dashboardHeader');
if (headerEl) headerEl.textContent = `${displayName} Dashboard`;
} catch (e) {
const headerEl = document.getElementById('dashboardHeader');
if (headerEl) headerEl.textContent = `Dashboard`;
}
const cachedLoaded = await loadMessagesFromCache(state.userPubkey);
if (cachedLoaded && state.conversations.size > 0) {
const overlay = document.querySelector('.loading-overlay');
if (overlay) overlay.remove();
showStatus('Loaded from encrypted cache, syncing new messages...', 'info');
// Force render conversations after cache load
setTimeout(() => {
console.log('Forcing conversation render after cache load');
renderConversations();
}, 200);
}
// Always load DMs to get new messages
loadDMs();
startHealthCheck();
}
function disconnect() {
state.closeAllSubscriptions();
if (state.healthCheckInterval) {
clearInterval(state.healthCheckInterval);
state.healthCheckInterval = null;
}
if (state.autoSaveInterval) {
clearInterval(state.autoSaveInterval);
state.autoSaveInterval = null;
}
if (state.pool) state.pool.close(state.currentRelays);
if (state.nip46Connection) state.nip46Connection.close();
localStorage.removeItem('bunker_session');
console.log('🗑️ Cleared saved session (kept message cache)');
state.reset();
const dashboard = document.getElementById('dashboard');
const loginSection = document.getElementById('loginSection');
const conversationsList = document.getElementById('conversationsList');
const chatMessages = document.getElementById('chatMessages');
const emptyState = document.getElementById('emptyState');
const chatView = document.getElementById('chatView');
const cacheInfo = document.getElementById('cacheInfo');
if (dashboard) dashboard.style.display = 'none';
if (loginSection) loginSection.style.display = 'block';
if (conversationsList) conversationsList.innerHTML = '<div class="loading-overlay"><div class="spinner"></div>Loading DMs...</div>';
if (chatMessages) chatMessages.innerHTML = '';
if (emptyState) emptyState.classList.remove('hidden');
if (chatView) chatView.classList.add('hidden');
if (cacheInfo) cacheInfo.textContent = '';
showStatus('Disconnected', 'info');
}
// ============================================
// 🎮 UI HELPERS
// ============================================
function showStatus(message, type = 'info') {
const status = document.getElementById('status');
if (!status) return;
status.textContent = message;
status.className = 'status';
if (type === 'success') status.classList.add('success');
if (type === 'error') status.classList.add('error');
status.classList.remove('hidden');
if (type !== 'error') {
setTimeout(() => status.classList.add('hidden'), 3000);
}
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function handleKeyPress(event) {
if (event.key === 'Enter') {
sendDM();
}
}
function filterConversations() {
const searchBox = document.getElementById('searchBox');
if (!searchBox) return;
const searchTerm = searchBox.value.toLowerCase();
const items = document.querySelectorAll('.conversation-item');
items.forEach(item => {
const text = item.textContent.toLowerCase();
if (text.includes(searchTerm)) {
item.style.display = '';
} else {
item.style.display = 'none';
}
});
}
function markAllAsRead() {
state.conversations.forEach(conv => {
conv.unread = 0;
if (conv.lastMessage) {
conv.lastReadTimestamp = conv.lastMessage.timestamp;
}
});
saveMessagesToCache(state.userPubkey);
renderConversations();
showStatus('All conversations marked as read', 'success');
}
// ============================================
// ⌨️ KEYBOARD NAVIGATION
// ============================================
function navigateConversations(direction) {
if (state.conversationsList.length === 0) return;
state.selectedConversationIndex += direction;
if (state.selectedConversationIndex < 0) {
state.selectedConversationIndex = state.conversationsList.length - 1;
} else if (state.selectedConversationIndex >= state.conversationsList.length) {
state.selectedConversationIndex = 0;
}
highlightSelectedConversation();
}
function highlightSelectedConversation() {
const items = document.querySelectorAll('.conversation-item');
items.forEach((item, index) => {
if (index === state.selectedConversationIndex) {
item.style.background = 'rgba(253, 173, 1, 0.15)';
item.style.outline = '2px solid rgba(253, 173, 1, 0.5)';
item.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
} else if (!item.classList.contains('active')) {
item.style.background = '';
item.style.outline = '';
}
});
}
document.addEventListener('keydown', (e) => {
const dashboard = document.getElementById('dashboard');
if (!dashboard || !dashboard.classList.contains('active')) return;
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
if (e.key === 'ArrowDown') {
e.preventDefault();
navigateConversations(1);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
navigateConversations(-1);
} else if (e.key === 'Enter') {
e.preventDefault();
if (state.selectedConversationIndex >= 0 && state.conversationsList[state.selectedConversationIndex]) {
openChat(state.conversationsList[state.selectedConversationIndex]);
}
} else if (e.key === 'Escape') {
e.preventDefault();
if (state.currentChatPubkey) {
backToConversations();
}
}
});
// ============================================
// 📱 MOBILE SWIPE BACK GESTURE
// ============================================
let touchStartX = 0;
let touchStartY = 0;
let touchCurrentX = 0;
let isSwiping = false;
function initSwipeBack() {
const chatArea = document.querySelector('.chat-area');
if (!chatArea) return;
chatArea.addEventListener('touchstart', (e) => {
if (e.touches[0].clientX > 50) return;
touchStartX = e.touches[0].clientX;
touchStartY = e.touches[0].clientY;
}, { passive: true });
chatArea.addEventListener('touchmove', (e) => {
if (!touchStartX) return;
touchCurrentX = e.touches[0].clientX;
const touchCurrentY = e.touches[0].clientY;
const deltaX = touchCurrentX - touchStartX;
const deltaY = touchCurrentY - touchStartY;
if (Math.abs(deltaX) > Math.abs(deltaY) && deltaX > 10) {
isSwiping = true;
if (deltaX > 0 && deltaX < 300) {
chatArea.style.transform = `translateX(${deltaX}px)`;
chatArea.style.transition = 'none';
const sidebar = document.querySelector('.sidebar');
if (sidebar) sidebar.style.opacity = Math.min(deltaX / 150, 1);
}
}
}, { passive: true });
chatArea.addEventListener('touchend', (e) => {
if (!isSwiping) {
touchStartX = 0;
touchStartY = 0;
return;
}
const deltaX = touchCurrentX - touchStartX;
if (deltaX > 100) {
chatArea.style.transition = 'transform 0.3s ease';
chatArea.style.transform = 'translateX(100%)';
setTimeout(() => {
backToConversations();
chatArea.style.transform = '';
chatArea.style.transition = '';
const sidebar = document.querySelector('.sidebar');
if (sidebar) sidebar.style.opacity = '';
}, 300);
} else {
chatArea.style.transition = 'transform 0.3s ease';
chatArea.style.transform = '';
const sidebar = document.querySelector('.sidebar');
if (sidebar) sidebar.style.opacity = '';
}
touchStartX = 0;
touchStartY = 0;
touchCurrentX = 0;
isSwiping = false;
});
chatArea.addEventListener('touchcancel', () => {
if (isSwiping) {
chatArea.style.transition = 'transform 0.3s ease';
chatArea.style.transform = '';
const sidebar = document.querySelector('.sidebar');
if (sidebar) sidebar.style.opacity = '';
}
touchStartX = 0;
touchStartY = 0;
touchCurrentX = 0;
isSwiping = false;
});
}
// ============================================
// 👁️ VISIBILITY CHANGE HANDLER
// ============================================
document.addEventListener('visibilitychange', async () => {
if (document.visibilityState === 'visible' && state.userPubkey) {
console.log('🔄 Page became visible, reconnecting...');
// Close old subscriptions
state.closeAllSubscriptions();
// Force reconnect
await reconnectAndSync();
}
});
// ============================================
// 🚀 INITIALIZATION
// ============================================
window.addEventListener('DOMContentLoaded', async () => {
console.log('🚀 Page loaded, checking for saved session...');
initSwipeBack();
const restored = await restoreSession();
if (!restored) {
console.log('No valid session, showing login screen');
}
});
// Check if running as PWA
function isPWA() {
return window.matchMedia('(display-mode: standalone)').matches ||
window.navigator.standalone === true ||
document.referrer.includes('android-app://');
}
window.addEventListener('DOMContentLoaded', () => {
const banner = document.getElementById('pwaInstallBanner');
if (!isPWA() && banner) {
banner.classList.remove('hidden');
}
});
</script>
</body>
</html>