Files
MeshTerm/meshterm/core/mute_store.py
T
Jean-Pierre MartineauandClaude Opus 4.8 d977aeb22a Channels: mute a channel's notifications from its detail screen
Some channels — an automated #wardriving beacon, a chatty public relay —
carry traffic worth recording but not worth being notified about. Muting
a channel now stops its new messages from raising the unread badge: the
recorder skips the unread bump for a muted channel (its transcript is
still written to history), and muting zeros whatever unread it had so the
badge drops immediately. The mute lives in a new MuteStore
(<config_dir>/mutes.json), keyed by the channel's intrinsic identity so
it follows the channel across slot moves and survives restarts.

The toggle is a state-reflecting row in the channel-detail subscreen
(🔕 Mute / 🔔 Unmute), and the manager list marks a muted channel with a
🔕 in its (then always-empty) unread lane.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 01:18:41 -04:00

100 lines
4.3 KiB
Python

"""Persistence for muted channel notifications.
Some channels carry traffic you want *recorded* but not *notified* about — an automated
``#wardriving`` beacon, a chatty public relay. Muting a channel here means its new messages
stop raising the unread badge: they no longer bump the per-conversation unread count (so the
channel list, the conversation picker, and the header's unread total all skip them), while the
transcript is still written to history so the conversation is all there when you open it. The
opposite of muting is the default — every channel notifies until you say otherwise.
The mute is keyed by the channel's *intrinsic identity* (see
:func:`~meshterm.core.channels.channel_identity`), the same slot-independent key its chat
history and unread counter use — never a slot index — so a muted channel stays muted after it
is reordered to another slot, and two devices that share a channel's key share the mute. Like
the other operator preferences (remembered devices, watched nodes, admin passwords), this is
global machine state in a small JSON file (``<config_dir>/mutes.json``) rather than the
per-invocation SQLite database. Reads are served from memory after the first load — the chat
recorder consults it on every inbound channel message and the channel list on every repaint —
and the rare, user-driven writes are flushed immediately and atomically.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Optional
class MuteStore:
"""Reads and writes the set of channels whose notifications are muted, memory-first.
Interact through :meth:`is_muted` (the hot-path check), :meth:`set_muted` (the toggle),
and :meth:`muted` (the whole set). The backing set is loaded once on first access and kept
in memory thereafter; each mutation persists the whole set atomically.
"""
def __init__(self, path: Path) -> None:
"""Open the store against a JSON file location.
Args:
path: Path to the JSON state file (created lazily on the first mute).
"""
self._path = path
self._muted: Optional[set[str]] = None
@property
def _state(self) -> set[str]:
"""The muted-channel-identity set, loaded from disk on first access."""
if self._muted is None:
self._muted = self._load()
return self._muted
def _load(self) -> set[str]:
"""Parse the file into a set of channel identities, or empty on missing/corrupt file."""
try:
data = json.loads(self._path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return set()
if not isinstance(data, dict):
return set()
return {str(x) for x in data.get("muted", []) if isinstance(x, str)}
def is_muted(self, channel_id: Optional[str]) -> bool:
"""Whether the channel with this intrinsic identity has its notifications muted.
Args:
channel_id: The channel's identity (``None`` — an unresolved channel — is never
muted, so a message on it always notifies).
Returns:
``True`` if the channel is muted.
"""
return channel_id is not None and channel_id in self._state
def muted(self) -> set[str]:
"""The muted channel identities (a copy, safe for the caller to keep)."""
return set(self._state)
def set_muted(self, channel_id: str, muted: bool) -> None:
"""Mute or unmute a channel's notifications, persisting only a real change.
Args:
channel_id: The channel's intrinsic identity.
muted: ``True`` to mute, ``False`` to restore notifications.
"""
if muted == (channel_id in self._state):
return # already in the requested state; nothing to write
if muted:
self._state.add(channel_id)
else:
self._state.discard(channel_id)
self._save()
def _save(self) -> None:
"""Persist the whole muted set atomically (a crash mid-write keeps the old file)."""
data = {"muted": sorted(self._state)}
self._path.parent.mkdir(parents=True, exist_ok=True)
tmp = self._path.with_suffix(self._path.suffix + ".tmp")
tmp.write_text(json.dumps(data, indent=2), encoding="utf-8")
tmp.replace(self._path)