forked from iarv/mc-webui
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>
199 lines
5.6 KiB
Python
199 lines
5.6 KiB
Python
"""
|
|
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
|