feat: Conditional DM button visibility based on contacts list

The DM button is now only shown for users who are in the device's contacts
list, ensuring that direct messages will actually be delivered. This prevents
users from attempting to send DMs to recipients who cannot receive them.

Changes:
- Added parse_contacts() and get_contacts_list() functions to cli.py for parsing
  meshcli contacts output
- Created /api/contacts endpoint to retrieve contact names from device
- Modified frontend app.js to fetch and cache contacts list on page load
- Updated createMessageElement() to conditionally render DM button only when
  sender is in contacts list
- Updated README.md with note about DM button visibility requirement

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
MarekWo
2025-12-28 14:31:14 +01:00
parent de79c04dee
commit 40a9b4e3bf
4 changed files with 121 additions and 3 deletions

View File

@@ -269,6 +269,7 @@ Access the Direct Messages feature:
**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
- **Note:** The DM button is only visible for users who are in your contacts list (meshcli: `contacts`). This ensures that direct messages will actually be delivered to the recipient.
**Using the DM page:**
1. Select a conversation from the dropdown at the top (or one opens automatically if started from a message)

View File

@@ -121,6 +121,57 @@ def get_contacts() -> Tuple[bool, str]:
return success, stdout or stderr
def parse_contacts(output: str) -> List[str]:
"""
Parse meshcli contacts output to extract contact names.
Expected formats:
- "ContactName" (simple list)
- "ContactName (type)" (with type info)
- Any line containing contact information
Args:
output: Raw output from meshcli contacts command
Returns:
List of contact names (unique)
"""
contacts = []
for line in output.split('\n'):
line = line.strip()
# Skip empty lines and potential headers
if not line or line.startswith('---') or line.lower().startswith('contact'):
continue
# Extract contact name (before parentheses or special chars)
# Handle formats like "ContactName" or "ContactName (type)"
name_match = re.match(r'^([^\s()\[\]]+)', line)
if name_match:
contact_name = name_match.group(1).strip()
if contact_name and contact_name not in contacts:
contacts.append(contact_name)
return contacts
def get_contacts_list() -> Tuple[bool, List[str], str]:
"""
Get parsed list of contact names from the device.
Returns:
Tuple of (success, contact_names_list, error_message)
"""
success, output = get_contacts()
if not success:
return False, [], output
contacts = parse_contacts(output)
return True, contacts, ""
def clean_inactive_contacts(hours: int = 48) -> Tuple[bool, str]:
"""
Remove contacts inactive for specified hours.

View File

@@ -224,6 +224,39 @@ def get_status():
}), 500
@api_bp.route('/contacts', methods=['GET'])
def get_contacts():
"""
Get list of contacts from the device.
Returns:
JSON with list of contact names
"""
try:
success, contacts, error = cli.get_contacts_list()
if success:
return jsonify({
'success': True,
'contacts': contacts,
'count': len(contacts)
}), 200
else:
return jsonify({
'success': False,
'error': error or 'Failed to get contacts',
'contacts': []
}), 500
except Exception as e:
logger.error(f"Error getting contacts: {e}")
return jsonify({
'success': False,
'error': str(e),
'contacts': []
}), 500
@api_bp.route('/contacts/cleanup', methods=['POST'])
def cleanup_contacts():
"""

View File

@@ -11,6 +11,7 @@ let currentChannelIdx = 0; // Current active channel (0 = Public)
let availableChannels = []; // List of channels from API
let lastSeenTimestamps = {}; // Track last seen message timestamp per channel
let unreadCounts = {}; // Track unread message counts per channel
let contactsList = []; // List of contacts from API (for DM button visibility)
// DM state (for badge updates on main page)
let dmLastSeenTimestamps = {}; // Track last seen DM timestamp per conversation
@@ -40,6 +41,9 @@ document.addEventListener('DOMContentLoaded', async function() {
// This ensures channels are available for checkForUpdates()
await loadChannels();
// Load contacts list (for DM button visibility)
loadContacts();
// Now load other data (can run in parallel)
loadArchiveList();
loadMessages();
@@ -335,6 +339,9 @@ function createMessageElement(msg) {
metaInfo += ` | Hops: ${msg.path_len}`;
}
// Check if sender is in contacts (for DM button visibility)
const senderInContacts = contactsList.includes(msg.sender);
div.innerHTML = `
<div class="message-header">
<span class="message-sender">${escapeHtml(msg.sender)}</span>
@@ -347,9 +354,11 @@ function createMessageElement(msg) {
<button class="btn btn-outline-secondary btn-sm btn-reply" onclick="replyTo('${escapeHtml(msg.sender)}')">
<i class="bi bi-reply"></i> Reply
</button>
<button class="btn btn-outline-secondary btn-sm btn-reply" onclick="startDmTo('${escapeHtml(msg.sender)}')">
<i class="bi bi-envelope"></i> DM
</button>
${senderInContacts ? `
<button class="btn btn-outline-secondary btn-sm btn-reply" onclick="startDmTo('${escapeHtml(msg.sender)}')">
<i class="bi bi-envelope"></i> DM
</button>
` : ''}
</div>
` : ''}
`;
@@ -957,6 +966,30 @@ async function loadChannels() {
}
}
/**
* Load contacts list from device
* This is used to determine if DM button should be shown for a sender
*/
async function loadContacts() {
try {
console.log('[loadContacts] Fetching contacts from API...');
const response = await fetch('/api/contacts');
const data = await response.json();
if (data.success) {
contactsList = data.contacts || [];
console.log(`[loadContacts] Loaded ${contactsList.length} contacts:`, contactsList);
} else {
console.error('[loadContacts] Error loading contacts:', data.error);
contactsList = [];
}
} catch (error) {
console.error('[loadContacts] Exception:', error.message || error);
contactsList = [];
}
}
/**
* Fallback: ensure Public channel exists in dropdown even if API fails
*/