refactor(dm): Replace modal with full-page view and fix sent DM tracking

- Replace DM modal with full-page view at /dm route for better mobile UX
- Add workaround for meshcore-cli bug where SENT_MSG contains sender's
  name instead of recipient - now saving sent DMs to separate log file
- Fix DM button styling to match Reply button (btn-outline-secondary)
- Add dm.js for DM page functionality
- Add dm.html template with green navbar for visual distinction
- Update menu link to navigate to /dm instead of opening modal
- Remove unused DM modal functions from app.js
- Update documentation with new DM workflow

🤖 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 20:27:02 +01:00
parent 1d2cc7fe18
commit 83b51ab2b9
10 changed files with 858 additions and 498 deletions
+14 -10
View File
@@ -140,10 +140,12 @@ mc-webui/
│ │ ├── css/
│ │ │ └── style.css # Custom styles
│ │ └── js/
│ │ ── app.js # Frontend logic
│ │ ── app.js # Main page frontend logic
│ │ └── dm.js # Direct Messages page logic
│ └── templates/
│ ├── base.html # Base template
│ ├── index.html # Main chat view
│ ├── dm.html # Direct Messages full-page view
│ └── components/ # Reusable components
├── requirements.txt # Python dependencies
├── .env.example # Example environment config
@@ -254,20 +256,22 @@ Click the reply button on any message to insert `@[UserName]` into the text fiel
### Direct Messages (DM)
Access the Direct Messages feature from the slide-out menu:
Access the Direct Messages feature:
**From the menu:**
1. Click the menu icon (☰) in the navbar
2. Select "Direct Messages" from the menu
3. View your conversation list with unread indicators
3. Opens a dedicated full-page DM view
**Starting a new conversation:**
- Click the "DM" button next to any channel message to start a private chat with that user
- Or select an existing conversation from the DM list
**From channel messages:**
- Click the "DM" button next to any message to start a private chat with that user
- You'll be redirected to the DM page with that conversation selected
**Sending a direct message:**
1. Open a conversation
2. Type your message (max 200 characters)
**Using the DM page:**
1. Select a conversation from the dropdown at the top (or one opens automatically if started from a message)
2. Type your message in the input field (max 200 bytes)
3. Press Enter or click Send
4. Click "Back" button to return to the main chat view
**Message status indicators:**
- ⏳ **Pending** (yellow) - Message sent, waiting for delivery confirmation
@@ -275,7 +279,7 @@ Access the Direct Messages feature from the slide-out menu:
**Notifications:**
- The bell icon shows a secondary green badge for unread DMs
- Each conversation shows unread count in the conversation list
- Each conversation shows unread indicator (*) in the dropdown
- DM badge in the menu shows total unread DM count
### Managing Contacts
+5
View File
@@ -42,6 +42,11 @@ class Config:
"""Get the full path to archive directory"""
return Path(self.MC_ARCHIVE_DIR)
@property
def dm_sent_log_path(self) -> Path:
"""Get the full path to our sent DM log file (workaround for meshcore-cli bug)"""
return Path(self.MC_CONFIG_DIR) / f"{self.MC_DEVICE_NAME}_dm_sent.jsonl"
def __repr__(self):
return (
f"Config(device={self.MC_DEVICE_NAME}, "
+151 -110
View File
@@ -313,6 +313,73 @@ def delete_channel_messages(channel_idx: int) -> bool:
# =============================================================================
# Direct Messages (DM) Parsing
# =============================================================================
#
# Note: meshcore-cli has a bug where SENT_MSG entries contain the sender's
# device name instead of the recipient's name. To work around this, we maintain
# our own sent DM log file with correct recipient information.
# See: https://github.com/liamcottle/meshcore-cli/issues/XXX
# =============================================================================
def save_sent_dm(recipient: str, text: str) -> bool:
"""
Save a sent DM to our own log file (workaround for meshcore-cli bug).
Args:
recipient: Contact name the message was sent to
text: Message content
Returns:
True if saved successfully, False otherwise
"""
dm_log_file = config.dm_sent_log_path
entry = {
'timestamp': int(time.time()),
'recipient': recipient,
'text': text,
'status': 'pending'
}
try:
with open(dm_log_file, 'a', encoding='utf-8') as f:
f.write(json.dumps(entry, ensure_ascii=False) + '\n')
logger.info(f"Saved sent DM to {recipient}")
return True
except Exception as e:
logger.error(f"Error saving sent DM: {e}")
return False
def _read_sent_dm_log() -> List[Dict]:
"""
Read sent DMs from our own log file.
Returns:
List of sent DM entries
"""
dm_log_file = config.dm_sent_log_path
if not dm_log_file.exists():
return []
entries = []
try:
with open(dm_log_file, 'r', encoding='utf-8') as f:
for line_num, line in enumerate(f, 1):
line = line.strip()
if not line:
continue
try:
data = json.loads(line)
entries.append(data)
except json.JSONDecodeError as e:
logger.warning(f"Invalid JSON in DM log at line {line_num}: {e}")
continue
except Exception as e:
logger.error(f"Error reading sent DM log: {e}")
return entries
def _parse_priv_message(line: Dict) -> Optional[Dict]:
"""
@@ -361,41 +428,32 @@ def _parse_priv_message(line: Dict) -> Optional[Dict]:
}
def _parse_sent_msg(line: Dict) -> Optional[Dict]:
def _parse_sent_dm_entry(entry: Dict) -> Optional[Dict]:
"""
Parse outgoing private message (SENT_MSG type).
Parse a sent DM entry from our own log file.
Args:
line: Raw JSON object from .msgs file with type='SENT_MSG'
entry: Entry from our dm_sent.jsonl file
Returns:
Parsed DM dict or None if invalid
"""
text = line.get('text', '').strip()
text = entry.get('text', '').strip()
if not text:
return None
timestamp = line.get('timestamp', 0)
recipient = line.get('name', 'Unknown')
expected_ack = line.get('expected_ack', '')
suggested_timeout = line.get('suggested_timeout', 10000) # Default 10s
timestamp = entry.get('timestamp', 0)
recipient = entry.get('recipient', 'Unknown')
# Generate conversation ID from recipient name
conversation_id = f"name_{recipient}"
# Deduplication key - use expected_ack if available
if expected_ack:
dedup_key = f"sent_{expected_ack}"
else:
text_hash = hash(text[:50]) & 0xFFFFFFFF
dedup_key = f"sent_{timestamp}_{text_hash}"
# Deduplication key
text_hash = hash(text[:50]) & 0xFFFFFFFF
dedup_key = f"sent_{timestamp}_{text_hash}"
# Calculate status based on timeout
age_ms = (time.time() - timestamp) * 1000
if age_ms > suggested_timeout:
status = 'timeout'
else:
status = 'pending'
# Status is always timeout for old messages (we don't have ACK tracking)
status = 'timeout'
return {
'type': 'dm',
@@ -405,42 +463,22 @@ def _parse_sent_msg(line: Dict) -> Optional[Dict]:
'timestamp': timestamp,
'datetime': datetime.fromtimestamp(timestamp).isoformat() if timestamp > 0 else None,
'is_own': True,
'expected_ack': expected_ack,
'suggested_timeout': suggested_timeout,
'status': status,
'txt_type': line.get('txt_type', 0),
'conversation_id': conversation_id,
'dedup_key': dedup_key
}
def parse_dm_message(line: Dict) -> Optional[Dict]:
"""
Parse a DM message (PRIV or SENT_MSG) from .msgs file.
Args:
line: Raw JSON object from .msgs file
Returns:
Parsed DM dict or None if not a valid DM message
"""
msg_type = line.get('type')
if msg_type == 'PRIV':
return _parse_priv_message(line)
elif msg_type == 'SENT_MSG':
return _parse_sent_msg(line)
return None
def read_dm_messages(
limit: Optional[int] = None,
conversation_id: Optional[str] = None,
days: Optional[int] = 7
) -> Tuple[List[Dict], Dict[str, str]]:
"""
Read and parse DM messages from .msgs file.
Read and parse DM messages from .msgs file (incoming) and our sent DM log (outgoing).
Note: We ignore SENT_MSG entries from .msgs because they have the wrong recipient
due to a bug in meshcore-cli.
Args:
limit: Maximum messages to return (None = all)
@@ -451,84 +489,87 @@ def read_dm_messages(
Tuple of (messages_list, pubkey_to_name_mapping)
The mapping helps correlate outgoing messages (name only) with incoming (pubkey)
"""
msgs_file = config.msgs_file_path
if not msgs_file.exists():
logger.warning(f"Messages file not found: {msgs_file}")
return [], {}
messages = []
seen_dedup_keys = set()
pubkey_to_name = {} # Map pubkey_prefix -> most recent name
try:
with open(msgs_file, 'r', encoding='utf-8') as f:
for line_num, line in enumerate(f, 1):
line = line.strip()
if not line:
continue
try:
data = json.loads(line)
parsed = parse_dm_message(data)
if not parsed:
# --- Read incoming messages (PRIV) from .msgs file ---
msgs_file = config.msgs_file_path
if msgs_file.exists():
try:
with open(msgs_file, 'r', encoding='utf-8') as f:
for line_num, line in enumerate(f, 1):
line = line.strip()
if not line:
continue
# Update pubkey->name mapping from incoming messages
if parsed['direction'] == 'incoming' and parsed.get('pubkey_prefix'):
pubkey_to_name[parsed['pubkey_prefix']] = parsed['sender']
try:
data = json.loads(line)
# Deduplicate
if parsed['dedup_key'] in seen_dedup_keys:
# Only process PRIV messages (incoming DMs)
if data.get('type') != 'PRIV':
continue
parsed = _parse_priv_message(data)
if not parsed:
continue
# Update pubkey->name mapping
if parsed.get('pubkey_prefix'):
pubkey_to_name[parsed['pubkey_prefix']] = parsed['sender']
# Deduplicate
if parsed['dedup_key'] in seen_dedup_keys:
continue
seen_dedup_keys.add(parsed['dedup_key'])
messages.append(parsed)
except json.JSONDecodeError as e:
logger.warning(f"Invalid JSON at line {line_num}: {e}")
continue
except Exception as e:
logger.error(f"Error parsing DM at line {line_num}: {e}")
continue
seen_dedup_keys.add(parsed['dedup_key'])
# Filter by conversation if specified
if conversation_id:
if parsed['conversation_id'] != conversation_id:
# Also check if it matches via pubkey->name mapping
# For outgoing messages, conversation_id is name-based
# but incoming might be pk-based
if conversation_id.startswith('pk_'):
pk = conversation_id[3:]
name = pubkey_to_name.get(pk)
if name and parsed['conversation_id'] == f"name_{name}":
# Match via name
pass
else:
continue
elif conversation_id.startswith('name_'):
name = conversation_id[5:]
# Check if any pubkey maps to this name
matching_pk = None
for pk, n in pubkey_to_name.items():
if n == name:
matching_pk = pk
break
if matching_pk and parsed['conversation_id'] == f"pk_{matching_pk}":
# Match via pubkey
pass
else:
continue
else:
continue
except Exception as e:
logger.error(f"Error reading messages file: {e}")
messages.append(parsed)
# --- Read sent DMs from our own log file ---
sent_entries = _read_sent_dm_log()
for entry in sent_entries:
parsed = _parse_sent_dm_entry(entry)
if not parsed:
continue
except json.JSONDecodeError as e:
logger.warning(f"Invalid JSON at line {line_num}: {e}")
continue
except Exception as e:
logger.error(f"Error parsing DM at line {line_num}: {e}")
continue
# Deduplicate
if parsed['dedup_key'] in seen_dedup_keys:
continue
seen_dedup_keys.add(parsed['dedup_key'])
except FileNotFoundError:
logger.error(f"Messages file not found: {msgs_file}")
return [], {}
except Exception as e:
logger.error(f"Error reading messages file: {e}")
return [], {}
messages.append(parsed)
# --- Filter by conversation if specified ---
if conversation_id:
filtered_messages = []
for msg in messages:
if msg['conversation_id'] == conversation_id:
filtered_messages.append(msg)
else:
# Check if it matches via pubkey->name mapping
if conversation_id.startswith('pk_'):
pk = conversation_id[3:]
name = pubkey_to_name.get(pk)
if name and msg['conversation_id'] == f"name_{name}":
filtered_messages.append(msg)
elif conversation_id.startswith('name_'):
name = conversation_id[5:]
# Check if any pubkey maps to this name
for pk, n in pubkey_to_name.items():
if n == name and msg['conversation_id'] == f"pk_{pk}":
filtered_messages.append(msg)
break
messages = filtered_messages
# Sort by timestamp (oldest first)
messages.sort(key=lambda m: m['timestamp'])
+3
View File
@@ -1069,6 +1069,9 @@ def send_dm_message():
success, message = cli.send_dm(recipient, text)
if success:
# Save to our own sent DM log (workaround for meshcore-cli bug)
parser.save_sent_dm(recipient, text)
return jsonify({
'success': True,
'message': 'DM sent',
+19 -1
View File
@@ -3,7 +3,7 @@ HTML views for mc-webui
"""
import logging
from flask import Blueprint, render_template
from flask import Blueprint, render_template, request
from app.config import config
logger = logging.getLogger(__name__)
@@ -23,6 +23,24 @@ def index():
)
@views_bp.route('/dm')
def direct_messages():
"""
Direct Messages view - full-page DM interface.
Query params:
conversation: Optional conversation ID to open initially
"""
initial_conversation = request.args.get('conversation', '')
return render_template(
'dm.html',
device_name=config.MC_DEVICE_NAME,
refresh_interval=config.MC_REFRESH_INTERVAL,
initial_conversation=initial_conversation
)
@views_bp.route('/health')
def health():
"""
+1 -6
View File
@@ -445,12 +445,7 @@ main {
max-width: 100%;
}
/* DM Button on channel messages */
.btn-dm {
font-size: 0.65rem;
padding: 0.1rem 0.3rem;
margin-left: 0.25rem;
}
/* DM Button on channel messages - uses same class as Reply (.btn-reply) */
/* DM Empty State */
.dm-empty-state {
+4 -324
View File
@@ -12,11 +12,9 @@ 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
// DM state (for badge updates on main page)
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() {
@@ -229,40 +227,6 @@ 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');
@@ -383,7 +347,7 @@ function createMessageElement(msg) {
<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)}')">
<button class="btn btn-outline-secondary btn-sm btn-reply" onclick="startDmTo('${escapeHtml(msg.sender)}')">
<i class="bi bi-envelope"></i> DM
</button>
</div>
@@ -1231,262 +1195,13 @@ function saveDmLastSeenTimestamps() {
}
}
/**
* 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)
* Redirects to the full-page DM view
*/
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
window.location.href = `/dm?conversation=${encodeURIComponent(conversationId)}`;
}
/**
@@ -1568,38 +1283,3 @@ function updateDmBadges(totalUnread) {
}
}
/**
* 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');
}
}
+534
View File
@@ -0,0 +1,534 @@
/**
* mc-webui Direct Messages JavaScript
* Full-page DM view functionality
*/
// State variables
let currentConversationId = null;
let currentRecipient = null;
let dmConversations = [];
let dmLastSeenTimestamps = {};
let autoRefreshInterval = null;
// Initialize on page load
document.addEventListener('DOMContentLoaded', async function() {
console.log('DM page initialized');
// Load last seen timestamps from localStorage
loadDmLastSeenTimestamps();
// Setup event listeners
setupEventListeners();
// Load conversations into dropdown
await loadConversations();
// Check for initial conversation from URL parameter
if (window.MC_CONFIG && window.MC_CONFIG.initialConversation) {
const convId = window.MC_CONFIG.initialConversation;
// Find the conversation in the list or use the ID directly
selectConversation(convId);
}
// Setup auto-refresh
setupAutoRefresh();
updateStatus('connected', 'Ready');
});
/**
* Setup event listeners
*/
function setupEventListeners() {
// Conversation selector
const selector = document.getElementById('dmConversationSelector');
if (selector) {
selector.addEventListener('change', function() {
const convId = this.value;
if (convId) {
selectConversation(convId);
} else {
clearConversation();
}
});
}
// Send form
const sendForm = document.getElementById('dmSendForm');
if (sendForm) {
sendForm.addEventListener('submit', async function(e) {
e.preventDefault();
await sendMessage();
});
}
// Message input
const input = document.getElementById('dmMessageInput');
if (input) {
input.addEventListener('input', updateCharCounter);
// Enter key to send
input.addEventListener('keydown', function(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
});
}
}
/**
* Load conversations from API
*/
async function loadConversations() {
try {
const response = await fetch('/api/dm/conversations?days=7');
const data = await response.json();
if (data.success) {
dmConversations = data.conversations || [];
populateConversationSelector();
} else {
console.error('Failed to load conversations:', data.error);
}
} catch (error) {
console.error('Error loading conversations:', error);
}
}
/**
* Populate the conversation selector dropdown
*/
function populateConversationSelector() {
const selector = document.getElementById('dmConversationSelector');
if (!selector) return;
// Clear existing options (keep first placeholder)
selector.innerHTML = '<option value="">Select chat...</option>';
if (dmConversations.length === 0) {
const opt = document.createElement('option');
opt.value = '';
opt.textContent = 'No conversations yet';
opt.disabled = true;
selector.appendChild(opt);
return;
}
dmConversations.forEach(conv => {
const opt = document.createElement('option');
opt.value = conv.conversation_id;
// Show unread indicator
const lastSeen = dmLastSeenTimestamps[conv.conversation_id] || 0;
const isUnread = conv.last_message_timestamp > lastSeen;
let label = conv.display_name;
if (isUnread) {
label = `* ${label}`;
}
opt.textContent = label;
selector.appendChild(opt);
});
// If we have a current conversation, select it
if (currentConversationId) {
selector.value = currentConversationId;
}
}
/**
* Select a conversation
*/
async function selectConversation(conversationId) {
currentConversationId = conversationId;
// Find the conversation to get recipient name
const conv = dmConversations.find(c => c.conversation_id === conversationId);
if (conv) {
currentRecipient = conv.display_name;
} else {
// Extract name from conversation_id
if (conversationId.startsWith('name_')) {
currentRecipient = conversationId.substring(5);
} else if (conversationId.startsWith('pk_')) {
currentRecipient = conversationId.substring(3, 11) + '...';
} else {
currentRecipient = 'Unknown';
}
}
// Update selector if not already selected
const selector = document.getElementById('dmConversationSelector');
if (selector && selector.value !== conversationId) {
selector.value = conversationId;
}
// Enable input
const input = document.getElementById('dmMessageInput');
const sendBtn = document.getElementById('dmSendBtn');
if (input) {
input.disabled = false;
input.placeholder = `Message ${currentRecipient}...`;
}
if (sendBtn) {
sendBtn.disabled = false;
}
// Load messages
await loadMessages();
}
/**
* Clear conversation selection
*/
function clearConversation() {
currentConversationId = null;
currentRecipient = null;
// Disable input
const input = document.getElementById('dmMessageInput');
const sendBtn = document.getElementById('dmSendBtn');
if (input) {
input.disabled = true;
input.placeholder = 'Type a message...';
input.value = '';
}
if (sendBtn) {
sendBtn.disabled = true;
}
// Show empty state
const container = document.getElementById('dmMessagesList');
if (container) {
container.innerHTML = `
<div class="dm-empty-state">
<i class="bi bi-envelope"></i>
<p class="mb-1">Select a conversation</p>
<small class="text-muted">Choose from the dropdown above or start a new chat from channel messages</small>
</div>
`;
}
updateCharCounter();
}
/**
* Load messages for current conversation
*/
async function loadMessages() {
if (!currentConversationId) return;
const container = document.getElementById('dmMessagesList');
if (!container) return;
container.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(currentConversationId)}&limit=100`);
const data = await response.json();
if (data.success) {
displayMessages(data.messages);
// Update recipient if we got a better name
if (data.display_name && data.display_name !== 'Unknown') {
currentRecipient = data.display_name;
const input = document.getElementById('dmMessageInput');
if (input) {
input.placeholder = `Message ${currentRecipient}...`;
}
}
// Mark as read
if (data.messages && data.messages.length > 0) {
const latestTs = Math.max(...data.messages.map(m => m.timestamp));
markAsRead(currentConversationId, latestTs);
}
updateLastRefresh();
} else {
container.innerHTML = '<div class="text-center text-danger py-4">Error loading messages</div>';
}
} catch (error) {
console.error('Error loading messages:', error);
container.innerHTML = '<div class="text-center text-danger py-4">Failed to load messages</div>';
}
}
/**
* Display messages in the container
*/
function displayMessages(messages) {
const container = document.getElementById('dmMessagesList');
if (!container) return;
if (!messages || messages.length === 0) {
container.innerHTML = `
<div class="dm-empty-state">
<i class="bi bi-chat-dots"></i>
<p>No messages yet</p>
<small class="text-muted">Send a message to start the conversation</small>
</div>
`;
return;
}
container.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}
`;
container.appendChild(div);
});
// Scroll to bottom
const scrollContainer = document.getElementById('dmMessagesContainer');
if (scrollContainer) {
scrollContainer.scrollTop = scrollContainer.scrollHeight;
}
}
/**
* Send a message
*/
async function sendMessage() {
const input = document.getElementById('dmMessageInput');
if (!input) return;
const text = input.value.trim();
if (!text || !currentRecipient) return;
const sendBtn = document.getElementById('dmSendBtn');
if (sendBtn) sendBtn.disabled = true;
try {
const response = await fetch('/api/dm/messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
recipient: currentRecipient,
text: text
})
});
const data = await response.json();
if (data.success) {
input.value = '';
updateCharCounter();
showNotification('Message sent', 'success');
// Reload messages after short delay
setTimeout(() => loadMessages(), 1000);
} else {
showNotification('Failed to send: ' + data.error, 'danger');
}
} catch (error) {
console.error('Error sending message:', error);
showNotification('Failed to send message', 'danger');
} finally {
if (sendBtn) sendBtn.disabled = false;
input.focus();
}
}
/**
* Setup auto-refresh
*/
function setupAutoRefresh() {
const checkInterval = 10000; // 10 seconds
autoRefreshInterval = setInterval(async () => {
// Reload conversations to update unread indicators
await loadConversations();
// If viewing a conversation, reload messages
if (currentConversationId) {
await loadMessages();
}
}, checkInterval);
console.log('Auto-refresh enabled');
}
/**
* Update character counter
*/
function updateCharCounter() {
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;
if (byteLength > 180) {
counter.classList.add('text-danger');
counter.classList.remove('text-warning');
} else if (byteLength > 150) {
counter.classList.remove('text-danger');
counter.classList.add('text-warning');
} else {
counter.classList.remove('text-danger', 'text-warning');
}
}
/**
* 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);
}
} catch (error) {
console.error('Error loading 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 last seen timestamps:', error);
}
}
/**
* Mark conversation as read
*/
function markAsRead(conversationId, timestamp) {
dmLastSeenTimestamps[conversationId] = timestamp;
saveDmLastSeenTimestamps();
// Update dropdown to remove unread indicator
populateConversationSelector();
}
/**
* Update status indicator
*/
function updateStatus(status, message) {
const statusEl = document.getElementById('dmStatusText');
if (!statusEl) return;
const statusColors = {
'connected': 'success',
'disconnected': 'danger',
'connecting': 'warning'
};
const color = statusColors[status] || 'secondary';
statusEl.innerHTML = `<i class="bi bi-circle-fill text-${color}"></i> ${message}`;
}
/**
* Update last refresh time
*/
function updateLastRefresh() {
const el = document.getElementById('dmLastRefresh');
if (el) {
el.textContent = `Updated: ${new Date().toLocaleTimeString()}`;
}
}
/**
* Format timestamp to readable time
*/
function formatTime(timestamp) {
if (!timestamp) return '';
const date = new Date(timestamp * 1000);
const now = new Date();
const isToday = date.toDateString() === now.toDateString();
if (isToday) {
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} else {
return date.toLocaleDateString([], { month: 'short', day: 'numeric' }) +
' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
}
/**
* Escape HTML to prevent XSS
*/
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* Show a toast notification
*/
function showNotification(message, type = 'info') {
const toastEl = document.getElementById('notificationToast');
if (!toastEl) return;
const toastBody = toastEl.querySelector('.toast-body');
if (toastBody) {
toastBody.textContent = message;
}
// Update toast header color based on type
const toastHeader = toastEl.querySelector('.toast-header');
if (toastHeader) {
toastHeader.className = 'toast-header';
if (type === 'success') {
toastHeader.classList.add('bg-success', 'text-white');
} else if (type === 'danger') {
toastHeader.classList.add('bg-danger', 'text-white');
} else if (type === 'warning') {
toastHeader.classList.add('bg-warning');
}
}
const toast = new bootstrap.Toast(toastEl, { delay: 3000 });
toast.show();
}
+2 -47
View File
@@ -63,13 +63,13 @@
<i class="bi bi-broadcast-pin" style="font-size: 1.5rem;"></i>
<span>Manage Channels</span>
</button>
<button class="list-group-item list-group-item-action d-flex align-items-center gap-3" data-bs-toggle="modal" data-bs-target="#dmModal" data-bs-dismiss="offcanvas">
<a href="/dm" class="list-group-item list-group-item-action d-flex align-items-center gap-3" data-bs-dismiss="offcanvas">
<i class="bi bi-envelope" style="font-size: 1.5rem;"></i>
<div class="d-flex flex-grow-1 justify-content-between align-items-center">
<span>Direct Messages</span>
<span id="dmMenuBadge" class="badge bg-success rounded-pill" style="display: none;">0</span>
</div>
</button>
</a>
<div class="list-group-item">
<div class="d-flex align-items-center gap-3 mb-2">
<i class="bi bi-calendar3" style="font-size: 1.5rem;"></i>
@@ -262,51 +262,6 @@
</div>
</div>
<!-- Direct Messages Modal -->
<div class="modal fade" id="dmModal" tabindex="-1">
<div class="modal-dialog modal-lg modal-fullscreen-sm-down">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-envelope"></i> Direct Messages</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body p-0">
<!-- Conversation list (shown when no conversation selected) -->
<div id="dmConversationList">
<div class="text-center text-muted py-4">
<div class="spinner-border spinner-border-sm"></div> Loading...
</div>
</div>
<!-- Conversation thread (shown when conversation selected) -->
<div id="dmThread" style="display: none;">
<div class="p-2 bg-light border-bottom d-flex align-items-center">
<button class="btn btn-sm btn-outline-secondary me-2" onclick="closeDmThread()">
<i class="bi bi-arrow-left"></i>
</button>
<strong id="dmThreadRecipient"></strong>
</div>
<div id="dmMessagesList" class="dm-messages-container">
<!-- Messages loaded here -->
</div>
<div class="p-2 border-top">
<form id="dmSendForm" class="d-flex gap-2">
<input type="text" id="dmMessageInput" class="form-control form-control-sm"
placeholder="Type a message..." maxlength="200">
<button type="submit" class="btn btn-primary btn-sm">
<i class="bi bi-send"></i>
</button>
</form>
<div class="d-flex justify-content-end mt-1">
<small class="text-muted"><span id="dmCharCounter">0</span>/200</small>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Toast container for notifications -->
<div class="toast-container position-fixed bottom-0 end-0 p-3">
<div id="notificationToast" class="toast" role="alert">
+125
View File
@@ -0,0 +1,125 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>Direct Messages - mc-webui</title>
<!-- Favicon -->
<link rel="apple-touch-icon" sizes="180x180" href="{{ url_for('static', filename='images/apple-touch-icon.png') }}">
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='images/favicon-32x32.png') }}">
<link rel="icon" type="image/png" sizes="16x16" href="{{ url_for('static', filename='images/favicon-16x16.png') }}">
<link rel="manifest" href="{{ url_for('static', filename='manifest.json') }}">
<link rel="shortcut icon" href="{{ url_for('static', filename='favicon.ico') }}">
<!-- Bootstrap 5 CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Bootstrap Icons -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.2/font/bootstrap-icons.css">
<!-- Custom CSS -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
</head>
<body>
<!-- Navbar for DM page -->
<nav class="navbar navbar-dark bg-success">
<div class="container-fluid">
<div class="d-flex align-items-center gap-2">
<a href="/" class="btn btn-outline-light btn-sm" title="Back to Channels">
<i class="bi bi-arrow-left"></i>
</a>
<span class="navbar-brand mb-0 h1">
<i class="bi bi-envelope"></i> Direct Messages
</span>
</div>
<div class="d-flex align-items-center gap-2">
<select id="dmConversationSelector" class="form-select form-select-sm" style="width: auto; min-width: 120px;" title="Select conversation">
<option value="">Select chat...</option>
<!-- Conversations loaded dynamically via JavaScript -->
</select>
</div>
</div>
</nav>
<!-- Main Content -->
<main>
<div class="container-fluid d-flex flex-column" style="height: 100%;">
<!-- Messages Container -->
<div class="row flex-grow-1 overflow-hidden" style="min-height: 0;">
<div class="col-12" style="height: 100%;">
<div id="dmMessagesContainer" class="messages-container h-100 overflow-auto p-3">
<div id="dmMessagesList">
<!-- Placeholder shown when no conversation selected -->
<div class="dm-empty-state">
<i class="bi bi-envelope"></i>
<p class="mb-1">Select a conversation</p>
<small class="text-muted">Choose from the dropdown above or start a new chat from channel messages</small>
</div>
</div>
</div>
</div>
</div>
<!-- Send Message Form -->
<div class="row border-top bg-light">
<div class="col-12">
<form id="dmSendForm" class="p-3">
<div class="input-group">
<input type="text"
id="dmMessageInput"
class="form-control"
placeholder="Type a message..."
maxlength="200"
disabled>
<button type="submit" class="btn btn-success px-4" id="dmSendBtn" disabled>
<i class="bi bi-send"></i>
</button>
</div>
<div class="d-flex justify-content-end">
<small class="text-muted"><span id="dmCharCounter">0</span> / 200</small>
</div>
</form>
</div>
</div>
<!-- Status Bar -->
<div class="row border-top">
<div class="col-12">
<div class="p-2 small text-muted d-flex justify-content-between align-items-center">
<span id="dmStatusText">
<i class="bi bi-circle-fill text-secondary"></i> Ready
</span>
<span id="dmLastRefresh">-</span>
</div>
</div>
</div>
</div>
</main>
<!-- Toast container for notifications -->
<div class="toast-container position-fixed bottom-0 end-0 p-3">
<div id="notificationToast" class="toast" role="alert">
<div class="toast-header">
<strong class="me-auto">mc-webui</strong>
<button type="button" class="btn-close" data-bs-dismiss="toast"></button>
</div>
<div class="toast-body"></div>
</div>
</div>
<!-- Bootstrap 5 JS Bundle -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<!-- Custom JS -->
<script src="{{ url_for('static', filename='js/dm.js') }}"></script>
<script>
// Pass configuration from Flask to JavaScript
window.MC_CONFIG = {
refreshInterval: {{ refresh_interval }} * 1000,
deviceName: "{{ device_name }}",
initialConversation: "{{ initial_conversation or '' }}"
};
</script>
</body>
</html>