feat: Add direct messages (DM) support

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

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

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