feat: Implement server-side read status for cross-device synchronization

Replace localStorage-based message read tracking with server-side storage
to enable unread badge synchronization across all devices and browsers.

Changes:
- Add read_status.py module for server-side read status management
- Add GET /api/read_status endpoint to fetch read status
- Add POST /api/read_status/mark_read endpoint to update read status
- Update app.js to load/save read status from server instead of localStorage
- Update dm.js to load/save DM read status from server instead of localStorage
- Read status stored in MC_CONFIG_DIR/.read_status.json for persistence

Benefits:
- Unread badges sync across all devices (phone, computer, tablet)
- Read status persists across browser sessions
- No more duplicate unread notifications when switching devices

🤖 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
2026-01-01 15:41:41 +01:00
parent 4345456db7
commit 5518c622bc
5 changed files with 446 additions and 41 deletions
+1
View File
@@ -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
+198
View File
@@ -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
+138
View File
@@ -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
+73 -27
View File
@@ -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);
}
}
+36 -14
View File
@@ -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();