diff --git a/README.md b/README.md index 7fc6550..ef2cb60 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ A lightweight web interface for meshcore-cli, providing browser-based access to - 💬 **View messages** - Display chat history with intelligent auto-refresh - 🔔 **Smart notifications** - Bell icon with unread message counter across all channels - 📊 **Per-channel badges** - Unread count displayed on each channel in selector +- 🔄 **Cross-device sync** - Unread message status syncs across all devices (server-side storage) - ✉️ **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 diff --git a/app/read_status.py b/app/read_status.py new file mode 100644 index 0000000..756995b --- /dev/null +++ b/app/read_status.py @@ -0,0 +1,198 @@ +""" +Read Status Manager - Server-side storage for message read status + +Manages the last seen timestamps for channels and DM conversations, +providing cross-device synchronization for unread message tracking. +""" + +import json +import logging +import os +from pathlib import Path +from threading import Lock +from app.config import config + +logger = logging.getLogger(__name__) + +# Thread-safe lock for file operations +_status_lock = Lock() + +# Path to read status file +READ_STATUS_FILE = Path(config.MC_CONFIG_DIR) / '.read_status.json' + + +def _get_default_status(): + """Get default read status structure""" + return { + 'channels': {}, # {"0": timestamp, "1": timestamp, ...} + 'dm': {} # {"name_User1": timestamp, "pk_abc123": timestamp, ...} + } + + +def load_read_status(): + """ + Load read status from disk. + + Returns: + dict: Read status with 'channels' and 'dm' keys + """ + with _status_lock: + try: + if not READ_STATUS_FILE.exists(): + logger.info("Read status file does not exist, creating default") + return _get_default_status() + + with open(READ_STATUS_FILE, 'r', encoding='utf-8') as f: + status = json.load(f) + + # Validate structure + if not isinstance(status, dict): + logger.warning("Invalid read status structure, resetting") + return _get_default_status() + + # Ensure both keys exist + if 'channels' not in status: + status['channels'] = {} + if 'dm' not in status: + status['dm'] = {} + + logger.debug(f"Loaded read status: {len(status['channels'])} channels, {len(status['dm'])} DM conversations") + return status + + except json.JSONDecodeError as e: + logger.error(f"Failed to parse read status file: {e}") + return _get_default_status() + except Exception as e: + logger.error(f"Error loading read status: {e}") + return _get_default_status() + + +def save_read_status(status): + """ + Save read status to disk. + + Args: + status (dict): Read status with 'channels' and 'dm' keys + + Returns: + bool: True if successful, False otherwise + """ + with _status_lock: + try: + # Ensure directory exists + READ_STATUS_FILE.parent.mkdir(parents=True, exist_ok=True) + + # Write atomically (write to temp file, then rename) + temp_file = READ_STATUS_FILE.with_suffix('.tmp') + with open(temp_file, 'w', encoding='utf-8') as f: + json.dump(status, f, indent=2) + + # Atomic rename + temp_file.replace(READ_STATUS_FILE) + + logger.debug(f"Saved read status: {len(status['channels'])} channels, {len(status['dm'])} DM conversations") + return True + + except Exception as e: + logger.error(f"Error saving read status: {e}") + return False + + +def mark_channel_read(channel_idx, timestamp): + """ + Mark a channel as read up to a specific timestamp. + + Args: + channel_idx (int or str): Channel index (will be converted to string) + timestamp (int or float): Unix timestamp of last read message + + Returns: + bool: True if successful, False otherwise + """ + try: + # Load current status + status = load_read_status() + + # Update channel timestamp (ensure key is string for JSON compatibility) + channel_key = str(channel_idx) + status['channels'][channel_key] = int(timestamp) + + # Save updated status + success = save_read_status(status) + + if success: + logger.debug(f"Marked channel {channel_idx} as read at timestamp {timestamp}") + + return success + + except Exception as e: + logger.error(f"Error marking channel {channel_idx} as read: {e}") + return False + + +def mark_dm_read(conversation_id, timestamp): + """ + Mark a DM conversation as read up to a specific timestamp. + + Args: + conversation_id (str): Conversation identifier (e.g., "name_User1" or "pk_abc123") + timestamp (int or float): Unix timestamp of last read message + + Returns: + bool: True if successful, False otherwise + """ + try: + # Load current status + status = load_read_status() + + # Update DM timestamp + status['dm'][conversation_id] = int(timestamp) + + # Save updated status + success = save_read_status(status) + + if success: + logger.debug(f"Marked DM conversation {conversation_id} as read at timestamp {timestamp}") + + return success + + except Exception as e: + logger.error(f"Error marking DM conversation {conversation_id} as read: {e}") + return False + + +def get_channel_last_seen(channel_idx): + """ + Get last seen timestamp for a specific channel. + + Args: + channel_idx (int or str): Channel index + + Returns: + int: Unix timestamp, or 0 if never seen + """ + try: + status = load_read_status() + channel_key = str(channel_idx) + return status['channels'].get(channel_key, 0) + except Exception as e: + logger.error(f"Error getting last seen for channel {channel_idx}: {e}") + return 0 + + +def get_dm_last_seen(conversation_id): + """ + Get last seen timestamp for a specific DM conversation. + + Args: + conversation_id (str): Conversation identifier + + Returns: + int: Unix timestamp, or 0 if never seen + """ + try: + status = load_read_status() + return status['dm'].get(conversation_id, 0) + except Exception as e: + logger.error(f"Error getting last seen for DM {conversation_id}: {e}") + return 0 diff --git a/app/routes/api.py b/app/routes/api.py index 5fb505e..236d2a2 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -1793,3 +1793,141 @@ def update_device_settings_api(): 'success': False, 'error': str(e) }), 500 + + +# ============================================================================= +# Read Status (Server-side message read tracking) +# ============================================================================= + +@api_bp.route('/read_status', methods=['GET']) +def get_read_status_api(): + """ + Get server-side read status for all channels and DM conversations. + + This replaces localStorage-based tracking to enable cross-device synchronization. + + Returns: + JSON with read status: + { + "success": true, + "channels": { + "0": 1735900000, + "1": 1735900100 + }, + "dm": { + "name_User1": 1735900200, + "pk_abc123": 1735900300 + } + } + """ + try: + from app import read_status + + status = read_status.load_read_status() + + return jsonify({ + 'success': True, + 'channels': status['channels'], + 'dm': status['dm'] + }), 200 + + except Exception as e: + logger.error(f"Error getting read status: {e}") + return jsonify({ + 'success': False, + 'error': str(e), + 'channels': {}, + 'dm': {} + }), 500 + + +@api_bp.route('/read_status/mark_read', methods=['POST']) +def mark_read_api(): + """ + Mark a channel or DM conversation as read. + + JSON body (one of the following): + {"type": "channel", "channel_idx": 0, "timestamp": 1735900000} + {"type": "dm", "conversation_id": "name_User1", "timestamp": 1735900200} + + Returns: + JSON with result: + { + "success": true, + "message": "Channel marked as read" + } + """ + try: + from app import read_status + + data = request.get_json() + + if not data: + return jsonify({ + 'success': False, + 'error': 'Missing JSON body' + }), 400 + + msg_type = data.get('type') + timestamp = data.get('timestamp') + + if not msg_type or not timestamp: + return jsonify({ + 'success': False, + 'error': 'Missing required fields: type and timestamp' + }), 400 + + if msg_type == 'channel': + channel_idx = data.get('channel_idx') + if channel_idx is None: + return jsonify({ + 'success': False, + 'error': 'Missing required field: channel_idx' + }), 400 + + success = read_status.mark_channel_read(channel_idx, timestamp) + + if success: + return jsonify({ + 'success': True, + 'message': f'Channel {channel_idx} marked as read' + }), 200 + else: + return jsonify({ + 'success': False, + 'error': 'Failed to save read status' + }), 500 + + elif msg_type == 'dm': + conversation_id = data.get('conversation_id') + if not conversation_id: + return jsonify({ + 'success': False, + 'error': 'Missing required field: conversation_id' + }), 400 + + success = read_status.mark_dm_read(conversation_id, timestamp) + + if success: + return jsonify({ + 'success': True, + 'message': f'DM conversation {conversation_id} marked as read' + }), 200 + else: + return jsonify({ + 'success': False, + 'error': 'Failed to save read status' + }), 500 + + else: + return jsonify({ + 'success': False, + 'error': f'Invalid type: {msg_type}. Must be "channel" or "dm"' + }), 400 + + except Exception as e: + logger.error(f"Error marking as read: {e}") + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 diff --git a/app/static/js/app.js b/app/static/js/app.js index 8c91afd..795bca6 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -20,9 +20,9 @@ let dmUnreadCounts = {}; // Track unread DM counts per conversation document.addEventListener('DOMContentLoaded', async function() { console.log('mc-webui initialized'); - // Load last seen timestamps from localStorage - loadLastSeenTimestamps(); - loadDmLastSeenTimestamps(); + // Load last seen timestamps from server + await loadLastSeenTimestampsFromServer(); + await loadDmLastSeenTimestampsFromServer(); // Restore last selected channel from localStorage const savedChannel = localStorage.getItem('mc_active_channel'); @@ -707,39 +707,64 @@ function escapeHtml(text) { } /** - * Load last seen timestamps from localStorage + * Load last seen timestamps from server */ -function loadLastSeenTimestamps() { +async function loadLastSeenTimestampsFromServer() { try { - const saved = localStorage.getItem('mc_last_seen_timestamps'); - if (saved) { - lastSeenTimestamps = JSON.parse(saved); - console.log('Loaded last seen timestamps:', lastSeenTimestamps); + const response = await fetch('/api/read_status'); + const data = await response.json(); + + if (data.success && data.channels) { + // Convert string keys to integers for channel indices + lastSeenTimestamps = {}; + for (const [key, value] of Object.entries(data.channels)) { + lastSeenTimestamps[parseInt(key)] = value; + } + console.log('Loaded channel read status from server:', lastSeenTimestamps); + } else { + console.warn('Failed to load read status from server, using empty state'); + lastSeenTimestamps = {}; } } catch (error) { - console.error('Error loading last seen timestamps:', error); + console.error('Error loading read status from server:', error); lastSeenTimestamps = {}; } } /** - * Save last seen timestamps to localStorage + * Save channel read status to server */ -function saveLastSeenTimestamps() { +async function saveChannelReadStatus(channelIdx, timestamp) { try { - localStorage.setItem('mc_last_seen_timestamps', JSON.stringify(lastSeenTimestamps)); + const response = await fetch('/api/read_status/mark_read', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + type: 'channel', + channel_idx: channelIdx, + timestamp: timestamp + }) + }); + + const data = await response.json(); + + if (!data.success) { + console.error('Failed to save channel read status:', data.error); + } } catch (error) { - console.error('Error saving last seen timestamps:', error); + console.error('Error saving channel read status:', error); } } /** * Update last seen timestamp for current channel */ -function markChannelAsRead(channelIdx, timestamp) { +async function markChannelAsRead(channelIdx, timestamp) { lastSeenTimestamps[channelIdx] = timestamp; unreadCounts[channelIdx] = 0; - saveLastSeenTimestamps(); + await saveChannelReadStatus(channelIdx, timestamp); updateUnreadBadges(); } @@ -1172,29 +1197,50 @@ async function copyChannelKey() { // ============================================================================= /** - * Load DM last seen timestamps from localStorage + * Load DM last seen timestamps from server */ -function loadDmLastSeenTimestamps() { +async function loadDmLastSeenTimestampsFromServer() { 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); + const response = await fetch('/api/read_status'); + const data = await response.json(); + + if (data.success && data.dm) { + dmLastSeenTimestamps = data.dm; + console.log('Loaded DM read status from server:', Object.keys(dmLastSeenTimestamps).length, 'conversations'); + } else { + console.warn('Failed to load DM read status from server, using empty state'); + dmLastSeenTimestamps = {}; } } catch (error) { - console.error('Error loading DM last seen timestamps:', error); + console.error('Error loading DM read status from server:', error); dmLastSeenTimestamps = {}; } } /** - * Save DM last seen timestamps to localStorage + * Save DM read status to server */ -function saveDmLastSeenTimestamps() { +async function saveDmReadStatus(conversationId, timestamp) { try { - localStorage.setItem('mc_dm_last_seen_timestamps', JSON.stringify(dmLastSeenTimestamps)); + const response = await fetch('/api/read_status/mark_read', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + type: 'dm', + conversation_id: conversationId, + timestamp: timestamp + }) + }); + + const data = await response.json(); + + if (!data.success) { + console.error('Failed to save DM read status:', data.error); + } } catch (error) { - console.error('Error saving DM last seen timestamps:', error); + console.error('Error saving DM read status:', error); } } diff --git a/app/static/js/dm.js b/app/static/js/dm.js index 2b43cfd..7497b65 100644 --- a/app/static/js/dm.js +++ b/app/static/js/dm.js @@ -24,8 +24,8 @@ document.addEventListener('DOMContentLoaded', async function() { // Force reflow to ensure proper layout calculation document.body.offsetHeight; - // Load last seen timestamps from localStorage - loadDmLastSeenTimestamps(); + // Load last seen timestamps from server + await loadDmLastSeenTimestampsFromServer(); // Setup event listeners setupEventListeners(); @@ -601,37 +601,59 @@ function setupEmojiPicker() { } /** - * Load DM last seen timestamps from localStorage + * Load DM last seen timestamps from server */ -function loadDmLastSeenTimestamps() { +async function loadDmLastSeenTimestampsFromServer() { try { - const saved = localStorage.getItem('mc_dm_last_seen_timestamps'); - if (saved) { - dmLastSeenTimestamps = JSON.parse(saved); + const response = await fetch('/api/read_status'); + const data = await response.json(); + + if (data.success && data.dm) { + dmLastSeenTimestamps = data.dm; + console.log('Loaded DM read status from server:', Object.keys(dmLastSeenTimestamps).length, 'conversations'); + } else { + console.warn('Failed to load DM read status from server, using empty state'); + dmLastSeenTimestamps = {}; } } catch (error) { - console.error('Error loading last seen timestamps:', error); + console.error('Error loading DM read status from server:', error); dmLastSeenTimestamps = {}; } } /** - * Save DM last seen timestamps to localStorage + * Save DM read status to server */ -function saveDmLastSeenTimestamps() { +async function saveDmReadStatus(conversationId, timestamp) { try { - localStorage.setItem('mc_dm_last_seen_timestamps', JSON.stringify(dmLastSeenTimestamps)); + const response = await fetch('/api/read_status/mark_read', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + type: 'dm', + conversation_id: conversationId, + timestamp: timestamp + }) + }); + + const data = await response.json(); + + if (!data.success) { + console.error('Failed to save DM read status:', data.error); + } } catch (error) { - console.error('Error saving last seen timestamps:', error); + console.error('Error saving DM read status:', error); } } /** * Mark conversation as read */ -function markAsRead(conversationId, timestamp) { +async function markAsRead(conversationId, timestamp) { dmLastSeenTimestamps[conversationId] = timestamp; - saveDmLastSeenTimestamps(); + await saveDmReadStatus(conversationId, timestamp); // Update dropdown to remove unread indicator populateConversationSelector();