From 83b51ab2b946e6dc6a58fe6368e20f3e1f832d69 Mon Sep 17 00:00:00 2001 From: MarekWo Date: Thu, 25 Dec 2025 20:27:02 +0100 Subject: [PATCH] refactor(dm): Replace modal with full-page view and fix sent DM tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- README.md | 24 +- app/config.py | 5 + app/meshcore/parser.py | 261 +++++++++++-------- app/routes/api.py | 3 + app/routes/views.py | 20 +- app/static/css/style.css | 7 +- app/static/js/app.js | 328 +----------------------- app/static/js/dm.js | 534 +++++++++++++++++++++++++++++++++++++++ app/templates/base.html | 49 +--- app/templates/dm.html | 125 +++++++++ 10 files changed, 858 insertions(+), 498 deletions(-) create mode 100644 app/static/js/dm.js create mode 100644 app/templates/dm.html diff --git a/README.md b/README.md index 93a359f..e53d8d9 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/config.py b/app/config.py index 09430a4..1180e3f 100644 --- a/app/config.py +++ b/app/config.py @@ -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}, " diff --git a/app/meshcore/parser.py b/app/meshcore/parser.py index 120f3ed..55f82a8 100644 --- a/app/meshcore/parser.py +++ b/app/meshcore/parser.py @@ -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']) diff --git a/app/routes/api.py b/app/routes/api.py index 87f6645..bb09830 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -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', diff --git a/app/routes/views.py b/app/routes/views.py index d728bcd..d0e1da4 100644 --- a/app/routes/views.py +++ b/app/routes/views.py @@ -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(): """ diff --git a/app/static/css/style.css b/app/static/css/style.css index 6e1a42c..5694512 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -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 { diff --git a/app/static/js/app.js b/app/static/js/app.js index c4b56de..e19280b 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -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) { - @@ -1231,262 +1195,13 @@ function saveDmLastSeenTimestamps() { } } -/** - * Load DM conversations list - */ -async function loadDmConversations() { - const listEl = document.getElementById('dmConversationList'); - if (!listEl) return; - - listEl.innerHTML = '
Loading...
'; - - try { - const response = await fetch('/api/dm/conversations?days=7'); - const data = await response.json(); - - if (data.success) { - displayDmConversations(data.conversations); - } else { - listEl.innerHTML = '
Error loading conversations
'; - } - } catch (error) { - console.error('Error loading DM conversations:', error); - listEl.innerHTML = '
Failed to load conversations
'; - } -} - -/** - * Display DM conversations list - */ -function displayDmConversations(conversations) { - const listEl = document.getElementById('dmConversationList'); - if (!listEl) return; - - if (!conversations || conversations.length === 0) { - listEl.innerHTML = ` -
- -

No direct messages yet

- Start a conversation by clicking DM on any message -
- `; - return; - } - - listEl.innerHTML = conversations.map(conv => { - const lastSeen = dmLastSeenTimestamps[conv.conversation_id] || 0; - const isUnread = conv.last_message_timestamp > lastSeen; - - return ` -
-
- ${escapeHtml(conv.display_name)} - ${formatTime(conv.last_message_timestamp)} -
-
${escapeHtml(conv.last_message_preview)}
- ${isUnread ? 'New' : ''} -
- `; - }).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 = '
'; - - 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 = '
Error loading messages
'; - } - } catch (error) { - console.error('Error loading DM messages:', error); - listEl.innerHTML = '
Failed to load messages
'; - } -} - -/** - * Display DM messages in thread view - */ -function displayDmMessages(messages) { - const listEl = document.getElementById('dmMessagesList'); - if (!listEl) return; - - if (!messages || messages.length === 0) { - listEl.innerHTML = ` -
- -

No messages in this conversation

-
- `; - 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': '', - 'delivered': '', - 'timeout': '' - }; - 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 = `
${parts.join(' | ')}
`; - } - } - - div.innerHTML = ` -
- ${formatTime(msg.timestamp)} - ${statusIcon} -
-
${escapeHtml(msg.content)}
- ${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'); - } -} diff --git a/app/static/js/dm.js b/app/static/js/dm.js new file mode 100644 index 0000000..785ba87 --- /dev/null +++ b/app/static/js/dm.js @@ -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 = ''; + + 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 = ` +
+ +

Select a conversation

+ Choose from the dropdown above or start a new chat from channel messages +
+ `; + } + + updateCharCounter(); +} + +/** + * Load messages for current conversation + */ +async function loadMessages() { + if (!currentConversationId) return; + + const container = document.getElementById('dmMessagesList'); + if (!container) return; + + container.innerHTML = '
'; + + 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 = '
Error loading messages
'; + } + } catch (error) { + console.error('Error loading messages:', error); + container.innerHTML = '
Failed to load messages
'; + } +} + +/** + * Display messages in the container + */ +function displayMessages(messages) { + const container = document.getElementById('dmMessagesList'); + if (!container) return; + + if (!messages || messages.length === 0) { + container.innerHTML = ` +
+ +

No messages yet

+ Send a message to start the conversation +
+ `; + 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': '', + 'delivered': '', + 'timeout': '' + }; + 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 = `
${parts.join(' | ')}
`; + } + } + + div.innerHTML = ` +
+ ${formatTime(msg.timestamp)} + ${statusIcon} +
+
${escapeHtml(msg.content)}
+ ${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 = ` ${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(); +} diff --git a/app/templates/base.html b/app/templates/base.html index 023d14b..846a397 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -63,13 +63,13 @@ Manage Channels - +
@@ -262,51 +262,6 @@
- - -