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) {
-
-