feat: Add direct messages (DM) support

- Parse PRIV (incoming) and SENT_MSG (outgoing) message types
- Add DM API endpoints: conversations, messages, updates
- Implement conversation grouping by pubkey_prefix or name
- Add timeout-based delivery status (pending → timeout)
- Add DM modal with conversation list and thread views
- Add dual notification badge (blue=channels, green=DM)
- Add DM button next to Reply on channel messages
- Include message deduplication for both incoming and outgoing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
MarekWo
2025-12-25 17:29:06 +01:00
parent 80e9405449
commit 1d2cc7fe18
7 changed files with 1305 additions and 5 deletions
+153
View File
@@ -329,3 +329,156 @@ main {
transform: scale(1.1);
transition: transform 0.2s ease;
}
/* =============================================================================
Direct Messages (DM) Styles
============================================================================= */
/* DM Badge on notification bell (secondary badge, bottom-right, green) */
.notification-badge-dm {
position: absolute;
bottom: -6px;
right: -6px;
background-color: #198754;
color: white;
border-radius: 8px;
padding: 1px 4px;
font-size: 0.6rem;
font-weight: bold;
min-width: 14px;
text-align: center;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
z-index: 10;
}
/* DM Messages Container */
.dm-messages-container {
height: 50vh;
overflow-y: auto;
padding: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
background-color: #fafafa;
}
@media (max-width: 576px) {
.dm-messages-container {
height: calc(100vh - 200px);
}
}
/* DM Message Bubbles */
.dm-message {
max-width: 80%;
padding: 0.5rem 0.75rem;
border-radius: 1rem;
font-size: 0.9rem;
word-wrap: break-word;
animation: fadeIn 0.2s ease-in;
}
.dm-message.own {
align-self: flex-end;
background-color: var(--msg-own-bg);
border: 1px solid #b8daff;
}
.dm-message.other {
align-self: flex-start;
background-color: var(--msg-other-bg);
border: 1px solid var(--msg-border);
}
/* DM Message Metadata */
.dm-meta {
font-size: 0.65rem;
color: #adb5bd;
margin-top: 0.25rem;
}
/* DM Status Indicators */
.dm-status {
font-size: 0.7rem;
margin-left: 0.25rem;
}
.dm-status.pending {
color: #ffc107;
}
.dm-status.delivered {
color: #198754;
}
.dm-status.timeout {
color: #dc3545;
}
/* DM Conversation List Item */
.dm-conversation-item {
padding: 0.75rem;
border-bottom: 1px solid #dee2e6;
cursor: pointer;
transition: background-color 0.15s ease;
}
.dm-conversation-item:hover {
background-color: #f8f9fa;
}
.dm-conversation-item.unread {
background-color: #e7f1ff;
}
.dm-conversation-item:last-child {
border-bottom: none;
}
/* DM Preview Text */
.dm-preview {
font-size: 0.85rem;
color: #6c757d;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
/* DM Button on channel messages */
.btn-dm {
font-size: 0.65rem;
padding: 0.1rem 0.3rem;
margin-left: 0.25rem;
}
/* DM Empty State */
.dm-empty-state {
text-align: center;
padding: 2rem 1rem;
color: #6c757d;
}
.dm-empty-state i {
font-size: 2.5rem;
margin-bottom: 0.75rem;
opacity: 0.5;
}
/* DM Scrollbar */
.dm-messages-container::-webkit-scrollbar {
width: 6px;
}
.dm-messages-container::-webkit-scrollbar-track {
background: #f1f1f1;
}
.dm-messages-container::-webkit-scrollbar-thumb {
background: #ccc;
border-radius: 3px;
}
.dm-messages-container::-webkit-scrollbar-thumb:hover {
background: #aaa;
}
+457 -3
View File
@@ -12,12 +12,19 @@ let availableChannels = []; // List of channels from API
let lastSeenTimestamps = {}; // Track last seen message timestamp per channel
let unreadCounts = {}; // Track unread message counts per channel
// DM state
let dmLastSeenTimestamps = {}; // Track last seen DM timestamp per conversation
let dmUnreadCounts = {}; // Track unread DM counts per conversation
let currentDmConversation = null; // Currently open DM conversation ID
let currentDmRecipient = null; // Current DM recipient name
// Initialize on page load
document.addEventListener('DOMContentLoaded', async function() {
console.log('mc-webui initialized');
// Load last seen timestamps from localStorage
loadLastSeenTimestamps();
loadDmLastSeenTimestamps();
// Restore last selected channel from localStorage
const savedChannel = localStorage.getItem('mc_active_channel');
@@ -222,6 +229,40 @@ function setupEventListeners() {
showNotification('QR scanning feature coming soon! For now, manually enter the channel details.', 'info');
});
// DM Modal - load conversations when opened
const dmModal = document.getElementById('dmModal');
if (dmModal) {
dmModal.addEventListener('show.bs.modal', function() {
loadDmConversations();
closeDmThread(); // Reset to conversation list view
});
}
// DM send form
const dmSendForm = document.getElementById('dmSendForm');
if (dmSendForm) {
dmSendForm.addEventListener('submit', async function(e) {
e.preventDefault();
await sendDmMessage();
});
}
// DM message input - character counter
const dmInput = document.getElementById('dmMessageInput');
if (dmInput) {
dmInput.addEventListener('input', function() {
updateDmCharCounter();
});
// Handle Enter key
dmInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendDmMessage();
}
});
}
// Network Commands: Advert button
document.getElementById('advertBtn').addEventListener('click', async function() {
await executeSpecialCommand('advert');
@@ -337,9 +378,16 @@ function createMessageElement(msg) {
</div>
<p class="message-content">${escapeHtml(msg.content)}</p>
${metaInfo ? `<div class="message-meta">${metaInfo}</div>` : ''}
${!msg.is_own ? `<button class="btn btn-outline-secondary btn-sm btn-reply" onclick="replyTo('${escapeHtml(msg.sender)}')">
<i class="bi bi-reply"></i> Reply
</button>` : ''}
${!msg.is_own ? `
<div class="mt-1">
<button class="btn btn-outline-secondary btn-sm btn-reply" onclick="replyTo('${escapeHtml(msg.sender)}')">
<i class="bi bi-reply"></i> Reply
</button>
<button class="btn btn-outline-primary btn-sm btn-dm" onclick="startDmTo('${escapeHtml(msg.sender)}')">
<i class="bi bi-envelope"></i> DM
</button>
</div>
` : ''}
`;
return div;
@@ -535,6 +583,7 @@ function setupAutoRefresh() {
}
await checkForUpdates();
await checkDmUpdates(); // Also check for DM updates
}, checkInterval);
console.log(`Intelligent auto-refresh enabled: checking every ${checkInterval / 1000}s`);
@@ -1149,3 +1198,408 @@ async function copyChannelKey() {
}
}
}
// =============================================================================
// Direct Messages (DM) Functions
// =============================================================================
/**
* Load DM last seen timestamps from localStorage
*/
function loadDmLastSeenTimestamps() {
try {
const saved = localStorage.getItem('mc_dm_last_seen_timestamps');
if (saved) {
dmLastSeenTimestamps = JSON.parse(saved);
console.log('Loaded DM last seen timestamps:', Object.keys(dmLastSeenTimestamps).length);
}
} catch (error) {
console.error('Error loading DM last seen timestamps:', error);
dmLastSeenTimestamps = {};
}
}
/**
* Save DM last seen timestamps to localStorage
*/
function saveDmLastSeenTimestamps() {
try {
localStorage.setItem('mc_dm_last_seen_timestamps', JSON.stringify(dmLastSeenTimestamps));
} catch (error) {
console.error('Error saving DM last seen timestamps:', error);
}
}
/**
* Load DM conversations list
*/
async function loadDmConversations() {
const listEl = document.getElementById('dmConversationList');
if (!listEl) return;
listEl.innerHTML = '<div class="text-center py-4"><div class="spinner-border spinner-border-sm"></div> Loading...</div>';
try {
const response = await fetch('/api/dm/conversations?days=7');
const data = await response.json();
if (data.success) {
displayDmConversations(data.conversations);
} else {
listEl.innerHTML = '<div class="text-center text-danger py-4">Error loading conversations</div>';
}
} catch (error) {
console.error('Error loading DM conversations:', error);
listEl.innerHTML = '<div class="text-center text-danger py-4">Failed to load conversations</div>';
}
}
/**
* Display DM conversations list
*/
function displayDmConversations(conversations) {
const listEl = document.getElementById('dmConversationList');
if (!listEl) return;
if (!conversations || conversations.length === 0) {
listEl.innerHTML = `
<div class="dm-empty-state">
<i class="bi bi-envelope"></i>
<p class="mb-1">No direct messages yet</p>
<small class="text-muted">Start a conversation by clicking DM on any message</small>
</div>
`;
return;
}
listEl.innerHTML = conversations.map(conv => {
const lastSeen = dmLastSeenTimestamps[conv.conversation_id] || 0;
const isUnread = conv.last_message_timestamp > lastSeen;
return `
<div class="dm-conversation-item ${isUnread ? 'unread' : ''}"
onclick="openDmThread('${escapeHtml(conv.conversation_id)}', '${escapeHtml(conv.display_name)}')">
<div class="d-flex justify-content-between align-items-start">
<strong>${escapeHtml(conv.display_name)}</strong>
<small class="text-muted">${formatTime(conv.last_message_timestamp)}</small>
</div>
<div class="dm-preview">${escapeHtml(conv.last_message_preview)}</div>
${isUnread ? '<span class="badge bg-primary mt-1">New</span>' : ''}
</div>
`;
}).join('');
}
/**
* Open a specific DM thread
*/
async function openDmThread(conversationId, displayName) {
currentDmConversation = conversationId;
currentDmRecipient = displayName;
// Show thread view, hide conversation list
document.getElementById('dmConversationList').style.display = 'none';
document.getElementById('dmThread').style.display = 'block';
document.getElementById('dmThreadRecipient').textContent = displayName;
// Clear input
const input = document.getElementById('dmMessageInput');
if (input) {
input.value = '';
updateDmCharCounter();
}
await loadDmMessages(conversationId);
}
/**
* Close DM thread, return to conversation list
*/
function closeDmThread() {
currentDmConversation = null;
currentDmRecipient = null;
const threadEl = document.getElementById('dmThread');
const listEl = document.getElementById('dmConversationList');
if (threadEl) threadEl.style.display = 'none';
if (listEl) listEl.style.display = 'block';
}
/**
* Load DM messages for a conversation
*/
async function loadDmMessages(conversationId) {
const listEl = document.getElementById('dmMessagesList');
if (!listEl) return;
listEl.innerHTML = '<div class="text-center py-4"><div class="spinner-border spinner-border-sm"></div></div>';
try {
const response = await fetch(`/api/dm/messages?conversation_id=${encodeURIComponent(conversationId)}&limit=100`);
const data = await response.json();
if (data.success) {
displayDmMessages(data.messages);
// Update recipient name if we got a better one
if (data.display_name && data.display_name !== 'Unknown') {
currentDmRecipient = data.display_name;
document.getElementById('dmThreadRecipient').textContent = data.display_name;
}
// Mark conversation as read
if (data.messages && data.messages.length > 0) {
const latestTs = Math.max(...data.messages.map(m => m.timestamp));
markDmAsRead(conversationId, latestTs);
}
} else {
listEl.innerHTML = '<div class="text-center text-danger py-4">Error loading messages</div>';
}
} catch (error) {
console.error('Error loading DM messages:', error);
listEl.innerHTML = '<div class="text-center text-danger py-4">Failed to load messages</div>';
}
}
/**
* Display DM messages in thread view
*/
function displayDmMessages(messages) {
const listEl = document.getElementById('dmMessagesList');
if (!listEl) return;
if (!messages || messages.length === 0) {
listEl.innerHTML = `
<div class="dm-empty-state">
<i class="bi bi-chat-dots"></i>
<p>No messages in this conversation</p>
</div>
`;
return;
}
listEl.innerHTML = '';
messages.forEach(msg => {
const div = document.createElement('div');
div.className = `dm-message ${msg.is_own ? 'own' : 'other'}`;
// Status icon for own messages
let statusIcon = '';
if (msg.is_own && msg.status) {
const icons = {
'pending': '<i class="bi bi-clock dm-status pending" title="Sending..."></i>',
'delivered': '<i class="bi bi-check2 dm-status delivered" title="Delivered"></i>',
'timeout': '<i class="bi bi-x-circle dm-status timeout" title="Not delivered"></i>'
};
statusIcon = icons[msg.status] || '';
}
// Metadata for incoming messages
let meta = '';
if (!msg.is_own) {
const parts = [];
if (msg.snr !== null && msg.snr !== undefined) {
parts.push(`SNR: ${msg.snr.toFixed(1)}`);
}
if (msg.path_len !== null && msg.path_len !== undefined) {
parts.push(`Hops: ${msg.path_len}`);
}
if (parts.length > 0) {
meta = `<div class="dm-meta">${parts.join(' | ')}</div>`;
}
}
div.innerHTML = `
<div class="d-flex justify-content-between align-items-center" style="font-size: 0.7rem;">
<span class="text-muted">${formatTime(msg.timestamp)}</span>
${statusIcon}
</div>
<div>${escapeHtml(msg.content)}</div>
${meta}
`;
listEl.appendChild(div);
});
// Scroll to bottom
listEl.scrollTop = listEl.scrollHeight;
}
/**
* Send a DM message
*/
async function sendDmMessage() {
const input = document.getElementById('dmMessageInput');
if (!input) return;
const text = input.value.trim();
if (!text || !currentDmRecipient) return;
const submitBtn = document.querySelector('#dmSendForm button[type="submit"]');
if (submitBtn) submitBtn.disabled = true;
try {
const response = await fetch('/api/dm/messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
recipient: currentDmRecipient,
text: text
})
});
const data = await response.json();
if (data.success) {
input.value = '';
updateDmCharCounter();
showNotification('DM sent', 'success');
// Reload messages after short delay
if (currentDmConversation) {
setTimeout(() => loadDmMessages(currentDmConversation), 1000);
}
} else {
showNotification('Failed to send DM: ' + data.error, 'danger');
}
} catch (error) {
console.error('Error sending DM:', error);
showNotification('Failed to send DM', 'danger');
} finally {
if (submitBtn) submitBtn.disabled = false;
input.focus();
}
}
/**
* Start DM from channel message (DM button click)
*/
function startDmTo(username) {
// Open DM modal
const modal = new bootstrap.Modal(document.getElementById('dmModal'));
modal.show();
// Open thread view for this user
const conversationId = `name_${username}`;
setTimeout(() => {
openDmThread(conversationId, username);
}, 300); // Small delay for modal animation
}
/**
* Check for new DMs (called by auto-refresh)
*/
async function checkDmUpdates() {
try {
const lastSeenParam = encodeURIComponent(JSON.stringify(dmLastSeenTimestamps));
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000);
const response = await fetch(`/api/dm/updates?last_seen=${lastSeenParam}`, {
signal: controller.signal
});
clearTimeout(timeoutId);
if (!response.ok) return;
const data = await response.json();
if (data.success) {
// Update unread counts
dmUnreadCounts = {};
if (data.conversations) {
data.conversations.forEach(conv => {
dmUnreadCounts[conv.conversation_id] = conv.unread_count;
});
}
// Update badges
updateDmBadges(data.total_unread || 0);
}
} catch (error) {
if (error.name !== 'AbortError') {
console.error('Error checking DM updates:', error);
}
}
}
/**
* Update DM notification badges
*/
function updateDmBadges(totalUnread) {
// Update menu badge
const menuBadge = document.getElementById('dmMenuBadge');
if (menuBadge) {
if (totalUnread > 0) {
menuBadge.textContent = totalUnread > 99 ? '99+' : totalUnread;
menuBadge.style.display = 'inline-block';
} else {
menuBadge.style.display = 'none';
}
}
// Update notification bell (secondary badge)
const bellContainer = document.getElementById('notificationBell');
if (!bellContainer) return;
let dmBadge = bellContainer.querySelector('.notification-badge-dm');
if (totalUnread > 0) {
if (!dmBadge) {
dmBadge = document.createElement('span');
dmBadge.className = 'notification-badge-dm';
bellContainer.appendChild(dmBadge);
}
dmBadge.textContent = totalUnread > 99 ? '99+' : totalUnread;
dmBadge.style.display = 'inline-block';
// Animate bell
const bellIcon = bellContainer.querySelector('i');
if (bellIcon) {
bellIcon.classList.add('bell-ring');
setTimeout(() => bellIcon.classList.remove('bell-ring'), 1000);
}
} else if (dmBadge) {
dmBadge.style.display = 'none';
}
}
/**
* Mark DM conversation as read
*/
function markDmAsRead(conversationId, timestamp) {
dmLastSeenTimestamps[conversationId] = timestamp;
dmUnreadCounts[conversationId] = 0;
saveDmLastSeenTimestamps();
// Recalculate total unread
const totalUnread = Object.values(dmUnreadCounts).reduce((sum, count) => sum + count, 0);
updateDmBadges(totalUnread);
}
/**
* Update DM character counter
*/
function updateDmCharCounter() {
const input = document.getElementById('dmMessageInput');
const counter = document.getElementById('dmCharCounter');
if (!input || !counter) return;
const encoder = new TextEncoder();
const byteLength = encoder.encode(input.value).length;
counter.textContent = byteLength;
// Visual warning
if (byteLength > 180) {
counter.classList.add('text-danger');
} else if (byteLength > 150) {
counter.classList.remove('text-danger');
counter.classList.add('text-warning');
} else {
counter.classList.remove('text-danger', 'text-warning');
}
}