From 7f819b63c76e96e730c6313b810016cff674ffee Mon Sep 17 00:00:00 2001 From: MarekWo Date: Mon, 29 Dec 2025 13:03:15 +0100 Subject: [PATCH] feat(contacts): Add 'Last Seen' timestamp display with activity indicators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/meshcore/cli.py | 57 ++++++++++++++++++ app/routes/api.py | 37 +++++++++--- app/static/js/contacts.js | 120 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 206 insertions(+), 8 deletions(-) diff --git a/app/meshcore/cli.py b/app/meshcore/cli.py index 7d21ea4..55a7549 100644 --- a/app/meshcore/cli.py +++ b/app/meshcore/cli.py @@ -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. diff --git a/app/routes/api.py b/app/routes/api.py index 1908c67..14cf849 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -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({ diff --git a/app/static/js/contacts.js b/app/static/js/contacts.js index 5789b66..d0fbf51 100644 --- a/app/static/js/contacts.js +++ b/app/static/js/contacts.js @@ -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);