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:
1 parent
6aecb58e59
commit
d977aeb22a
6 files changed
+328
-16
No files matched your search
@@ -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.
|
||||
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -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 -------------------------------------------
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user