mirror of
https://github.com/MarekWo/mc-webui.git
synced 2026-07-05 01:11:06 +02:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user