feat(contacts): Add 'Last Seen' timestamp display with activity indicators

Add comprehensive "last seen" tracking for all contact types:

Backend (cli.py):
- New function get_contacts_with_last_seen() using 'apply_to t=1,t=2,t=3,t=4 contact_info'
- Fetches detailed contact metadata including last_advert timestamps
- Returns dictionary indexed by full public_key for efficient lookup

API (api.py):
- Enhanced /api/contacts/detailed endpoint to merge last_seen data
- Matches contacts by public_key_prefix (first 12 chars)
- Graceful fallback if detailed fetch fails (contacts still displayed without last_seen)

Frontend (contacts.js):
- formatRelativeTime() - converts Unix timestamps to human-readable format
  ("5 minutes ago", "2 hours ago", "3 days ago")
- getActivityStatus() - returns status indicator based on recency:
  🟢 Active (< 5 min), 🟡 Recent (< 1 hour), 🔴 Inactive (> 1 hour)
- Contact cards now display "Last seen" with status icon and relative time
- Clean handling of missing last_seen data (shows "Unknown")

This feature helps users identify active vs. inactive contacts at a glance,
using the last_advert field from meshcli's contact_info command.

🤖 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-29 13:03:15 +01:00
parent 3e524bb0d2
commit 7f819b63c7
3 changed files with 206 additions and 8 deletions
+57
View File
@@ -524,6 +524,63 @@ def get_all_contacts_detailed() -> Tuple[bool, List[Dict], int, str]:
return False, [], 0, str(e)
def get_contacts_with_last_seen() -> Tuple[bool, Dict[str, Dict], str]:
"""
Get detailed contact information including last_advert timestamps.
Uses 'apply_to t=1,t=2,t=3,t=4 contact_info' command to fetch metadata
for all contact types (CLI, REP, ROOM, SENS).
Returns:
Tuple of (success, contacts_dict, error_message)
contacts_dict maps public_key -> contact_details where each detail dict contains:
{
'public_key': str (full key),
'type': int (1=CLI, 2=REP, 3=ROOM, 4=SENS),
'flags': int,
'out_path_len': int,
'out_path': str,
'adv_name': str (name with emoji),
'last_advert': int (Unix timestamp),
'adv_lat': float,
'adv_lon': float,
'lastmod': int (Unix timestamp)
}
"""
try:
# Execute command to get all contact types
# t=1 (CLI), t=2 (REP), t=3 (ROOM), t=4 (SENS)
success, stdout, stderr = _run_command(['apply_to', 't=1,t=2,t=3,t=4', 'contact_info'])
if not success:
return False, {}, stderr or 'Failed to get contact details'
# Parse JSON output
try:
# The output should be a JSON array
contact_list = json.loads(stdout)
if not isinstance(contact_list, list):
return False, {}, 'Unexpected response format (expected JSON array)'
# Build dictionary indexed by public_key for easy lookup
contacts_dict = {}
for contact in contact_list:
if 'public_key' in contact:
# Use full public key as index
contacts_dict[contact['public_key']] = contact
return True, contacts_dict, ""
except json.JSONDecodeError as e:
logger.error(f"Failed to parse contact_info JSON: {e}")
return False, {}, f'JSON parse error: {str(e)}'
except Exception as e:
logger.error(f"Error getting contact details: {e}")
return False, {}, str(e)
def delete_contact(selector: str) -> Tuple[bool, str]:
"""
Delete a contact from the device.
+29 -8
View File
@@ -1221,6 +1221,7 @@ def get_contacts_detailed_api():
"public_key_prefix": "df2027d3f2ef",
"type_label": "REP",
"path_or_mode": "Flood",
"last_seen": 1735429453, // Unix timestamp from last_advert
"raw_line": "..."
},
...
@@ -1228,16 +1229,10 @@ def get_contacts_detailed_api():
}
"""
try:
# Get basic contacts list
success, contacts, total_count, error = cli.get_all_contacts_detailed()
if success:
return jsonify({
'success': True,
'contacts': contacts,
'count': total_count,
'limit': 350 # MeshCore device limit
}), 200
else:
if not success:
return jsonify({
'success': False,
'error': error or 'Failed to get contacts list',
@@ -1246,6 +1241,32 @@ def get_contacts_detailed_api():
'limit': 350
}), 500
# Get detailed contact info with last_advert timestamps
success_detailed, contacts_detailed, error_detailed = cli.get_contacts_with_last_seen()
if success_detailed:
# Merge last_advert data with contacts
# Match by public_key_prefix (first 12 chars of full public_key)
for contact in contacts:
prefix = contact.get('public_key_prefix', '')
# Find matching contact in detailed data
for full_key, details in contacts_detailed.items():
if full_key.startswith(prefix):
# Add last_seen timestamp
contact['last_seen'] = details.get('last_advert', None)
break
else:
# If detailed fetch failed, log warning but still return contacts without last_seen
logger.warning(f"Failed to get last_seen data: {error_detailed}")
return jsonify({
'success': True,
'contacts': contacts,
'count': total_count,
'limit': 350 # MeshCore device limit
}), 200
except Exception as e:
logger.error(f"Error getting detailed contacts list: {e}")
return jsonify({
+120
View File
@@ -523,6 +523,93 @@ function renderExistingList(contacts) {
});
}
/**
* Format Unix timestamp as relative time ("5 minutes ago", "2 hours ago", etc.)
*/
function formatRelativeTime(timestamp) {
if (!timestamp) return 'Never';
const now = Math.floor(Date.now() / 1000); // Current time in Unix seconds
const diffSeconds = now - timestamp;
if (diffSeconds < 0) return 'Just now'; // Future timestamp (clock skew)
// Less than 1 minute
if (diffSeconds < 60) {
return 'Just now';
}
// Less than 1 hour
if (diffSeconds < 3600) {
const minutes = Math.floor(diffSeconds / 60);
return `${minutes} minute${minutes !== 1 ? 's' : ''} ago`;
}
// Less than 1 day
if (diffSeconds < 86400) {
const hours = Math.floor(diffSeconds / 3600);
return `${hours} hour${hours !== 1 ? 's' : ''} ago`;
}
// Less than 30 days
if (diffSeconds < 2592000) {
const days = Math.floor(diffSeconds / 86400);
return `${days} day${days !== 1 ? 's' : ''} ago`;
}
// Less than 1 year
if (diffSeconds < 31536000) {
const months = Math.floor(diffSeconds / 2592000);
return `${months} month${months !== 1 ? 's' : ''} ago`;
}
// More than 1 year
const years = Math.floor(diffSeconds / 31536000);
return `${years} year${years !== 1 ? 's' : ''} ago`;
}
/**
* Get activity status indicator based on last_seen timestamp
* Returns: { icon: string, color: string, title: string }
*/
function getActivityStatus(timestamp) {
if (!timestamp) {
return {
icon: '⚫',
color: '#6c757d',
title: 'Never seen'
};
}
const now = Math.floor(Date.now() / 1000);
const diffSeconds = now - timestamp;
// Active (< 5 minutes)
if (diffSeconds < 300) {
return {
icon: '🟢',
color: '#28a745',
title: 'Active (seen recently)'
};
}
// Recent (< 1 hour)
if (diffSeconds < 3600) {
return {
icon: '🟡',
color: '#ffc107',
title: 'Recent activity'
};
}
// Inactive (> 1 hour)
return {
icon: '🔴',
color: '#dc3545',
title: 'Inactive'
};
}
function createExistingContactCard(contact, index) {
const card = document.createElement('div');
card.className = 'existing-contact-card';
@@ -567,6 +654,38 @@ function createExistingContactCard(contact, index) {
keyDiv.textContent = contact.public_key_prefix;
keyDiv.title = 'Public Key Prefix';
// Last seen row (with activity status indicator)
const lastSeenDiv = document.createElement('div');
lastSeenDiv.className = 'text-muted small d-flex align-items-center gap-1';
lastSeenDiv.style.marginBottom = '0.25rem';
if (contact.last_seen) {
const status = getActivityStatus(contact.last_seen);
const relativeTime = formatRelativeTime(contact.last_seen);
const statusIcon = document.createElement('span');
statusIcon.textContent = status.icon;
statusIcon.style.fontSize = '0.9rem';
statusIcon.title = status.title;
const timeText = document.createElement('span');
timeText.textContent = `Last seen: ${relativeTime}`;
lastSeenDiv.appendChild(statusIcon);
lastSeenDiv.appendChild(timeText);
} else {
// No last_seen data available
const statusIcon = document.createElement('span');
statusIcon.textContent = '⚫';
statusIcon.style.fontSize = '0.9rem';
const timeText = document.createElement('span');
timeText.textContent = 'Last seen: Unknown';
lastSeenDiv.appendChild(statusIcon);
lastSeenDiv.appendChild(timeText);
}
// Path/mode (optional)
let pathDiv = null;
if (contact.path_or_mode && contact.path_or_mode !== 'Flood') {
@@ -597,6 +716,7 @@ function createExistingContactCard(contact, index) {
// Assemble card
card.appendChild(infoRow);
card.appendChild(keyDiv);
card.appendChild(lastSeenDiv);
if (pathDiv) card.appendChild(pathDiv);
card.appendChild(actionsDiv);