2237 lines
83 KiB
HTML
2237 lines
83 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="apple-mobile-web-app-capable" content="yes">
|
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
|
<meta name="apple-mobile-web-app-title" content="Nostr DMs">
|
|
<meta name="theme-color" content="#1a1a2e">
|
|
<title>Nostr DM Dashboard</title>
|
|
<link rel="manifest" href="data:application/json;base64,ewogICJuYW1lIjogIk5vc3RyIERNIERhc2hib2FyZCIsCiAgInNob3J0X25hbWUiOiAiTm9zdHIgRE1zIiwKICAic3RhcnRfdXJsIjogIi8iLAogICJkaXNwbGF5IjogInN0YW5kYWxvbmUiLAogICJiYWNrZ3JvdW5kX2NvbG9yIjogIiMxYTFhMmUiLAogICJ0aGVtZV9jb2xvciI6ICIjMWExYTJlIiwKICAiZGVzY3JpcHRpb24iOiAiU2VjdXJlIE5vc3RyIGRpcmVjdCBtZXNzYWdpbmcgZGFzaGJvYXJkIiwKICAiaWNvbnMiOiBbCiAgICB7CiAgICAgICJzcmMiOiAiZGF0YTppbWFnZS9zdmcreG1sLCUzQ3N2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPScxOTInIGhlaWdodD0nMTkyJyB2aWV3Qm94PScwIDAgMTkyIDE5MiclM0UlM0NyZWN0IHdpZHRoPScxOTInIGhlaWdodD0nMTkyJyBmaWxsPSclMjNmZGFkMDEnIHJ4PScyNCcvJTNFJTNDdGV4dCB4PScwJScgeT0nNTAlMjUnIGZvbnQtc2l6ZT0nOTAnIGZpbGw9JyUyMzAwMCcgdGV4dC1hbmNob3I9J21pZGRsZScgZG9taW5hbnQtYmFzZWxpbmU9J21pZGRsZSclM0Xwn5OsJTNDL3RleHQlM0UlM0Mvc3ZnJTNFIiwKICAgICAgInNpemVzIjogIjE5MngxOTIiLAogICAgICAidHlwZSI6ICJpbWFnZS9zdmcreG1sIiwKICAgICAgInB1cnBvc2UiOiAiYW55IG1hc2thYmxlIgogICAgfQogIF0KfQ==">
|
|
<script src="https://unpkg.com/nostr-tools@2.7.2/lib/nostr.bundle.js"></script>
|
|
<style>
|
|
* {
|
|
margin: 0;
|
|
padding: 0;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
|
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
|
min-height: 100vh;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 0;
|
|
margin: 0;
|
|
}
|
|
|
|
.container {
|
|
background: rgba(30, 30, 40, 0.7);
|
|
backdrop-filter: blur(20px);
|
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
|
width: 100%;
|
|
height: 100vh;
|
|
max-width: 100%;
|
|
max-height: 100vh;
|
|
overflow: hidden;
|
|
display: flex;
|
|
flex-direction: column;
|
|
position: relative;
|
|
}
|
|
|
|
@media (min-width: 768px) {
|
|
body {
|
|
padding: 20px;
|
|
}
|
|
|
|
.container {
|
|
border-radius: 24px;
|
|
width: 95vw;
|
|
height: 95vh;
|
|
max-width: 1600px;
|
|
}
|
|
}
|
|
|
|
.header {
|
|
background: rgba(20, 20, 30, 0.95);
|
|
backdrop-filter: blur(10px);
|
|
color: white;
|
|
padding: 16px 20px;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
|
flex-shrink: 0;
|
|
width: 100%;
|
|
position: relative;
|
|
z-index: 10;
|
|
}
|
|
|
|
.header h1 {
|
|
font-size: 18px;
|
|
font-weight: 600;
|
|
letter-spacing: -0.5px;
|
|
margin: 0;
|
|
}
|
|
|
|
.header-actions {
|
|
display: flex;
|
|
gap: 8px;
|
|
align-items: center;
|
|
}
|
|
|
|
@media (min-width: 768px) {
|
|
.header {
|
|
padding: 20px 24px;
|
|
}
|
|
|
|
.header h1 {
|
|
font-size: 20px;
|
|
}
|
|
}
|
|
|
|
.login-section {
|
|
padding: 40px;
|
|
text-align: center;
|
|
background: rgba(25, 25, 35, 0.5);
|
|
border-radius: 16px;
|
|
margin: 20px;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.login-section h2 {
|
|
margin-bottom: 20px;
|
|
color: #ffffff;
|
|
}
|
|
|
|
.pwa-install-banner {
|
|
background: linear-gradient(135deg, rgba(253, 173, 1, 0.2) 0%, rgba(255, 149, 0, 0.2) 100%);
|
|
border: 2px solid rgba(253, 173, 1, 0.4);
|
|
border-radius: 16px;
|
|
padding: 20px;
|
|
margin: 20px 0;
|
|
text-align: left;
|
|
}
|
|
|
|
.pwa-install-banner h3 {
|
|
color: #fdad01;
|
|
margin-bottom: 12px;
|
|
font-size: 18px;
|
|
}
|
|
|
|
.pwa-install-banner p {
|
|
color: rgba(255, 255, 255, 0.8);
|
|
margin-bottom: 8px;
|
|
font-size: 14px;
|
|
line-height: 1.6;
|
|
}
|
|
|
|
.pwa-install-banner ol {
|
|
margin: 12px 0 12px 20px;
|
|
color: rgba(255, 255, 255, 0.7);
|
|
}
|
|
|
|
.pwa-install-banner li {
|
|
margin: 8px 0;
|
|
font-size: 14px;
|
|
}
|
|
|
|
.input-group {
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.input-group label {
|
|
display: block;
|
|
margin-bottom: 8px;
|
|
color: rgba(255, 255, 255, 0.7);
|
|
font-weight: 500;
|
|
text-align: left;
|
|
}
|
|
|
|
.input-group input {
|
|
width: 100%;
|
|
padding: 12px 16px;
|
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
border-radius: 10px;
|
|
font-size: 14px;
|
|
transition: all 0.3s;
|
|
background: rgba(40, 40, 50, 0.5);
|
|
color: #ffffff;
|
|
}
|
|
|
|
.input-group input::placeholder {
|
|
color: rgba(255, 255, 255, 0.3);
|
|
}
|
|
|
|
.input-group input:focus {
|
|
outline: none;
|
|
border-color: rgba(253, 173, 1, 0.5);
|
|
background: rgba(40, 40, 50, 0.8);
|
|
}
|
|
|
|
.btn {
|
|
background: linear-gradient(135deg, #fdad01 0%, #ff9500 100%);
|
|
color: #000000;
|
|
border: none;
|
|
padding: 12px 20px;
|
|
border-radius: 12px;
|
|
font-size: 14px;
|
|
font-weight: 700;
|
|
cursor: pointer;
|
|
transition: all 0.2s ease;
|
|
box-shadow: 0 4px 16px rgba(253, 173, 1, 0.3);
|
|
letter-spacing: -0.2px;
|
|
}
|
|
|
|
@media (min-width: 768px) {
|
|
.btn {
|
|
padding: 14px 28px;
|
|
font-size: 15px;
|
|
}
|
|
}
|
|
|
|
.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: #ffffff;
|
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
|
box-shadow: none;
|
|
}
|
|
|
|
.btn-secondary:hover {
|
|
background: rgba(70, 70, 80, 0.9);
|
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
|
}
|
|
|
|
.btn-icon {
|
|
padding: 10px;
|
|
width: 40px;
|
|
height: 40px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 18px;
|
|
}
|
|
|
|
.btn-small {
|
|
padding: 8px 16px;
|
|
font-size: 13px;
|
|
}
|
|
|
|
@media (min-width: 768px) {
|
|
.btn-small {
|
|
padding: 10px 20px;
|
|
font-size: 14px;
|
|
}
|
|
}
|
|
|
|
.dashboard {
|
|
display: none;
|
|
flex: 1;
|
|
overflow: hidden;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.dashboard.active {
|
|
display: flex;
|
|
}
|
|
|
|
.dashboard-content {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100%;
|
|
flex: 1;
|
|
overflow: hidden;
|
|
position: relative;
|
|
}
|
|
|
|
@media (min-width: 768px) {
|
|
.dashboard-content {
|
|
flex-direction: row;
|
|
}
|
|
}
|
|
|
|
.sidebar {
|
|
width: 100%;
|
|
border-right: none;
|
|
border-bottom: none;
|
|
display: flex;
|
|
flex-direction: column;
|
|
background: rgba(25, 25, 35, 0.6);
|
|
backdrop-filter: blur(10px);
|
|
height: 100%;
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 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;
|
|
max-height: none;
|
|
border-right: 1px solid rgba(255, 255, 255, 0.08);
|
|
border-bottom: none;
|
|
position: relative;
|
|
transform: none !important;
|
|
}
|
|
|
|
.sidebar.hidden-mobile {
|
|
transform: none;
|
|
}
|
|
}
|
|
|
|
.sidebar-header {
|
|
padding: 16px 20px;
|
|
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
|
background: rgba(20, 20, 30, 0.5);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
@media (min-width: 768px) {
|
|
.sidebar-header {
|
|
padding: 24px 24px 20px 24px;
|
|
}
|
|
}
|
|
|
|
.sidebar-header h2 {
|
|
font-size: 18px;
|
|
margin-bottom: 12px;
|
|
color: #ffffff;
|
|
font-weight: 600;
|
|
letter-spacing: -0.5px;
|
|
}
|
|
|
|
@media (min-width: 768px) {
|
|
.sidebar-header h2 {
|
|
font-size: 20px;
|
|
margin-bottom: 16px;
|
|
}
|
|
}
|
|
|
|
.search-box {
|
|
width: 100%;
|
|
padding: 12px 16px;
|
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
|
border-radius: 12px;
|
|
font-size: 14px;
|
|
background: rgba(40, 40, 50, 0.6);
|
|
color: #ffffff;
|
|
transition: all 0.3s ease;
|
|
}
|
|
|
|
.search-box::placeholder {
|
|
color: rgba(255, 255, 255, 0.35);
|
|
}
|
|
|
|
.search-box:focus {
|
|
outline: none;
|
|
border-color: rgba(253, 173, 1, 0.4);
|
|
background: rgba(40, 40, 50, 0.9);
|
|
box-shadow: 0 0 0 3px rgba(253, 173, 1, 0.1);
|
|
}
|
|
|
|
.conversations-list {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
overflow-x: hidden;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
.conversations-list::-webkit-scrollbar-thumb:hover {
|
|
background: rgba(255, 255, 255, 0.25);
|
|
}
|
|
|
|
.conversation-item {
|
|
padding: 16px 20px;
|
|
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
|
|
cursor: pointer;
|
|
transition: all 0.2s ease;
|
|
background: transparent;
|
|
position: relative;
|
|
}
|
|
|
|
@media (min-width: 768px) {
|
|
.conversation-item {
|
|
padding: 20px 24px;
|
|
}
|
|
}
|
|
|
|
.conversation-item::before {
|
|
content: '';
|
|
position: absolute;
|
|
left: 0;
|
|
top: 0;
|
|
bottom: 0;
|
|
width: 3px;
|
|
background: #fdad01;
|
|
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;
|
|
color: #ffffff;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
}
|
|
|
|
@media (min-width: 768px) {
|
|
.conversation-item .contact-name {
|
|
font-size: 15px;
|
|
margin-bottom: 8px;
|
|
}
|
|
}
|
|
|
|
.conversation-item .last-message {
|
|
font-size: 13px;
|
|
color: rgba(255, 255, 255, 0.45);
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
line-height: 1.4;
|
|
}
|
|
|
|
@media (min-width: 768px) {
|
|
.conversation-item .last-message {
|
|
font-size: 14px;
|
|
}
|
|
}
|
|
|
|
.conversation-item .timestamp {
|
|
font-size: 11px;
|
|
color: rgba(255, 255, 255, 0.3);
|
|
margin-top: 4px;
|
|
}
|
|
|
|
@media (min-width: 768px) {
|
|
.conversation-item .timestamp {
|
|
font-size: 12px;
|
|
margin-top: 6px;
|
|
}
|
|
}
|
|
|
|
.conversation-item .unread-badge {
|
|
display: inline-block;
|
|
background: #fdad01;
|
|
color: #000;
|
|
font-size: 11px;
|
|
font-weight: 700;
|
|
padding: 4px 10px;
|
|
border-radius: 12px;
|
|
min-width: 24px;
|
|
text-align: center;
|
|
}
|
|
|
|
.chat-area {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
background: rgba(20, 20, 30, 0.3);
|
|
overflow: hidden;
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 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 rgba(255, 255, 255, 0.08);
|
|
background: rgba(20, 20, 30, 0.6);
|
|
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 rgba(255, 255, 255, 0.12);
|
|
color: #ffffff;
|
|
padding: 8px 12px;
|
|
border-radius: 8px;
|
|
font-size: 14px;
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
transition: all 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;
|
|
}
|
|
}
|
|
|
|
.chat-header .contact-info h3 {
|
|
font-size: 16px;
|
|
margin-bottom: 4px;
|
|
color: #ffffff;
|
|
font-weight: 600;
|
|
letter-spacing: -0.3px;
|
|
}
|
|
|
|
@media (min-width: 768px) {
|
|
.chat-header .contact-info h3 {
|
|
font-size: 18px;
|
|
margin-bottom: 6px;
|
|
}
|
|
}
|
|
|
|
.chat-header .contact-info .npub {
|
|
font-size: 11px;
|
|
color: rgba(255, 255, 255, 0.4);
|
|
font-family: 'SF Mono', Monaco, monospace;
|
|
}
|
|
|
|
@media (min-width: 768px) {
|
|
.chat-header .contact-info .npub {
|
|
font-size: 13px;
|
|
}
|
|
}
|
|
|
|
.chat-messages {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
overflow-x: hidden;
|
|
padding: 20px 16px;
|
|
background: rgba(18, 18, 28, 0.4);
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 12px;
|
|
}
|
|
|
|
@media (min-width: 768px) {
|
|
.chat-messages {
|
|
padding: 32px;
|
|
gap: 16px;
|
|
}
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
.chat-messages::-webkit-scrollbar-thumb:hover {
|
|
background: rgba(255, 255, 255, 0.25);
|
|
}
|
|
|
|
.message {
|
|
padding: 14px 16px;
|
|
border-radius: 16px;
|
|
max-width: 85%;
|
|
word-wrap: break-word;
|
|
animation: slideIn 0.3s ease;
|
|
position: relative;
|
|
}
|
|
|
|
@media (min-width: 768px) {
|
|
.message {
|
|
padding: 16px 20px;
|
|
border-radius: 18px;
|
|
max-width: 65%;
|
|
}
|
|
}
|
|
|
|
@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 rgba(255, 255, 255, 0.12);
|
|
color: #ffffff;
|
|
margin-left: auto;
|
|
align-self: flex-end;
|
|
}
|
|
|
|
.message.received {
|
|
background: linear-gradient(135deg, #fdad01 0%, #ffa500 100%);
|
|
color: #000000;
|
|
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;
|
|
letter-spacing: -0.1px;
|
|
}
|
|
|
|
.message .time {
|
|
font-size: 11px;
|
|
opacity: 0.5;
|
|
margin-top: 8px;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.message.received .time {
|
|
opacity: 0.6;
|
|
}
|
|
|
|
.chat-input-area {
|
|
padding: 16px 20px;
|
|
background: rgba(20, 20, 30, 0.7);
|
|
backdrop-filter: blur(10px);
|
|
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
|
display: flex;
|
|
gap: 10px;
|
|
align-items: center;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
@media (min-width: 768px) {
|
|
.chat-input-area {
|
|
padding: 24px 32px;
|
|
gap: 12px;
|
|
}
|
|
}
|
|
|
|
.chat-input-area input {
|
|
flex: 1;
|
|
padding: 12px 16px;
|
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
|
border-radius: 12px;
|
|
font-size: 14px;
|
|
background: rgba(40, 40, 50, 0.6);
|
|
color: #ffffff;
|
|
transition: all 0.3s ease;
|
|
}
|
|
|
|
@media (min-width: 768px) {
|
|
.chat-input-area input {
|
|
padding: 16px 20px;
|
|
border-radius: 14px;
|
|
font-size: 15px;
|
|
}
|
|
}
|
|
|
|
.chat-input-area input::placeholder {
|
|
color: rgba(255, 255, 255, 0.3);
|
|
}
|
|
|
|
.chat-input-area input:focus {
|
|
outline: none;
|
|
border-color: rgba(253, 173, 1, 0.4);
|
|
background: rgba(40, 40, 50, 0.9);
|
|
box-shadow: 0 0 0 3px rgba(253, 173, 1, 0.1);
|
|
}
|
|
|
|
.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 p {
|
|
font-size: 15px;
|
|
opacity: 0.7;
|
|
}
|
|
|
|
.empty-state-icon {
|
|
font-size: 64px;
|
|
opacity: 0.3;
|
|
}
|
|
|
|
.status {
|
|
padding: 10px 20px;
|
|
background: #fff3cd;
|
|
color: #856404;
|
|
border-bottom: 1px solid #e0e0e0;
|
|
font-size: 13px;
|
|
text-align: center;
|
|
}
|
|
|
|
.status.success {
|
|
background: #d4edda;
|
|
color: #155724;
|
|
}
|
|
|
|
.status.error {
|
|
background: #f8d7da;
|
|
color: #721c24;
|
|
}
|
|
|
|
.hidden {
|
|
display: none !important;
|
|
}
|
|
|
|
.spinner {
|
|
border: 3px solid rgba(255, 255, 255, 0.1);
|
|
border-top: 3px solid #fdad01;
|
|
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); }
|
|
}
|
|
|
|
.instructions {
|
|
background: rgba(253, 173, 1, 0.1);
|
|
border-left: 4px solid #fdad01;
|
|
padding: 15px;
|
|
margin: 20px 0;
|
|
border-radius: 8px;
|
|
text-align: left;
|
|
color: rgba(255, 255, 255, 0.8);
|
|
}
|
|
|
|
.instructions strong {
|
|
color: #fdad01;
|
|
}
|
|
|
|
.instructions a {
|
|
color: #fdad01;
|
|
text-decoration: none;
|
|
}
|
|
|
|
.instructions a:hover {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.instructions ol {
|
|
margin: 10px 0 10px 20px;
|
|
color: rgba(255, 255, 255, 0.7);
|
|
}
|
|
|
|
.instructions li {
|
|
margin: 5px 0;
|
|
}
|
|
|
|
.loading-overlay {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 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);
|
|
}
|
|
|
|
.cache-info {
|
|
font-size: 11px;
|
|
color: rgba(255, 255, 255, 0.4);
|
|
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-overlay {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 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 rgba(255, 255, 255, 0.1);
|
|
border-radius: 20px;
|
|
padding: 32px;
|
|
max-width: 500px;
|
|
width: 100%;
|
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
|
}
|
|
|
|
.modal h3 {
|
|
color: #fdad01;
|
|
margin-bottom: 16px;
|
|
font-size: 22px;
|
|
}
|
|
|
|
.modal p {
|
|
color: rgba(255, 255, 255, 0.8);
|
|
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 DM 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 id="pwaInstallBanner" class="pwa-install-banner hidden">
|
|
<h3>📱 Install as App for Push Notifications</h3>
|
|
<p>To receive notifications when new messages arrive, install this app to your home screen:</p>
|
|
<ol>
|
|
<li><strong>iOS:</strong> Tap the Share button <span style="font-size: 16px;">⎋</span> and select "Add to Home Screen"</li>
|
|
<li><strong>Android:</strong> Tap the menu ⋮ and select "Install app" or "Add to Home Screen"</li>
|
|
<li>Open the app from your home screen (not from the browser)</li>
|
|
<li>Enable notifications when prompted after logging in</li>
|
|
</ol>
|
|
<p style="margin-top: 12px; font-size: 13px; opacity: 0.8;">💡 Push notifications only work when installed as a standalone app on iOS 16.4+ and Android.</p>
|
|
</div>
|
|
|
|
<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>📬 DM Dashboard</h1>
|
|
<div class="header-actions">
|
|
<button class="btn btn-secondary btn-small" onclick="showNotificationSettings()" id="notifBtn" title="Notification Settings">
|
|
<span id="notifIcon">🔕</span> Notifications
|
|
</button>
|
|
<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>
|
|
</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;
|
|
|
|
let pool;
|
|
let currentRelays = [
|
|
'wss://relay.btcforplebs.com',
|
|
'wss://relay.damus.io',
|
|
'wss://nos.lol'
|
|
];
|
|
let userPubkey;
|
|
let userPrivkey;
|
|
let nip46Connection;
|
|
let subscriptions = [];
|
|
let conversations = new Map();
|
|
let currentChatPubkey = null;
|
|
let lastSyncTimestamp = 0;
|
|
let notificationPermission = 'default';
|
|
let serviceWorkerRegistration = null;
|
|
let selectedConversationIndex = -1;
|
|
let conversationsList = [];
|
|
let profileCache = new Map(); // Cache for profile metadata
|
|
|
|
// Cache management
|
|
const CACHE_VERSION = 1;
|
|
const CACHE_KEY_PREFIX = 'nostr_dm_cache_';
|
|
const MAX_CACHE_AGE = 7 * 24 * 60 * 60 * 1000;
|
|
|
|
// Check if running as PWA
|
|
function isPWA() {
|
|
return window.matchMedia('(display-mode: standalone)').matches ||
|
|
window.navigator.standalone === true ||
|
|
document.referrer.includes('android-app://');
|
|
}
|
|
|
|
// Keyboard navigation for conversations
|
|
document.addEventListener('keydown', (e) => {
|
|
// Only handle keyboard navigation when dashboard is active and not typing in an input
|
|
if (!document.getElementById('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 (selectedConversationIndex >= 0 && conversationsList[selectedConversationIndex]) {
|
|
openChat(conversationsList[selectedConversationIndex]);
|
|
}
|
|
} else if (e.key === 'Escape') {
|
|
e.preventDefault();
|
|
if (currentChatPubkey) {
|
|
backToConversations();
|
|
}
|
|
}
|
|
});
|
|
|
|
function navigateConversations(direction) {
|
|
if (conversationsList.length === 0) return;
|
|
|
|
// Update selected index
|
|
selectedConversationIndex += direction;
|
|
|
|
// Wrap around
|
|
if (selectedConversationIndex < 0) {
|
|
selectedConversationIndex = conversationsList.length - 1;
|
|
} else if (selectedConversationIndex >= conversationsList.length) {
|
|
selectedConversationIndex = 0;
|
|
}
|
|
|
|
// Highlight the selected conversation
|
|
highlightSelectedConversation();
|
|
}
|
|
|
|
function highlightSelectedConversation() {
|
|
const items = document.querySelectorAll('.conversation-item');
|
|
items.forEach((item, index) => {
|
|
if (index === 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 = '';
|
|
}
|
|
});
|
|
}
|
|
|
|
// Show PWA install banner if not installed
|
|
window.addEventListener('DOMContentLoaded', () => {
|
|
if (!isPWA()) {
|
|
document.getElementById('pwaInstallBanner').classList.remove('hidden');
|
|
}
|
|
});
|
|
|
|
// Register service worker
|
|
if ('serviceWorker' in navigator) {
|
|
navigator.serviceWorker.register('/sw.js').then(reg => {
|
|
console.log('✅ Service Worker registered');
|
|
serviceWorkerRegistration = reg;
|
|
}).catch(err => {
|
|
console.error('❌ Service Worker registration failed:', err);
|
|
});
|
|
}
|
|
|
|
function getCacheKey(pubkey) {
|
|
return `${CACHE_KEY_PREFIX}${pubkey}_v${CACHE_VERSION}`;
|
|
}
|
|
|
|
function getProfileCacheKey(pubkey) {
|
|
return `nostr_profile_${pubkey}`;
|
|
}
|
|
|
|
// Fetch profile metadata for a pubkey
|
|
async function fetchProfile(pubkey) {
|
|
// Check cache first
|
|
const cached = profileCache.get(pubkey);
|
|
if (cached) return cached;
|
|
|
|
// Check localStorage
|
|
try {
|
|
const stored = localStorage.getItem(getProfileCacheKey(pubkey));
|
|
if (stored) {
|
|
const profile = JSON.parse(stored);
|
|
const age = Date.now() - profile.timestamp;
|
|
// Cache profiles for 24 hours
|
|
if (age < 24 * 60 * 60 * 1000) {
|
|
profileCache.set(pubkey, profile);
|
|
return profile;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error('Error loading cached profile:', e);
|
|
}
|
|
|
|
// Fetch from relays
|
|
try {
|
|
const profileData = await new Promise((resolve) => {
|
|
let resolved = false;
|
|
const timeout = setTimeout(() => {
|
|
if (!resolved) {
|
|
resolved = true;
|
|
resolve(null);
|
|
}
|
|
}, 5000); // 5 second timeout
|
|
|
|
const sub = pool.subscribeMany(
|
|
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()
|
|
};
|
|
|
|
profileCache.set(pubkey, profile);
|
|
|
|
// Save to localStorage
|
|
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;
|
|
}
|
|
|
|
// Get display name for a pubkey
|
|
function getDisplayName(pubkey) {
|
|
const profile = 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);
|
|
}
|
|
|
|
// Batch fetch profiles for all conversations
|
|
async function fetchAllProfiles() {
|
|
const pubkeys = Array.from(conversations.keys());
|
|
console.log('Fetching profiles for', pubkeys.length, 'contacts...');
|
|
|
|
// Fetch in parallel but limit concurrency
|
|
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)));
|
|
|
|
// Re-render after each batch to show updates progressively
|
|
renderConversations();
|
|
}
|
|
|
|
console.log('✅ Profile fetching complete');
|
|
}
|
|
|
|
function saveMessagesToCache(pubkey) {
|
|
try {
|
|
const cacheData = {
|
|
version: CACHE_VERSION,
|
|
timestamp: Date.now(),
|
|
conversations: Array.from(conversations.entries()).map(([key, value]) => ({
|
|
pubkey: key,
|
|
messages: value.messages,
|
|
lastMessage: value.lastMessage,
|
|
unread: value.unread,
|
|
lastReadTimestamp: value.lastReadTimestamp || 0
|
|
})),
|
|
lastSyncTimestamp: lastSyncTimestamp
|
|
};
|
|
|
|
localStorage.setItem(getCacheKey(pubkey), JSON.stringify(cacheData));
|
|
console.log('💾 Saved', conversations.size, 'conversations to cache');
|
|
updateCacheInfo();
|
|
} catch (error) {
|
|
console.error('Failed to save cache:', error);
|
|
if (error.name === 'QuotaExceededError') {
|
|
clearOldCaches();
|
|
}
|
|
}
|
|
}
|
|
|
|
function loadMessagesFromCache(pubkey) {
|
|
try {
|
|
const cached = localStorage.getItem(getCacheKey(pubkey));
|
|
if (!cached) {
|
|
console.log('No cache found');
|
|
return false;
|
|
}
|
|
|
|
const cacheData = JSON.parse(cached);
|
|
|
|
if (cacheData.version !== CACHE_VERSION) {
|
|
console.log('Cache version mismatch, clearing');
|
|
localStorage.removeItem(getCacheKey(pubkey));
|
|
return false;
|
|
}
|
|
|
|
const cacheAge = Date.now() - cacheData.timestamp;
|
|
if (cacheAge > MAX_CACHE_AGE) {
|
|
console.log('Cache too old, clearing');
|
|
localStorage.removeItem(getCacheKey(pubkey));
|
|
return false;
|
|
}
|
|
|
|
conversations.clear();
|
|
cacheData.conversations.forEach(conv => {
|
|
conversations.set(conv.pubkey, {
|
|
messages: conv.messages,
|
|
lastMessage: conv.lastMessage,
|
|
unread: conv.unread || 0,
|
|
lastReadTimestamp: conv.lastReadTimestamp || 0
|
|
});
|
|
});
|
|
|
|
lastSyncTimestamp = cacheData.lastSyncTimestamp || 0;
|
|
|
|
console.log('✅ Loaded', conversations.size, 'conversations from cache');
|
|
console.log('Last sync:', new Date(lastSyncTimestamp * 1000).toLocaleString());
|
|
|
|
renderConversations();
|
|
updateCacheInfo();
|
|
return true;
|
|
} catch (error) {
|
|
console.error('Failed to load cache:', error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function clearOldCaches() {
|
|
const keys = Object.keys(localStorage);
|
|
keys.forEach(key => {
|
|
if (key.startsWith(CACHE_KEY_PREFIX)) {
|
|
try {
|
|
const data = JSON.parse(localStorage.getItem(key));
|
|
const age = Date.now() - data.timestamp;
|
|
if (age > MAX_CACHE_AGE) {
|
|
localStorage.removeItem(key);
|
|
console.log('Cleared old cache:', key);
|
|
}
|
|
} catch (e) {
|
|
localStorage.removeItem(key);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
function updateCacheInfo() {
|
|
const info = document.getElementById('cacheInfo');
|
|
if (conversations.size > 0) {
|
|
const msgCount = Array.from(conversations.values())
|
|
.reduce((sum, conv) => sum + conv.messages.length, 0);
|
|
const lastSync = lastSyncTimestamp > 0
|
|
? new Date(lastSyncTimestamp * 1000).toLocaleTimeString()
|
|
: 'Never';
|
|
info.textContent = `${conversations.size} chats • ${msgCount} messages • Last sync: ${lastSync}`;
|
|
} else {
|
|
info.textContent = '';
|
|
}
|
|
}
|
|
|
|
// Notification functions
|
|
async function requestNotificationPermission() {
|
|
if (!('Notification' in window)) {
|
|
console.log('❌ Notifications not supported');
|
|
return false;
|
|
}
|
|
|
|
if (!isPWA()) {
|
|
showStatus('Please install app to home screen first for notifications', 'error');
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
const permission = await Notification.requestPermission();
|
|
notificationPermission = permission;
|
|
|
|
if (permission === 'granted') {
|
|
console.log('✅ Notification permission granted');
|
|
showStatus('Notifications enabled!', 'success');
|
|
updateNotificationBadge();
|
|
return true;
|
|
} else {
|
|
showStatus('Notification permission denied', 'error');
|
|
return false;
|
|
}
|
|
} catch (error) {
|
|
console.error('Error requesting notification permission:', error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function updateNotificationBadge() {
|
|
const icon = document.getElementById('notifIcon');
|
|
const btn = document.getElementById('notifBtn');
|
|
if (!icon || !btn) return;
|
|
|
|
if (notificationPermission === 'granted') {
|
|
icon.textContent = '🔔';
|
|
btn.title = 'Notifications Enabled';
|
|
btn.style.background = 'linear-gradient(135deg, rgba(253, 173, 1, 0.2) 0%, rgba(255, 149, 0, 0.2) 100%)';
|
|
btn.style.borderColor = 'rgba(253, 173, 1, 0.4)';
|
|
} else {
|
|
icon.textContent = '🔕';
|
|
btn.title = 'Enable Notifications';
|
|
btn.style.background = '';
|
|
btn.style.borderColor = '';
|
|
}
|
|
}
|
|
|
|
function showNotificationSettings() {
|
|
if (notificationPermission === 'granted') {
|
|
showModal(
|
|
'Notifications Enabled',
|
|
'Push notifications are enabled. You\'ll receive alerts when new messages arrive.\n\nTo disable, go to your device Settings > Notifications.',
|
|
[{ text: 'OK', primary: true }]
|
|
);
|
|
} else {
|
|
showModal(
|
|
'Enable Notifications',
|
|
'Get notified when you receive new messages. This requires:\n\n1. App installed to home screen\n2. iOS 16.4+ or Android\n3. Permission granted',
|
|
[
|
|
{ text: 'Cancel', primary: false },
|
|
{ text: 'Enable', primary: true, action: requestNotificationPermission }
|
|
]
|
|
);
|
|
}
|
|
}
|
|
|
|
function showModal(title, message, buttons) {
|
|
const modal = document.createElement('div');
|
|
modal.className = 'modal-overlay';
|
|
modal.innerHTML = `
|
|
<div class="modal">
|
|
<h3>${title}</h3>
|
|
<p style="white-space: pre-line;">${message}</p>
|
|
<div class="btn-group">
|
|
${buttons.map((btn, i) =>
|
|
`<button class="btn ${btn.primary ? '' : 'btn-secondary'}" onclick="closeModal(${i})">${btn.text}</button>`
|
|
).join('')}
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
document.body.appendChild(modal);
|
|
window.modalButtons = buttons;
|
|
}
|
|
|
|
function closeModal(index) {
|
|
const overlay = document.querySelector('.modal-overlay');
|
|
if (overlay) {
|
|
overlay.remove();
|
|
if (window.modalButtons && window.modalButtons[index].action) {
|
|
window.modalButtons[index].action();
|
|
}
|
|
}
|
|
}
|
|
|
|
function showNotification(title, body, tag) {
|
|
if (notificationPermission !== 'granted') return;
|
|
if (document.hasFocus()) return; // Don't show if app is in focus
|
|
|
|
try {
|
|
if (serviceWorkerRegistration && serviceWorkerRegistration.active) {
|
|
serviceWorkerRegistration.showNotification(title, {
|
|
body: body,
|
|
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',
|
|
tag: tag || 'dm',
|
|
requireInteraction: false,
|
|
vibrate: [200, 100, 200]
|
|
});
|
|
} else {
|
|
new Notification(title, {
|
|
body: body,
|
|
tag: tag || 'dm',
|
|
requireInteraction: false
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error('Error showing notification:', error);
|
|
}
|
|
}
|
|
|
|
window.addEventListener('DOMContentLoaded', async () => {
|
|
console.log('🚀 Page loaded, checking for saved session...');
|
|
|
|
// Check notification permission
|
|
if ('Notification' in window) {
|
|
notificationPermission = Notification.permission;
|
|
updateNotificationBadge();
|
|
}
|
|
|
|
const restored = await restoreSession();
|
|
if (!restored) {
|
|
console.log('No valid session, showing login screen');
|
|
}
|
|
});
|
|
|
|
class NIP46Client {
|
|
constructor(appName) {
|
|
this.localPrivkey = generateSecretKey();
|
|
this.localPubkey = getPublicKey(this.localPrivkey);
|
|
this.appName = appName;
|
|
this.secret = this.generateSecret();
|
|
this.relays = currentRelays;
|
|
this.pendingRequests = new Map();
|
|
this.remotePubkey = null;
|
|
this.connected = false;
|
|
this.conversationKey = 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);
|
|
}
|
|
}
|
|
|
|
function showStatus(message, type = 'info') {
|
|
const status = document.getElementById('status');
|
|
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);
|
|
}
|
|
}
|
|
|
|
async function connectWithBunker() {
|
|
const bunkerUri = document.getElementById('bunkerInput').value.trim();
|
|
|
|
if (!bunkerUri || !bunkerUri.startsWith('bunker://')) {
|
|
showStatus('Please enter a valid bunker:// URI', 'error');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
showStatus('Parsing bunker URI...', 'info');
|
|
document.getElementById('bunkerWaiting').classList.remove('hidden');
|
|
document.getElementById('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(...currentRelays);
|
|
}
|
|
|
|
console.log('Connecting to bunker:', { remotePubkey, relays, secret });
|
|
|
|
nip46Connection = new NIP46Client('BTC for Plebs DMs');
|
|
nip46Connection.remotePubkey = remotePubkey;
|
|
nip46Connection.relays = relays;
|
|
nip46Connection.pool = new SimplePool();
|
|
|
|
nip46Connection.conversationKey = nip44.v2.utils.getConversationKey(
|
|
nip46Connection.localPrivkey,
|
|
remotePubkey
|
|
);
|
|
|
|
showStatus('Sending connect request to remote signer...', 'info');
|
|
|
|
const connectRequest = {
|
|
id: 'connect_' + Math.random().toString(36).substring(7),
|
|
method: 'connect',
|
|
params: [
|
|
nip46Connection.localPubkey,
|
|
secret || ''
|
|
]
|
|
};
|
|
|
|
const encrypted = nip44.v2.encrypt(
|
|
JSON.stringify(connectRequest),
|
|
nip46Connection.conversationKey
|
|
);
|
|
|
|
const connectEvent = finalizeEvent({
|
|
kind: 24133,
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
tags: [['p', remotePubkey]],
|
|
content: encrypted
|
|
}, nip46Connection.localPrivkey);
|
|
|
|
await 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);
|
|
|
|
nip46Connection.pendingRequests.set(connectRequest.id, { resolve, reject });
|
|
|
|
nip46Connection.sub = nip46Connection.pool.subscribeMany(
|
|
relays,
|
|
[{
|
|
kinds: [24133],
|
|
'#p': [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, 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);
|
|
nip46Connection.connected = true;
|
|
resolve(response);
|
|
return;
|
|
}
|
|
}
|
|
|
|
const pending = nip46Connection.pendingRequests.get(response.id);
|
|
if (pending) {
|
|
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);
|
|
|
|
userPubkey = await nip46Connection.getPublicKey();
|
|
pool = nip46Connection.pool;
|
|
|
|
const sessionData = {
|
|
bunkerUri: bunkerUri,
|
|
remotePubkey: remotePubkey,
|
|
relays: relays,
|
|
localPrivkey: Array.from(nip46Connection.localPrivkey).map(b => b.toString(16).padStart(2, '0')).join(''),
|
|
localPubkey: nip46Connection.localPubkey,
|
|
userPubkey: userPubkey,
|
|
timestamp: Date.now()
|
|
};
|
|
localStorage.setItem('bunker_session', JSON.stringify(sessionData));
|
|
console.log('💾 Saved bunker session');
|
|
|
|
document.getElementById('bunkerWaiting').classList.add('hidden');
|
|
showStatus('Connected to bunker successfully!', 'success');
|
|
|
|
initDashboard();
|
|
|
|
} catch (error) {
|
|
console.error('Bunker connection error:', error);
|
|
document.getElementById('bunkerWaiting').classList.add('hidden');
|
|
document.getElementById('connectBtn').disabled = false;
|
|
showStatus('Failed to connect: ' + error.message, 'error');
|
|
}
|
|
}
|
|
|
|
function initDashboard() {
|
|
console.log('🎯 Initializing dashboard...');
|
|
document.getElementById('loginSection').style.display = 'none';
|
|
const dashboardEl = document.getElementById('dashboard');
|
|
dashboardEl.style.display = 'flex';
|
|
dashboardEl.classList.add('active');
|
|
|
|
console.log('✅ Dashboard visible, loading DMs...');
|
|
console.log('User pubkey:', userPubkey);
|
|
console.log('Relays:', currentRelays);
|
|
|
|
// Prompt for notifications if PWA and not yet granted
|
|
if (isPWA() && notificationPermission === 'default') {
|
|
setTimeout(() => {
|
|
showModal(
|
|
'Enable Notifications?',
|
|
'Get notified when you receive new messages, even when the app is closed.',
|
|
[
|
|
{ text: 'Not Now', primary: false },
|
|
{ text: 'Enable', primary: true, action: requestNotificationPermission }
|
|
]
|
|
);
|
|
}, 2000);
|
|
}
|
|
|
|
const cachedLoaded = loadMessagesFromCache(userPubkey);
|
|
if (cachedLoaded && conversations.size > 0) {
|
|
document.querySelector('.loading-overlay')?.remove();
|
|
showStatus('Loaded from cache, syncing new messages...', 'info');
|
|
}
|
|
|
|
loadDMs();
|
|
}
|
|
|
|
async function loadDMs() {
|
|
console.log('📬 Subscribing to DMs...');
|
|
console.log('Current relays:', currentRelays);
|
|
console.log('User pubkey:', userPubkey);
|
|
console.log('Last sync timestamp:', lastSyncTimestamp, '(' + new Date(lastSyncTimestamp * 1000).toLocaleString() + ')');
|
|
|
|
// Always add a limit to prevent overwhelming the system, but make it higher for sync
|
|
const filters = lastSyncTimestamp > 0 ? [
|
|
{ kinds: [4], authors: [userPubkey], since: lastSyncTimestamp, limit: 500 },
|
|
{ kinds: [4], '#p': [userPubkey], since: lastSyncTimestamp, limit: 500 }
|
|
] : [
|
|
{ kinds: [4], authors: [userPubkey], limit: 200 },
|
|
{ kinds: [4], '#p': [userPubkey], limit: 200 }
|
|
];
|
|
|
|
console.log('Subscribing with filters:', JSON.stringify(filters, null, 2));
|
|
|
|
let eventCount = 0;
|
|
let newMessagesReceived = false;
|
|
|
|
const sub = pool.subscribeMany(
|
|
currentRelays,
|
|
filters,
|
|
{
|
|
onevent: async (event) => {
|
|
eventCount++;
|
|
console.log('📨 Received DM event #' + eventCount + ':', event.id, 'created:', new Date(event.created_at * 1000).toLocaleString());
|
|
|
|
const wasNew = await processDM(event);
|
|
if (wasNew) newMessagesReceived = true;
|
|
|
|
// Update last sync timestamp to the most recent event
|
|
if (event.created_at > lastSyncTimestamp) {
|
|
lastSyncTimestamp = event.created_at;
|
|
console.log('Updated lastSyncTimestamp to:', lastSyncTimestamp);
|
|
}
|
|
},
|
|
oneose: () => {
|
|
console.log('✅ DM subscription complete - received ' + eventCount + ' events');
|
|
|
|
// After initial sync, update timestamp to NOW so we're current
|
|
// This ensures we don't re-fetch messages we just received
|
|
const now = Math.floor(Date.now() / 1000);
|
|
if (lastSyncTimestamp < now) {
|
|
lastSyncTimestamp = now;
|
|
console.log('Updated lastSyncTimestamp to current time:', now);
|
|
}
|
|
|
|
renderConversations();
|
|
document.querySelector('.loading-overlay')?.remove();
|
|
|
|
// Save cache with updated timestamp
|
|
saveMessagesToCache(userPubkey);
|
|
|
|
if (newMessagesReceived) {
|
|
showStatus(eventCount > 0 ? `Synced ${eventCount} new messages` : 'Up to date', 'success');
|
|
} else {
|
|
showStatus('Up to date', 'success');
|
|
}
|
|
|
|
// Fetch profiles for all conversations
|
|
fetchAllProfiles();
|
|
}
|
|
}
|
|
);
|
|
|
|
subscriptions.push(sub);
|
|
|
|
if (!window.autoSaveInterval) {
|
|
window.autoSaveInterval = setInterval(() => {
|
|
if (userPubkey) {
|
|
saveMessagesToCache(userPubkey);
|
|
}
|
|
}, 30000);
|
|
}
|
|
}
|
|
|
|
async function processDM(event) {
|
|
try {
|
|
const otherPubkey = event.pubkey === userPubkey
|
|
? event.tags.find(t => t[0] === 'p')?.[1]
|
|
: event.pubkey;
|
|
|
|
if (!otherPubkey) return false;
|
|
|
|
let decryptedContent;
|
|
if (nip46Connection) {
|
|
decryptedContent = await nip46Connection.nip04Decrypt(otherPubkey, event.content);
|
|
} else {
|
|
decryptedContent = await nip04.decrypt(userPrivkey, otherPubkey, event.content);
|
|
}
|
|
|
|
if (!conversations.has(otherPubkey)) {
|
|
conversations.set(otherPubkey, {
|
|
messages: [],
|
|
lastMessage: null,
|
|
unread: 0,
|
|
lastReadTimestamp: 0
|
|
});
|
|
}
|
|
|
|
const conversation = conversations.get(otherPubkey);
|
|
const message = {
|
|
id: event.id,
|
|
content: decryptedContent,
|
|
timestamp: event.created_at,
|
|
from: event.pubkey,
|
|
isSent: event.pubkey === userPubkey
|
|
};
|
|
|
|
// Check if this is a new message
|
|
const isNewMessage = !conversation.messages.find(m => m.id === message.id);
|
|
|
|
if (isNewMessage) {
|
|
conversation.messages.push(message);
|
|
conversation.messages.sort((a, b) => a.timestamp - b.timestamp);
|
|
conversation.lastMessage = message;
|
|
|
|
if (!message.isSent &&
|
|
otherPubkey !== currentChatPubkey &&
|
|
message.timestamp > conversation.lastReadTimestamp) {
|
|
conversation.unread++;
|
|
|
|
// Show notification for new message
|
|
const displayName = getDisplayName(otherPubkey);
|
|
showNotification(
|
|
`New message from ${displayName}`,
|
|
decryptedContent.substring(0, 100),
|
|
`dm-${otherPubkey}`
|
|
);
|
|
}
|
|
|
|
renderConversations();
|
|
|
|
if (currentChatPubkey === otherPubkey) {
|
|
renderChat(otherPubkey);
|
|
}
|
|
|
|
return true; // Return true if it was a new message
|
|
}
|
|
|
|
return false; // Return false if message already existed
|
|
} catch (error) {
|
|
console.error('DM error:', error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function renderConversations() {
|
|
const listEl = document.getElementById('conversationsList');
|
|
|
|
const loading = listEl.querySelector('.loading-overlay');
|
|
if (loading) loading.remove();
|
|
|
|
const sorted = Array.from(conversations.entries())
|
|
.sort((a, b) => {
|
|
const timeA = a[1].lastMessage?.timestamp || 0;
|
|
const timeB = b[1].lastMessage?.timestamp || 0;
|
|
return timeB - timeA;
|
|
});
|
|
|
|
// Update the conversations list for keyboard navigation
|
|
conversationsList = sorted.map(([pubkey]) => pubkey);
|
|
|
|
listEl.innerHTML = 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 = currentChatPubkey === pubkey ? 'active' : '';
|
|
|
|
// Show name if available, otherwise show npub
|
|
const profile = 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;">${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('');
|
|
|
|
// Restore keyboard selection highlight if applicable
|
|
if (selectedConversationIndex >= 0) {
|
|
highlightSelectedConversation();
|
|
}
|
|
|
|
updateCacheInfo();
|
|
}
|
|
|
|
function openChat(pubkey) {
|
|
currentChatPubkey = pubkey;
|
|
const conversation = conversations.get(pubkey);
|
|
|
|
// Update selected index to match opened chat
|
|
selectedConversationIndex = conversationsList.indexOf(pubkey);
|
|
|
|
if (conversation) {
|
|
conversation.unread = 0;
|
|
if (conversation.lastMessage) {
|
|
conversation.lastReadTimestamp = conversation.lastMessage.timestamp;
|
|
}
|
|
saveMessagesToCache(userPubkey);
|
|
renderConversations();
|
|
}
|
|
|
|
document.getElementById('emptyState').classList.add('hidden');
|
|
document.getElementById('chatView').classList.remove('hidden');
|
|
|
|
if (window.innerWidth < 768) {
|
|
document.querySelector('.sidebar').classList.add('hidden-mobile');
|
|
document.querySelector('.chat-area').classList.add('visible-mobile');
|
|
}
|
|
|
|
const displayName = getDisplayName(pubkey);
|
|
const npub = nip19.npubEncode(pubkey);
|
|
|
|
document.getElementById('chatContactName').textContent = displayName;
|
|
document.getElementById('chatContactNpub').textContent = npub;
|
|
|
|
renderChat(pubkey);
|
|
|
|
// Focus on message input for immediate typing
|
|
setTimeout(() => {
|
|
document.getElementById('messageInput')?.focus();
|
|
}, 100);
|
|
}
|
|
|
|
function backToConversations() {
|
|
document.querySelector('.sidebar').classList.remove('hidden-mobile');
|
|
document.querySelector('.chat-area').classList.remove('visible-mobile');
|
|
currentChatPubkey = null;
|
|
|
|
// Reset selection when going back
|
|
selectedConversationIndex = -1;
|
|
highlightSelectedConversation();
|
|
}
|
|
|
|
function renderChat(pubkey) {
|
|
const messagesEl = document.getElementById('chatMessages');
|
|
const conversation = 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;
|
|
}
|
|
|
|
async function sendDM() {
|
|
const input = document.getElementById('messageInput');
|
|
const content = input.value.trim();
|
|
|
|
if (!content || !currentChatPubkey) return;
|
|
|
|
try {
|
|
showStatus('Sending...', 'info');
|
|
|
|
let encryptedContent;
|
|
if (nip46Connection) {
|
|
encryptedContent = await nip46Connection.nip04Encrypt(currentChatPubkey, content);
|
|
} else {
|
|
encryptedContent = await nip04.encrypt(userPrivkey, currentChatPubkey, content);
|
|
}
|
|
|
|
const event = {
|
|
kind: 4,
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
tags: [['p', currentChatPubkey]],
|
|
content: encryptedContent
|
|
};
|
|
|
|
let signedEvent;
|
|
if (nip46Connection) {
|
|
signedEvent = await nip46Connection.signEvent(event);
|
|
} else {
|
|
signedEvent = finalizeEvent(event, userPrivkey);
|
|
}
|
|
|
|
await pool.publish(currentRelays, signedEvent);
|
|
|
|
const conversation = conversations.get(currentChatPubkey);
|
|
if (conversation) {
|
|
const message = {
|
|
id: signedEvent.id,
|
|
content: content,
|
|
timestamp: signedEvent.created_at,
|
|
from: userPubkey,
|
|
isSent: true
|
|
};
|
|
|
|
if (!conversation.messages.find(m => m.id === message.id)) {
|
|
conversation.messages.push(message);
|
|
conversation.lastMessage = message;
|
|
}
|
|
}
|
|
|
|
input.value = '';
|
|
renderChat(currentChatPubkey);
|
|
renderConversations();
|
|
|
|
saveMessagesToCache(userPubkey);
|
|
|
|
showStatus('Sent!', 'success');
|
|
|
|
} catch (error) {
|
|
console.error('Send error:', error);
|
|
showStatus('Failed to send: ' + error.message, 'error');
|
|
}
|
|
}
|
|
|
|
function handleKeyPress(event) {
|
|
if (event.key === 'Enter') {
|
|
sendDM();
|
|
}
|
|
}
|
|
|
|
function filterConversations() {
|
|
const searchTerm = document.getElementById('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 disconnect() {
|
|
subscriptions.forEach(sub => sub.close());
|
|
subscriptions = [];
|
|
|
|
if (pool) pool.close(currentRelays);
|
|
if (nip46Connection) nip46Connection.close();
|
|
|
|
localStorage.removeItem('bunker_session');
|
|
console.log('🗑️ Cleared saved session (kept message cache)');
|
|
|
|
userPubkey = null;
|
|
userPrivkey = null;
|
|
nip46Connection = null;
|
|
conversations.clear();
|
|
currentChatPubkey = null;
|
|
lastSyncTimestamp = 0;
|
|
|
|
document.getElementById('dashboard').style.display = 'none';
|
|
document.getElementById('loginSection').style.display = 'block';
|
|
document.getElementById('conversationsList').innerHTML = '<div class="loading-overlay"><div class="spinner"></div>Loading DMs...</div>';
|
|
document.getElementById('chatMessages').innerHTML = '';
|
|
document.getElementById('emptyState').classList.remove('hidden');
|
|
document.getElementById('chatView').classList.add('hidden');
|
|
document.getElementById('cacheInfo').textContent = '';
|
|
|
|
showStatus('Disconnected', 'info');
|
|
}
|
|
|
|
function escapeHtml(text) {
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
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');
|
|
|
|
nip46Connection = new NIP46Client('BTC for Plebs DMs');
|
|
nip46Connection.remotePubkey = sessionData.remotePubkey;
|
|
nip46Connection.relays = sessionData.relays;
|
|
nip46Connection.localPubkey = sessionData.localPubkey;
|
|
|
|
nip46Connection.localPrivkey = new Uint8Array(
|
|
sessionData.localPrivkey.match(/.{1,2}/g).map(byte => parseInt(byte, 16))
|
|
);
|
|
|
|
nip46Connection.pool = new SimplePool();
|
|
nip46Connection.conversationKey = nip44.v2.utils.getConversationKey(
|
|
nip46Connection.localPrivkey,
|
|
sessionData.remotePubkey
|
|
);
|
|
nip46Connection.connected = true;
|
|
|
|
const nip46Sub = nip46Connection.pool.subscribeMany(
|
|
sessionData.relays,
|
|
[{
|
|
kinds: [24133],
|
|
'#p': [nip46Connection.localPubkey],
|
|
since: Math.floor(Date.now() / 1000) - 60
|
|
}],
|
|
{
|
|
onevent: async (event) => {
|
|
await nip46Connection.handleResponse(event);
|
|
}
|
|
}
|
|
);
|
|
|
|
nip46Connection.sub = nip46Sub;
|
|
|
|
userPubkey = sessionData.userPubkey;
|
|
pool = nip46Connection.pool;
|
|
currentRelays = sessionData.relays;
|
|
|
|
showStatus('Session restored successfully!', 'success');
|
|
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;
|
|
}
|
|
}
|
|
</script>
|
|
</body>
|
|
</html> |