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>
This commit is contained in:
Jean-Pierre MartineauandClaude Opus 4.8 committed 2026-07-14 01:18:41 -04:00
1 parent 6aecb58e59
commit d977aeb22a
6 files changed
+328 -16

No files matched your search

+2 -1
View File
@@ -94,7 +94,8 @@ Relative ages: `format_ago` for prose ("now", "5m ago", "never" — never "now a
- Concept icons: 📡 advert · 🕒 clock/sync · 🔄 reboot · 💾 backup · 📂 restore ·
🔑 channel/credential key · 🔐 identity/auth secret · 🗑 clear/delete · ✎ compose/edit ·
⚙ parameter · `#` count · ▶ run · ⚡ explore/probe · ★ best/winner · ⭐ watch ·
📤 send now · 📨 courier/queue · 💬 chat · 📱 QR · 🔗 link · ↻ re-read · ↕ reorder ·
📤 send now · 📨 courier/queue · 💬 chat · 🔔 notify · 🔕 mute (notifications off) ·
📱 QR · 🔗 link · ↻ re-read · ↕ reorder ·
⌨ command line · 🚪 quit. Packet-class icons (feed/viewer lane): 📢 advert ·
📊 telemetry · 📦 packet · 💬 message · ✅ ack.
+7
View File
@@ -16,6 +16,7 @@ from .core.admin_store import AdminStore
from .core.advert_store import AdvertStore
from .core.config import DeviceProfile, Settings
from .core.courier_store import CourierStore
from .core.mute_store import MuteStore
from .core.remote_store import RemoteStore
from .core.watch_store import WatchStore
from .core.connection import Device, make_device
@@ -56,6 +57,9 @@ class AppContext:
log (defaults to ``<config_dir>/watchtower.json`` when not injected).
courier_store: Store for the Courier's outbox — queued, delivered, and
given-up messages (defaults to ``<config_dir>/courier.json``).
mute_store: Store for muted channel notifications — the channels whose new
messages don't raise the unread badge (defaults to
``<config_dir>/mutes.json`` when not injected).
mock: Whether the simulator device is in use.
port_override: Explicit serial port (from ``--port`` or the interactive picker),
overriding the profile.
@@ -78,6 +82,7 @@ class AppContext:
remote_store: Optional[RemoteStore] = None
watch_store: Optional[WatchStore] = None
courier_store: Optional[CourierStore] = None
mute_store: Optional[MuteStore] = None
profile: Optional[DeviceProfile] = None
mock: bool = False
port_override: Optional[str] = None
@@ -119,6 +124,8 @@ class AppContext:
self.watch_store = WatchStore(self.settings.config_dir / "watchtower.json")
if self.courier_store is None:
self.courier_store = CourierStore(self.settings.config_dir / "courier.json")
if self.mute_store is None:
self.mute_store = MuteStore(self.settings.config_dir / "mutes.json")
@property
def profile_name(self) -> Optional[str]:
+99
View File
@@ -0,0 +1,99 @@
"""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)
+25 -2
View File
@@ -102,6 +102,18 @@ class ChatService:
if key is not None:
self._unread.pop(key, None)
def clear_unread(self, key: str) -> None:
"""Drop a conversation's unread count without making it the active thread.
Used when a channel is muted: the channel's outstanding unread is zeroed (per the
mute UX) while the currently-open conversation, if any, is left untouched — unlike
:meth:`set_active`, which also re-points the active thread.
Args:
key: The conversation key to clear.
"""
self._unread.pop(key, None)
async def refresh_channels(self) -> None:
"""Rebuild the slot-index -> channel-identity map from the device's channel table.
@@ -265,16 +277,27 @@ class ChatService:
def _store_inbound(
self, run_id: int, message: Message, channel_id: Optional[str]
) -> None:
"""Persist an inbound message under a resolved identity and bump its unread count."""
"""Persist an inbound message under a resolved identity and bump its unread count.
The transcript is always recorded; the unread bump is skipped for the open
conversation (the user is already reading it) and for a *muted* channel (its new
messages don't raise the unread badge — see :class:`~meshterm.core.mute_store.MuteStore`).
A muted channel is still written to history, so opening it later shows everything.
"""
chat = ChatMessage.from_message(message, channel_id=channel_id)
self._session_count += 1
if chat.key != self._active:
if chat.key != self._active and not self._is_muted(chat):
self._unread[chat.key] = self._unread.get(chat.key, 0) + 1
try:
self._ctx.repo.record_chat_message(chat, run_id=run_id)
except Exception as exc: # noqa: BLE001 - never let logging break the subscription
self._ctx.log.debug("chat: failed to record message: %s", exc)
def _is_muted(self, chat: ChatMessage) -> bool:
"""Whether this message belongs to a channel the user has muted notifications for."""
store = getattr(self._ctx, "mute_store", None)
return bool(chat.is_channel and store is not None and store.is_muted(chat.channel_id))
async def _deliver_direct(self, contact: Contact, text: str):
"""Transmit a direct message, softly retrying until it is acknowledged.
+72 -13
View File
@@ -2,11 +2,18 @@
This is the user-facing channel-management experience for the ``channels`` tool: a live
list of the device's channel slots with, for each, a detail view that shows the sharable
QR code and key, renames or re-keys it, opens it in chat, or clears it. New channels are
created four ways — a fresh private channel (random key), a public ``#`` channel (key
derived from the name), joining by pasting a key, or importing a scanned ``meshcore://``
link. Every change is written to the device immediately (like a phone app), so the list you
see always reflects the radio.
QR code and key, renames or re-keys it, opens it in chat, mutes its notifications, or clears
it. New channels are created four ways — a fresh private channel (random key), a public ``#``
channel (key derived from the name), joining by pasting a key, or importing a scanned
``meshcore://`` link. Every change is written to the device immediately (like a phone app), so
the list you see always reflects the radio.
Muting is the one per-channel setting that is a local *preference* rather than device
configuration: a muted channel's new messages stop raising the unread badge (its inbound
messages no longer accrue unread, and muting zeros whatever it had), while still being
recorded to history. The mute lives in :class:`~meshterm.core.mute_store.MuteStore`, keyed by
the channel's intrinsic identity so it follows the channel across slot moves, and the list
row shows a ``🔕`` in its (then always-empty) unread lane to mark it.
The list is laid out like the config editor: fixed, column-aligned lanes under one header
line — the openness glyph and name, unread badge, total messages, last-message age, and a
@@ -75,6 +82,7 @@ _BACK = "__back__"
_QR = "qr"
_KEY = "key"
_CHAT = "chat"
_MUTE = "mute"
_EDIT = "edit"
_CLEAR = "clear"
@@ -316,6 +324,29 @@ def _slot_label(slot: ChannelSlot) -> str:
return f"{channel_glyph(slot.name, slot.secret)} {slot.name}"
# --- notifications -------------------------------------------------------------
def _is_muted(ctx: "AppContext", slot: ChannelSlot) -> bool:
"""Whether this channel's new-message notifications are muted (see :class:`MuteStore`)."""
return ctx.mute_store.is_muted(slot.identity)
def _toggle_mute(ctx: "AppContext", slot: ChannelSlot) -> None:
"""Flip a channel's notification mute, zeroing its unread the moment it is muted.
Muting is a remembered per-channel preference (keyed by the channel's intrinsic
identity, so it follows the channel across slot moves) that stops its new messages from
raising the unread badge. Muting also clears whatever unread it had accrued this session,
so the badge drops immediately rather than lingering until the next open — the reverse
(unmuting) simply lets future messages start counting again.
"""
now_muted = not _is_muted(ctx, slot)
ctx.mute_store.set_muted(slot.identity, now_muted)
if now_muted:
ctx.chat.clear_unread(slot.conversation.key)
# --- message statistics --------------------------------------------------------
@@ -419,17 +450,22 @@ def _slot_text(
:func:`_lanes_header` line. Colour stays light and purposeful: the name is the row's
focus in the base colour, the descriptive lanes are muted, the unread ``●`` badge is
red with its count in warn (the conversation picker's language), and the sparkline
draws in the ok green over a faint flatline. The row is always a Rich
:class:`~rich.text.Text` so those spans
survive under the select screen's row highlight.
draws in the ok green over a faint flatline. A muted channel shows a muted ``🔕`` in
the unread lane instead of a count — muting zeros its unread and stops it accruing, so
that lane is always free to carry the state. The row is always a Rich
:class:`~rich.text.Text` so those spans survive under the select screen's row highlight.
"""
st = stats.get(slot.identity)
muted = _is_muted(ctx, slot)
unread = ctx.chat.unread(slot.conversation.key)
text = Text(no_wrap=True, overflow="ellipsis")
text.append(f"{channel_glyph(slot.name, slot.secret)} ") # # / 🌐 / 🔒 (2 cells) + gap
text.append(fit_cells(slot.name, name_w))
text.append(" ")
if unread:
if muted:
text.append("🔕", style="muted") # 2 cells; pad the rest of the lane
text.append(" " * (_BADGE_WIDTH - 2))
elif unread:
text.append("●", style="err")
text.append(f" {unread}".ljust(_BADGE_WIDTH - 1), style="warn")
else:
@@ -492,14 +528,18 @@ def _menu_items(
def _detail_summary(ctx: "AppContext", slot: ChannelSlot, stats: _LiveStats) -> str:
"""One line of vital signs for the detail screen: slot, totals, unread, last activity."""
"""One line of vital signs for the detail screen: slot, totals, unread, mute, last activity."""
st = stats.get(slot.identity)
unread = ctx.chat.unread(slot.conversation.key)
muted = _is_muted(ctx, slot)
if st is None or not st.total:
return f"Slot {slot.idx} · no messages recorded yet"
base = f"Slot {slot.idx} · no messages recorded yet"
return f"{base} · muted" if muted else base
parts = [f"Slot {slot.idx}", f"{st.total} message{'' if st.total == 1 else 's'}"]
if unread:
parts.append(f"{unread} unread")
if muted:
parts.append("muted")
if st.last_at is not None:
parts.append(f"last message {format_ago(_age_seconds(st.last_at))}")
return " · ".join(parts)
@@ -510,19 +550,34 @@ def _detail_items(ctx: "AppContext", slot: ChannelSlot) -> list:
The Device actions presentation (menu-style lanes, no header line — these are commands,
not tabular data), padded in display cells so the double-width emoji can't skew the
description column. Open-in-chat carries the channel's live unread badge, and the one
destructive row keeps an err-tinted label so it reads as such.
description column. Open-in-chat carries the channel's live unread badge, the
notifications row reads as a toggle whose glyph and verb reflect the current mute state
(an immediate action, so no trailing ``…``), and the one destructive row keeps an
err-tinted label so it reads as such.
"""
unread = ctx.chat.unread(slot.conversation.key)
chat_label = Text("💬 Open in chat")
if unread:
chat_label.append(" ●", style="err")
chat_label.append(f" {unread}", style="warn")
if _is_muted(ctx, slot):
mute_row = (
"🔔 Unmute notifications",
"Show new messages here in the unread badge",
_MUTE,
)
else:
mute_row = (
"🔕 Mute notifications",
"Hide new messages here from the unread badge",
_MUTE,
)
items = menu_rows(
[
("📱 Show QR code", "Share this channel as a scannable code", _QR),
("🔑 Show key", "The name, key, hash, and share link", _KEY),
(chat_label, "Read and send messages on this channel", _CHAT),
mute_row,
("✎ Rename / change key…", "Edit the name or paste a different key", _EDIT),
(
Text("🗑 Clear this slot…", style="err"),
@@ -561,6 +616,10 @@ async def _channel_detail(
await _show_key(ctx, slot)
elif choice == _CHAT:
await _open_chat(ctx, slot)
elif choice == _MUTE:
# A preference, not a slot-config change: toggle in place and loop back to the
# detail (whose rows re-render to the new state) without counting a channel change.
_toggle_mute(ctx, slot)
elif choice == _EDIT and await _edit(ctx, device, slot):
return 1
elif choice == _CLEAR and await _clear(ctx, device, slot):
+123
View File
@@ -630,6 +630,129 @@ async def test_detail_summary_reads_slot_totals_and_unread(ctx: AppContext) -> N
assert summary == "Slot 3 · 1 message · 1 unread · last message now"
# -- muting channel notifications ---------------------------------------------
def test_mute_store_round_trips_and_persists(tmp_path: Path) -> None:
"""A muted channel is remembered across store reloads; unmuting forgets it."""
from meshterm.core.mute_store import MuteStore
path = tmp_path / "mutes.json"
store = MuteStore(path)
assert store.is_muted("wardriving-id") is False
assert store.is_muted(None) is False # an unresolved channel is never muted
store.set_muted("wardriving-id", True)
assert store.is_muted("wardriving-id") is True
assert MuteStore(path).is_muted("wardriving-id") is True # survived a reload from disk
store.set_muted("wardriving-id", False)
assert store.is_muted("wardriving-id") is False
assert MuteStore(path).muted() == set() # the unmute persisted too
async def test_muting_suppresses_unread_but_still_records(ctx: AppContext) -> None:
"""A muted channel's inbound message lands in history but never bumps the unread badge."""
from meshterm.core.events import MeshEvent
from meshterm.core.models import Message
device = await ctx.device()
await device.set_channel(0, "#wardriving", None) # public, key derived from the name
slot = (await read_channel_slots(device))[0]
ctx.mute_store.set_muted(slot.identity, True)
await ctx.chat.start()
try:
ctx.events.publish(
MeshEvent.message_event(Message(text="auto beacon", channel=0, is_channel=True))
)
await ctx.chat._queue.join()
# Muted: no unread accrued anywhere, but the transcript is all there.
assert ctx.chat.unread(slot.conversation.key) == 0
assert ctx.chat.unread_total() == 0
stored = ctx.repo.recent_chat_messages(is_channel=True, channel_id=slot.identity)
assert [m.text for m in stored] == ["auto beacon"]
finally:
await ctx.chat.stop()
async def test_unmuted_channel_still_bumps_unread(ctx: AppContext) -> None:
"""The control case: an ordinary (unmuted) channel does raise the unread badge."""
from meshterm.core.events import MeshEvent
from meshterm.core.models import Message
device = await ctx.device()
await device.set_channel(0, "Ops", bytes(range(16)))
slot = (await read_channel_slots(device))[0]
await ctx.chat.start()
try:
ctx.events.publish(
MeshEvent.message_event(Message(text="hey", channel=0, is_channel=True))
)
await ctx.chat._queue.join()
assert ctx.chat.unread(slot.conversation.key) == 1
finally:
await ctx.chat.stop()
async def test_toggle_mute_zeros_unread_and_persists(ctx: AppContext) -> None:
"""Muting through the detail toggle flips the store and clears the channel's unread."""
from meshterm.ui.channels import _is_muted, _toggle_mute
device = await ctx.device()
await device.set_channel(0, "Ops", bytes(range(16)))
slot = (await read_channel_slots(device))[0]
ctx.chat._unread[slot.conversation.key] = 4 # as the service would after four arrivals
assert ctx.chat.unread_total() == 4
_toggle_mute(ctx, slot) # mute
assert _is_muted(ctx, slot) is True
assert ctx.chat.unread(slot.conversation.key) == 0 # muting zeroed it
assert ctx.chat.unread_total() == 0
_toggle_mute(ctx, slot) # unmute
assert _is_muted(ctx, slot) is False
async def test_muted_channel_row_shows_bell_not_unread(ctx: AppContext) -> None:
"""A muted channel's list row carries the 🔕 glyph in place of an unread badge."""
from meshterm.ui.channels import _LiveStats, _menu_items
from meshterm.ui.tui import Choice
device = await ctx.device()
await device.set_channel(0, "#wardriving", None)
slots = await read_channel_slots(device)
slot = slots[0]
ctx.chat._unread[slot.conversation.key] = 3 # stale unread that muting must not display
ctx.mute_store.set_muted(slot.identity, True)
_, items = _menu_items(ctx, slots, 8, _LiveStats(ctx))
row = next(it for it in items if isinstance(it, Choice) and it.value == 0)
plain = row.label.plain
assert "🔕" in plain
assert "● 3" not in plain # the mute glyph replaces the unread badge
async def test_detail_mute_row_reflects_state(ctx: AppContext) -> None:
"""The detail's notifications row reads 'Mute' when on and 'Unmute' once muted."""
from meshterm.ui.channels import _detail_items
from meshterm.ui.tui import Choice
device = await ctx.device()
await device.set_channel(0, "Ops", bytes(range(16)))
slot = (await read_channel_slots(device))[0]
def mute_label() -> str:
items = _detail_items(ctx, slot)
row = next(it for it in items if isinstance(it, Choice) and it.value == "mute")
return row.label.plain
assert "🔕 Mute notifications" in mute_label()
ctx.mute_store.set_muted(slot.identity, True)
assert "🔔 Unmute notifications" in mute_label()
# -- the tool against the simulator -------------------------------------------