diff --git a/CLAUDE.md b/CLAUDE.md index d524a78..fa63ca4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/meshterm/context.py b/meshterm/context.py index 709244e..2da474c 100644 --- a/meshterm/context.py +++ b/meshterm/context.py @@ -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 ``/watchtower.json`` when not injected). courier_store: Store for the Courier's outbox โ€” queued, delivered, and given-up messages (defaults to ``/courier.json``). + mute_store: Store for muted channel notifications โ€” the channels whose new + messages don't raise the unread badge (defaults to + ``/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]: diff --git a/meshterm/core/mute_store.py b/meshterm/core/mute_store.py new file mode 100644 index 0000000..47a3bb9 --- /dev/null +++ b/meshterm/core/mute_store.py @@ -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 (``/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) diff --git a/meshterm/services/chat_service.py b/meshterm/services/chat_service.py index 058bad8..2bb965b 100644 --- a/meshterm/services/chat_service.py +++ b/meshterm/services/chat_service.py @@ -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. diff --git a/meshterm/ui/channels.py b/meshterm/ui/channels.py index a514b7b..cb21381 100644 --- a/meshterm/ui/channels.py +++ b/meshterm/ui/channels.py @@ -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): diff --git a/tests/test_channels.py b/tests/test_channels.py index 4668a78..7c4b9be 100644 --- a/tests/test_channels.py +++ b/tests/test_channels.py @@ -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 -------------------------------------------