Files
mc-webui/app/read_status.py
T
MarekWoandClaude Fable 5.1 04c4312a7f feat(notifications): per-channel notification profiles
A channel used to have two settings: every message or nothing. Now it has
three - muted, every message, or a named profile of rules (mentions me,
message contains, sender contains; any or all must match). A channel on a
profile counts only matching messages: the unread badge, the navbar bell,
the app-icon badge and the browser/Android notification all follow the same
rule. Direct messages are untouched.

- app/notify_profiles.py: rule matching (case- and diacritic-insensitive,
  same table as the chat filter), validation, storage in app_settings
- read_status.notify_profile column, written together with is_muted so a
  mute never leaves a profile behind; migration added
- /api/notification-profiles CRUD, PUT /api/channels/<i>/notifications;
  /api/read_status and /api/messages/updates report channel_notify_profiles,
  the latter counts a profiled channel's unread through the profile
- Manage Channels: the bell opens a menu (Off / Every message / profiles /
  Manage profiles...) instead of toggling; profiled rows get a green badge
- Settings > Notifications: profile list with in-use counts and an editor
- app.js evaluates the same rules for socket-delivered messages, and a
  state version guard stops an in-flight poll from undoing a fresh
  mute/favorite/profile change
- i18n en/pl (43 keys), docs (user guide, architecture, whatsnew), tests

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 20:17:40 +02:00

206 lines
6.7 KiB
Python

"""
Read Status Manager - DB-backed storage for message read status
Manages the last seen timestamps for channels and DM conversations,
providing cross-device synchronization for unread message tracking.
All data is stored in the read_status table of the SQLite database.
"""
import logging
from flask import current_app
logger = logging.getLogger(__name__)
def _get_db():
"""Get database instance from Flask app context."""
return getattr(current_app, 'db', None)
def load_read_status():
"""Load read status from database.
Returns:
dict: Read status with 'channels', 'dm', 'muted_channels',
'favorite_channels' and 'channel_notify_profiles' keys. The last is
{"<channel idx>": "<profile id>"} for channels in profile mode.
"""
try:
db = _get_db()
rows = db.get_read_status()
channels = {}
dm = {}
muted_channels = []
favorite_channels = []
channel_notify_profiles = {}
for key, row in rows.items():
if key.startswith('chan_'):
chan_idx = key[5:] # "chan_0" -> "0"
channels[chan_idx] = row['last_seen_ts']
if row.get('is_muted'):
try:
muted_channels.append(int(chan_idx))
except ValueError:
pass
if row.get('is_favorite'):
try:
favorite_channels.append(int(chan_idx))
except ValueError:
pass
if row.get('notify_profile') and not row.get('is_muted'):
channel_notify_profiles[chan_idx] = row['notify_profile']
elif key.startswith('dm_'):
conv_id = key[3:] # "dm_name_User1" -> "name_User1"
dm[conv_id] = row['last_seen_ts']
return {
'channels': channels,
'dm': dm,
'muted_channels': muted_channels,
'favorite_channels': favorite_channels,
'channel_notify_profiles': channel_notify_profiles,
}
except Exception as e:
logger.error(f"Error loading read status: {e}")
return {'channels': {}, 'dm': {}, 'muted_channels': [], 'favorite_channels': [],
'channel_notify_profiles': {}}
def save_read_status(status):
"""No-op — data is written per-operation via mark_* functions."""
return True
def mark_channel_read(channel_idx, timestamp):
"""Mark a channel as read up to a specific timestamp."""
try:
db = _get_db()
db.mark_read(f"chan_{channel_idx}", int(timestamp))
logger.debug(f"Marked channel {channel_idx} as read at timestamp {timestamp}")
return True
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."""
try:
db = _get_db()
db.mark_read(f"dm_{conversation_id}", int(timestamp))
logger.debug(f"Marked DM conversation {conversation_id} as read at timestamp {timestamp}")
return True
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."""
try:
status = load_read_status()
return status['channels'].get(str(channel_idx), 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."""
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
def get_muted_channels():
"""Get list of muted channel indices."""
try:
db = _get_db()
return db.get_muted_channels()
except Exception as e:
logger.error(f"Error getting muted channels: {e}")
return []
def set_channel_muted(channel_idx, muted):
"""Set mute state for a channel. Unmuting lands on 'every message'."""
return set_channel_notify(channel_idx, 'muted' if muted else 'all')
def set_channel_notify(channel_idx, mode, profile_id=None):
"""
Set how a channel asks for attention.
Args:
mode: 'muted' (never), 'all' (every message) or 'profile' (only
messages passing the notification profile `profile_id`)
"""
try:
db = _get_db()
muted = mode == 'muted'
db.set_channel_notify(int(channel_idx), muted,
profile_id if mode == 'profile' else None)
if mode == 'profile':
logger.info(f"Channel {channel_idx} notifications: profile {profile_id}")
else:
logger.info(f"Channel {channel_idx} {'muted' if muted else 'unmuted'}")
return True
except Exception as e:
logger.error(f"Error setting notifications for channel {channel_idx}: {e}")
return False
def get_channel_notify_profiles():
"""Channels in profile mode: {channel_idx: profile_id}."""
try:
db = _get_db()
return db.get_channel_notify_profiles()
except Exception as e:
logger.error(f"Error getting channel notification profiles: {e}")
return {}
def get_favorite_channels():
"""Get list of favorite channel indices."""
try:
db = _get_db()
return db.get_favorite_channels()
except Exception as e:
logger.error(f"Error getting favorite channels: {e}")
return []
def set_channel_favorite(channel_idx, favorite):
"""Set favorite state for a channel."""
try:
db = _get_db()
db.set_channel_favorite(int(channel_idx), favorite)
logger.info(f"Channel {channel_idx} {'favorited' if favorite else 'unfavorited'}")
return True
except Exception as e:
logger.error(f"Error setting favorite for channel {channel_idx}: {e}")
return False
def mark_all_channels_read(channel_timestamps):
"""Mark all channels as read in bulk.
Args:
channel_timestamps (dict): {"0": timestamp, "1": timestamp, ...}
"""
try:
db = _get_db()
for channel_key, timestamp in channel_timestamps.items():
db.mark_read(f"chan_{channel_key}", int(timestamp))
logger.info(f"Marked {len(channel_timestamps)} channels as read")
return True
except Exception as e:
logger.error(f"Error marking all channels as read: {e}")
return False