mirror of
https://github.com/MarekWo/mc-webui.git
synced 2026-08-11 11:12:55 +02:00
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:
@@ -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
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user