diff --git a/README.md b/README.md index dac1d3d..93a359f 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ A lightweight web interface for meshcore-cli, providing browser-based access to - 🔔 **Smart notifications** - Bell icon with unread message counter across all channels - 📊 **Per-channel badges** - Unread count displayed on each channel in selector - ✉️ **Send messages** - Publish to any channel (140 byte limit for LoRa) +- 💌 **Direct messages (DM)** - Send and receive private messages with delivery status tracking - 📡 **Channel management** - Create, join, and switch between encrypted channels - 🔐 **Channel sharing** - Share channels via QR code or encrypted keys - 🔓 **Public channels** - Join public channels (starting with #) without encryption keys @@ -166,10 +167,10 @@ mc-webui/ - [x] Public Channels (# prefix support, auto-key generation) - [x] Message Archiving (Daily archiving with browse-by-date selector) - [x] Smart Notifications (Unread counters per channel and total) +- [x] Direct Messages (DM) - Private messaging with delivery status tracking ### Next Steps -- [ ] **Private Messages (DM)** - Send and receive direct messages with delivery status tracking - [ ] Performance Optimization - Frontend and backend improvements - [ ] Enhanced Testing - Unit and integration tests - [ ] Documentation Polish - API docs and usage guides @@ -251,6 +252,32 @@ Archives are created automatically at midnight (00:00 UTC) each day. The live vi Click the reply button on any message to insert `@[UserName]` into the text field, then type your reply. +### Direct Messages (DM) + +Access the Direct Messages feature from the slide-out menu: + +1. Click the menu icon (☰) in the navbar +2. Select "Direct Messages" from the menu +3. View your conversation list with unread indicators + +**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 + +**Sending a direct message:** +1. Open a conversation +2. Type your message (max 200 characters) +3. Press Enter or click Send + +**Message status indicators:** +- ⏳ **Pending** (yellow) - Message sent, waiting for delivery confirmation +- ⏱️ **Timeout** (red) - Delivery confirmation not received within expected time + +**Notifications:** +- The bell icon shows a secondary green badge for unread DMs +- Each conversation shows unread count in the conversation list +- DM badge in the menu shows total unread DM count + ### Managing Contacts Access the settings panel to clean up inactive contacts: diff --git a/app/meshcore/cli.py b/app/meshcore/cli.py index e08175c..4f7cc88 100644 --- a/app/meshcore/cli.py +++ b/app/meshcore/cli.py @@ -300,3 +300,30 @@ def floodadv() -> Tuple[bool, str]: """ success, stdout, stderr = _run_command(['floodadv']) return success, stdout or stderr + + +# ============================================================================= +# Direct Messages (DM) +# ============================================================================= + +def send_dm(recipient: str, text: str) -> Tuple[bool, str]: + """ + Send a direct/private message to a contact. + + Uses meshcli 'msg' command: msg + + Args: + recipient: Contact name to send to + text: Message content + + Returns: + Tuple of (success, message) + """ + if not recipient or not recipient.strip(): + return False, "Recipient name is required" + + if not text or not text.strip(): + return False, "Message text is required" + + success, stdout, stderr = _run_command(['msg', recipient.strip(), text.strip()]) + return success, stdout or stderr diff --git a/app/meshcore/parser.py b/app/meshcore/parser.py index 058312a..120f3ed 100644 --- a/app/meshcore/parser.py +++ b/app/meshcore/parser.py @@ -1,11 +1,13 @@ """ Message parser - reads and parses .msgs file (JSON Lines format) +Supports channel messages (CHAN, SENT_CHAN) and direct messages (PRIV, SENT_MSG) """ import json import logging +import time from pathlib import Path -from typing import List, Dict, Optional +from typing import List, Dict, Optional, Tuple from datetime import datetime, timedelta from app.config import config @@ -306,3 +308,318 @@ def delete_channel_messages(channel_idx: int) -> bool: except Exception as e: logger.error(f"Error deleting channel messages: {e}") return False + + +# ============================================================================= +# Direct Messages (DM) Parsing +# ============================================================================= + +def _parse_priv_message(line: Dict) -> Optional[Dict]: + """ + Parse incoming private message (PRIV type). + + Args: + line: Raw JSON object from .msgs file with type='PRIV' + + Returns: + Parsed DM dict or None if invalid + """ + text = line.get('text', '').strip() + if not text: + return None + + timestamp = line.get('timestamp', 0) + pubkey_prefix = line.get('pubkey_prefix', '') + sender = line.get('name', 'Unknown') + sender_timestamp = line.get('sender_timestamp', 0) + + # Generate conversation ID - prefer pubkey_prefix if available + if pubkey_prefix: + conversation_id = f"pk_{pubkey_prefix}" + else: + conversation_id = f"name_{sender}" + + # Generate deduplication key + text_hash = hash(text[:50]) & 0xFFFFFFFF # 32-bit positive hash + dedup_key = f"priv_{pubkey_prefix}_{sender_timestamp}_{text_hash}" + + return { + 'type': 'dm', + 'direction': 'incoming', + 'sender': sender, + 'content': text, + 'timestamp': timestamp, + 'sender_timestamp': sender_timestamp, + 'datetime': datetime.fromtimestamp(timestamp).isoformat() if timestamp > 0 else None, + 'is_own': False, + 'snr': line.get('SNR'), + 'path_len': line.get('path_len'), + 'pubkey_prefix': pubkey_prefix, + 'txt_type': line.get('txt_type', 0), + 'conversation_id': conversation_id, + 'dedup_key': dedup_key + } + + +def _parse_sent_msg(line: Dict) -> Optional[Dict]: + """ + Parse outgoing private message (SENT_MSG type). + + Args: + line: Raw JSON object from .msgs file with type='SENT_MSG' + + Returns: + Parsed DM dict or None if invalid + """ + text = line.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 + + # 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}" + + # Calculate status based on timeout + age_ms = (time.time() - timestamp) * 1000 + if age_ms > suggested_timeout: + status = 'timeout' + else: + status = 'pending' + + return { + 'type': 'dm', + 'direction': 'outgoing', + 'recipient': recipient, + 'content': text, + '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. + + Args: + limit: Maximum messages to return (None = all) + conversation_id: Filter by specific conversation (None = all) + days: Filter to last N days (None = no filter) + + Returns: + 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: + 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'] + + # Deduplicate + if parsed['dedup_key'] in seen_dedup_keys: + 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 + + 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 + + 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 [], {} + + # Sort by timestamp (oldest first) + messages.sort(key=lambda m: m['timestamp']) + + # Filter by days if specified + if days is not None and days > 0: + cutoff_timestamp = (datetime.now() - timedelta(days=days)).timestamp() + messages = [m for m in messages if m['timestamp'] >= cutoff_timestamp] + + # Apply limit (return most recent) + if limit is not None and limit > 0: + messages = messages[-limit:] + + logger.info(f"Loaded {len(messages)} DM messages") + return messages, pubkey_to_name + + +def get_dm_conversations(days: Optional[int] = 7) -> List[Dict]: + """ + Get list of DM conversations with metadata. + + Args: + days: Filter to last N days (None = no filter) + + Returns: + List of conversation dicts sorted by most recent activity: + [ + { + 'conversation_id': str, + 'display_name': str, + 'pubkey_prefix': str or None, + 'last_message_timestamp': int, + 'last_message_preview': str, + 'unread_count': int, # Always 0 here, frontend tracks unread + 'message_count': int + } + ] + """ + messages, pubkey_to_name = read_dm_messages(days=days) + + # Group messages by conversation + conversations = {} + + for msg in messages: + conv_id = msg['conversation_id'] + + # For incoming messages with pubkey, also try to merge with name-based + if conv_id.startswith('pk_'): + pk = conv_id[3:] + name = pubkey_to_name.get(pk) + # Check if there's a name-based conversation we should merge + name_conv_id = f"name_{name}" if name else None + if name_conv_id and name_conv_id in conversations: + # Merge into pubkey-based conversation + conversations[conv_id] = conversations.pop(name_conv_id) + + if conv_id not in conversations: + conversations[conv_id] = { + 'conversation_id': conv_id, + 'display_name': '', + 'pubkey_prefix': None, + 'last_message_timestamp': 0, + 'last_message_preview': '', + 'unread_count': 0, + 'message_count': 0 + } + + conv = conversations[conv_id] + conv['message_count'] += 1 + + # Update display name + if msg['direction'] == 'incoming': + conv['display_name'] = msg['sender'] + if msg.get('pubkey_prefix'): + conv['pubkey_prefix'] = msg['pubkey_prefix'] + elif msg['direction'] == 'outgoing' and not conv['display_name']: + conv['display_name'] = msg.get('recipient', 'Unknown') + + # Update last message info + if msg['timestamp'] > conv['last_message_timestamp']: + conv['last_message_timestamp'] = msg['timestamp'] + preview = msg['content'][:50] + if len(msg['content']) > 50: + preview += '...' + if msg['is_own']: + preview = f"You: {preview}" + conv['last_message_preview'] = preview + + # Convert to list and sort by most recent + result = list(conversations.values()) + result.sort(key=lambda c: c['last_message_timestamp'], reverse=True) + + logger.info(f"Found {len(result)} DM conversations") + return result diff --git a/app/routes/api.py b/app/routes/api.py index a54c936..87f6645 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -895,3 +895,273 @@ def get_messages_updates(): 'success': False, 'error': str(e) }), 500 + + +# ============================================================================= +# Direct Messages (DM) Endpoints +# ============================================================================= + +@api_bp.route('/dm/conversations', methods=['GET']) +def get_dm_conversations(): + """ + Get list of DM conversations. + + Query params: + days (int): Filter to last N days (default: 7) + + Returns: + JSON with conversations list: + { + "success": true, + "conversations": [ + { + "conversation_id": "pk_4563b1621b58", + "display_name": "daniel5120", + "pubkey_prefix": "4563b1621b58", + "last_message_timestamp": 1766491173, + "last_message_preview": "Hello there...", + "unread_count": 0, + "message_count": 15 + } + ], + "count": 5 + } + """ + try: + days = request.args.get('days', default=7, type=int) + + conversations = parser.get_dm_conversations(days=days) + + return jsonify({ + 'success': True, + 'conversations': conversations, + 'count': len(conversations) + }), 200 + + except Exception as e: + logger.error(f"Error getting DM conversations: {e}") + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@api_bp.route('/dm/messages', methods=['GET']) +def get_dm_messages(): + """ + Get DM messages for a specific conversation. + + Query params: + conversation_id (str): Required - conversation identifier (pk_xxx or name_xxx) + limit (int): Max messages to return (default: 100) + days (int): Filter to last N days (default: 7) + + Returns: + JSON with messages list: + { + "success": true, + "conversation_id": "pk_4563b1621b58", + "display_name": "daniel5120", + "messages": [...], + "count": 25 + } + """ + try: + conversation_id = request.args.get('conversation_id', type=str) + if not conversation_id: + return jsonify({ + 'success': False, + 'error': 'Missing required parameter: conversation_id' + }), 400 + + limit = request.args.get('limit', default=100, type=int) + days = request.args.get('days', default=7, type=int) + + messages, pubkey_to_name = parser.read_dm_messages( + limit=limit, + conversation_id=conversation_id, + days=days + ) + + # Determine display name from conversation_id or messages + display_name = 'Unknown' + if conversation_id.startswith('pk_'): + pk = conversation_id[3:] + display_name = pubkey_to_name.get(pk, pk[:8] + '...') + elif conversation_id.startswith('name_'): + display_name = conversation_id[5:] + + # Also check messages for better name + for msg in messages: + if msg['direction'] == 'incoming' and msg.get('sender'): + display_name = msg['sender'] + break + elif msg['direction'] == 'outgoing' and msg.get('recipient'): + display_name = msg['recipient'] + + return jsonify({ + 'success': True, + 'conversation_id': conversation_id, + 'display_name': display_name, + 'messages': messages, + 'count': len(messages) + }), 200 + + except Exception as e: + logger.error(f"Error getting DM messages: {e}") + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@api_bp.route('/dm/messages', methods=['POST']) +def send_dm_message(): + """ + Send a direct message. + + JSON body: + recipient (str): Contact name (required) + text (str): Message content (required) + + Returns: + JSON with send result: + { + "success": true, + "message": "DM sent", + "recipient": "daniel5120", + "status": "pending" + } + """ + try: + data = request.get_json() + + if not data: + return jsonify({ + 'success': False, + 'error': 'Missing JSON body' + }), 400 + + recipient = data.get('recipient', '').strip() + text = data.get('text', '').strip() + + if not recipient: + return jsonify({ + 'success': False, + 'error': 'Missing required field: recipient' + }), 400 + + if not text: + return jsonify({ + 'success': False, + 'error': 'Missing required field: text' + }), 400 + + # MeshCore message length limit + byte_length = len(text.encode('utf-8')) + if byte_length > 200: + return jsonify({ + 'success': False, + 'error': f'Message too long ({byte_length} bytes). Maximum 200 bytes allowed.' + }), 400 + + # Send via CLI + success, message = cli.send_dm(recipient, text) + + if success: + return jsonify({ + 'success': True, + 'message': 'DM sent', + 'recipient': recipient, + 'status': 'pending' + }), 200 + else: + return jsonify({ + 'success': False, + 'error': message + }), 500 + + except Exception as e: + logger.error(f"Error sending DM: {e}") + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@api_bp.route('/dm/updates', methods=['GET']) +def get_dm_updates(): + """ + Check for new DMs across all conversations. + Used for notification badge updates. + + Query params: + last_seen (str): JSON object with last seen timestamps per conversation + Format: {"pk_xxx": 1234567890, "name_yyy": 1234567891, ...} + + Returns: + JSON with update information: + { + "success": true, + "total_unread": 5, + "conversations": [ + { + "conversation_id": "pk_4563b1621b58", + "display_name": "daniel5120", + "unread_count": 3, + "latest_timestamp": 1766491173 + } + ] + } + """ + try: + # Parse last_seen timestamps + last_seen_str = request.args.get('last_seen', '{}') + try: + last_seen = json.loads(last_seen_str) + except json.JSONDecodeError: + last_seen = {} + + # Get all conversations + conversations = parser.get_dm_conversations(days=7) + + updates = [] + total_unread = 0 + + for conv in conversations: + conv_id = conv['conversation_id'] + last_seen_ts = last_seen.get(conv_id, 0) + + # Count unread + if conv['last_message_timestamp'] > last_seen_ts: + # Need to count actual unread messages + messages, _ = parser.read_dm_messages( + conversation_id=conv_id, + days=7 + ) + unread_count = sum(1 for m in messages if m['timestamp'] > last_seen_ts) + else: + unread_count = 0 + + total_unread += unread_count + + if unread_count > 0: + updates.append({ + 'conversation_id': conv_id, + 'display_name': conv['display_name'], + 'unread_count': unread_count, + 'latest_timestamp': conv['last_message_timestamp'] + }) + + return jsonify({ + 'success': True, + 'total_unread': total_unread, + 'conversations': updates + }), 200 + + except Exception as e: + logger.error(f"Error checking DM updates: {e}") + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 diff --git a/app/static/css/style.css b/app/static/css/style.css index 1ab1720..6e1a42c 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -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; +} diff --git a/app/static/js/app.js b/app/static/js/app.js index 5b8c1ec..c4b56de 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -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) {

${escapeHtml(msg.content)}

${metaInfo ? `
${metaInfo}
` : ''} - ${!msg.is_own ? `` : ''} + ${!msg.is_own ? ` +
+ + +
+ ` : ''} `; 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 = '
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) + */ +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'); + } +} diff --git a/app/templates/base.html b/app/templates/base.html index 1884f6a..023d14b 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -63,6 +63,13 @@ Manage Channels +
@@ -255,6 +262,51 @@
+ + +