From daa26be5df1eec9649757adb4c00f8f092a5baac Mon Sep 17 00:00:00 2001 From: Jean-Pierre Martineau Date: Mon, 19 Oct 2026 23:18:57 -0400 Subject: [PATCH] ^K in a conversation locks the author of the selected message In a channel, a DM, or a room, ^K toggles the lock of the contact that wrote the selected message, so the archive sweep never takes it. The hint says ^K lock or ^K unlock, but only where it fits without dropping another atom, and the status line confirms what the key did. On the handhelds, Lock (or Unlock) rides Shift+F2 in a channel and a DM. A room keeps Log in there, so the Author page that a conversation opens now carries the lock row, and no other management verb. Co-Authored-By: Claude Haiku 5.5 --- CLAUDE.md | 10 +- meshterm/ui/chat.py | 124 +++++++++++++++++++++--- meshterm/ui/node_detail_screen.py | 151 +++++++++++++++++++++--------- meshterm/ui/room.py | 2 +- meshterm/ui/tui/session.py | 4 + tests/conftest.py | 10 +- tests/test_chat.py | 118 +++++++++++++++++++++++ tests/test_node_detail.py | 61 ++++++++++++ tests/test_rooms.py | 22 +++++ 9 files changed, 442 insertions(+), 60 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 77eb80e..ff8b8b5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -292,7 +292,8 @@ deliberately, one at a time, and say why in the code. - **A bare-letter shortcut only where nothing is typed.** A screen that filters, finds or composes as you type has no free letters, so its verbs are Ctrl chords — one table, `session._CTRL_LETTER_CHORDS`, each letter the mnemonic of its action where it can be - (`^P` paths, `^U` URL, `^L` locate, `^O` open the author's page; `^G` logs in to a room, + (`^P` paths, `^U` URL, `^L` locate, `^O` open the author's page, `^K` lock the author — + JP, 2026-10-19; `^G` logs in to a room, JP's pick, 2026-10-11). One verb per letter across screens is the preference, not the rule (JP, 2026-10-10): a screen that has no use for a letter's verb may give the letter a verb of its own, so long as nothing between the keyboard and the app takes it — @@ -853,7 +854,12 @@ branch on the platform per frame, and never `from meshterm.platforms import PLAT on Shift+F2. F4/F5 stay the pager, with `Latest`/`Oldest` behind Shift. A verb new to one of them goes on the Shift bank; one all three gain goes on the plain bank — and once that is full, on Shift+F3: `Author` (^O, the author's node page, `ContactPages`) rides - there behind `Map`, its twin about the same author. + there behind `Map`, its twin about the same author. `Lock` (^K, a toggle that reads + `Unlock` on a locked author) is the one exception, by JP's choice (2026-10-19): it sits on + Shift+F2 where that is free (channel, DM), and in a room, where `Log in` holds it, the + `Author` page carries it — a conversation opens that page with its lock row and no other + management verb. Its hint atom goes in only where the hint has room without dropping + another atom. - **Each handheld deals its own lane.** Everything about the lane that belongs to one keyboard is a `LaneDeck` (`fkeys.PICOCALC_LYRA_DECK`, `fkeys.CARDPUTER_ZERO_DECK`), named by `Platform.lane_deck`: which keycodes drive the slots, where the chips sit on the row, how diff --git a/meshterm/ui/chat.py b/meshterm/ui/chat.py index d31133a..5d178fa 100644 --- a/meshterm/ui/chat.py +++ b/meshterm/ui/chat.py @@ -113,6 +113,17 @@ def _fit_hint(atoms: list[str], spare: tuple[str, ...] = ()) -> str: return " · ".join(kept) +#: The long words of a hint atom, and the short words that the same atom has when the hint +#: needs the room. The two forms count as one atom. The short form leaves out a note or a +#: second key, as the hint already does for ^U, ^L, and ^O. +_SHORT_ATOMS = {"Enter reply (@mention)": "Enter reply", "^End/Esc cancel": "Esc cancel"} + + +def _atom_set(hint: str) -> set[str]: + """The atoms of a hint, each in its short form, to compare two hints by their atoms.""" + return {_SHORT_ATOMS.get(atom, atom) for atom in hint.split(" · ")} + + class ChatScreen(Screen): """A live conversation: a transcript that scrolls, above a pinned input line. @@ -125,8 +136,8 @@ class ChatScreen(Screen): of the selected message. A path belongs to one message, so when no message is selected, there is nothing to show. ^U shows the links of the selected message as QR codes, on the share screen. ^L opens the map on the node of the author of the selected - message, when that node has a location. When no message is selected, Esc leaves the - chat. + message, when that node has a location. ^K locks that author against the archive + sweep, or unlocks it. When no message is selected, Esc leaves the chat. A channel with a send scope shows its region in the title (``#ops · yul``). There, ^R sends a message again *unscoped*: the selected message, or the newest when no message is @@ -166,6 +177,13 @@ class ChatScreen(Screen): page (refer to :meth:`_author_page`). Each of the two halves of F3 is empty when the screen has nothing to find that node with. + ``Lock`` (^K) is the fifth verb about the author, and it is on the Shift half of F2. + It toggles the lock of the author, so it reads ``Unlock`` on a locked author, and + it is lit while the author can have a lock (refer to :meth:`_lock_target`). That + slot is free in a channel and in a DM. In a room, ``Log in`` has it, so there the + user locks from the ``Author`` page, which a conversation opens with its lock row + (JP, 2026-10-19). The slot is empty where MeshTerm cannot store a lock. + The Shift half of F1 follows the two rules of the lane. In a channel with no resend connected, it is empty, because a channel message is never acknowledged and there is nothing to retry. In a DM with nothing outstanding, it is only dim. @@ -205,7 +223,18 @@ class ChatScreen(Screen): enabled=self._selected is not None and self._paths is not None, opp_enabled=self._retry_target() is not None, ) - lane[1] = FPair("URL→QR", "url_code", enabled=bool(self._picked_urls())) + lock = self._lock_target() + if self._pages is not None and self._pages.can_lock: + toggle = ("Unlock" if lock is not None and lock[1] else "Lock", "lock") + else: + toggle = ("", "") + lane[1] = FPair( + "URL→QR", + "url_code", + *toggle, + enabled=bool(self._picked_urls()), + opp_enabled=lock is not None, + ) if self._locator is not None or self._pages is not None: where = ("Map", "locate") if self._locator is not None else ("", "") who = ("Author", "author_page") if self._pages is not None else ("", "") @@ -362,7 +391,29 @@ class ChatScreen(Screen): selected message whose author has a node page (refer to :meth:`_author_page`). ``^R retry failed`` must have something to retry (refer to :meth:`_retry_target`). The F-key lane uses the same rules to dim its slots. + + ``^K lock`` (or ``^K unlock``) must have a selected message whose author can have a + lock (refer to :meth:`_lock_target`). It goes in only when there is room for it + (JP, 2026-10-19: "If there's room, put it."): the hint with it must stay in 72 cells + and keep each atom of the hint without it. It can make the words of an atom shorter + (:data:`_SHORT_ATOMS`), as ^U, ^L, and ^O already do. When it does not go in, the + status line still tells what the key did. """ + hint = self._hint_with("") + lock = self._lock_target() + if lock is not None: + atom = "^K unlock" if lock[1] else "^K lock" + longer = self._hint_with(atom) + if ( + cell_len(longer) <= _HINT_CELLS + and atom in longer.split(" · ") + and _atom_set(hint) <= _atom_set(longer) + ): + return longer + return hint + + def _hint_with(self, lock: str) -> str: + """The key hint, with the ``lock`` atom after ``^O author`` (or no atom for ``""``).""" if self._selected is not None: code = "^U QR" if self._picked_urls() else "" where = "^L map" if self._locate_target() is not None else "" @@ -381,6 +432,7 @@ class ChatScreen(Screen): code, where, who, + lock, "^R resend unscoped", "↑↓ pick", "Esc cancel", @@ -397,6 +449,7 @@ class ChatScreen(Screen): code, where, who, + lock, "↑↓ pick", "^End/Esc cancel", ], @@ -405,11 +458,11 @@ class ChatScreen(Screen): if cell_len(hint) <= _HINT_CELLS: return hint return _fit_hint( - ["Enter reply", "^P paths", code, where, who, "↑↓ pick", "Esc cancel"], + ["Enter reply", "^P paths", code, where, who, lock, "↑↓ pick", "Esc cancel"], spare=("↑↓ pick",), ) return _fit_hint( - ["Enter paths", code, where, who, "↑↓ pick", "^End/Esc cancel"], + ["Enter paths", code, where, who, lock, "↑↓ pick", "^End/Esc cancel"], spare=("↑↓ pick",), ) if self._retry_target() is not None: @@ -938,10 +991,10 @@ class ChatScreen(Screen): Ctrl+PgUp/PgDn (day dividers), and the viewport moves with it. Enter on a selected message starts a reply with an ``@mention`` in a channel, and opens the delivery paths in a direct chat. In the two types of chat, ^P opens the paths of the - selected message, ^U the QR codes of its links, and ^L the map on its author. The - three do nothing when no message is selected. ^End (or a move past the newest - message) goes back to the compose line. The first Esc clears the selection, and the - second Esc leaves the screen. + selected message, ^U the QR codes of its links, and ^L the map on its author. ^K + locks or unlocks its author. These keys do nothing when no message is selected. + ^End (or a move past the newest message) goes back to the compose line. The first + Esc clears the selection, and the second Esc leaves the screen. """ if action == "enter": if self._selected is not None: @@ -961,6 +1014,8 @@ class ChatScreen(Screen): self._open_map() elif action == "author_page": self._open_author_page() + elif action == "lock": + self._toggle_lock() elif action == "retry": if self._is_channel: self._begin_resend_unscoped() @@ -1073,11 +1128,56 @@ class ChatScreen(Screen): message = self._messages[max(0, min(self._selected, len(self._messages) - 1))] if message.outbound: return lambda: pages.show(None) + contact = self._author_contact() + return None if contact is None else (lambda: pages.show(contact)) + + def _author_contact(self) -> Contact | None: + """The contact that wrote the selected message, or ``None``. + + The key of the author finds it (refer to :meth:`_header_key`, the same key that + gives the colour of the sender chip). A message of ours, a message with no sender, + and an author that is not a contact have none. + """ + pages = self._pages + if pages is None or self._selected is None or not self._messages: + return None + message = self._messages[max(0, min(self._selected, len(self._messages) - 1))] + if message.outbound: + return None sender, _body = self._sender_and_body(message) if sender == "·": return None - contact = pages.find(self._header_key(sender, message)) - return None if contact is None else (lambda: pages.show(contact)) + return pages.find(self._header_key(sender, message)) + + def _lock_target(self) -> tuple[Contact, bool] | None: + """The author that ^K locks or unlocks, and whether it is locked now. + + ``None`` when ^K does nothing: no selected message, a message of ours, an author + that is not a contact, or a contact that cannot have a lock (refer to + :meth:`~meshterm.ui.node_detail_screen.ContactPages.lock_state`). The hint and the + F-key lane ask at each paint, and the store answers from memory. + """ + contact = self._author_contact() + if contact is None or self._pages is None: + return None + locked = self._pages.lock_state(contact) + return None if locked is None else (contact, locked) + + def _toggle_lock(self) -> None: + """Lock the author of the selected message, or unlock it when it is locked (^K). + + There is no confirm, because the next ^K reverses it, as the row on the node page + does. The hint atom and the lane chip change to their opposite. The status line also + tells what the key did, because the hint has no room for the atom on each message. + """ + target = self._lock_target() + if target is None or self._pages is None: + return + contact, locked = target + self._pages.set_locked(contact, not locked) + name = contact.name or contact.key_prefix + self._status = f"Unlocked {name}" if locked else f"Locked {name} — no sweep archives it" + self._session.invalidate() def _open_author_page(self) -> None: """Open the node page of the author of the selected message (^O). One page at a time. @@ -1617,7 +1717,7 @@ async def open_chat(ctx: AppContext, conversation: Conversation) -> int: scope=scope, resend_unscoped=resend_unscoped, locator=await Locator.gather(ctx), - pages=await ContactPages.gather(ctx), + pages=await ContactPages.gather(ctx, self_key=self_key or ""), self_name=self_name, self_key=self_key, ) diff --git a/meshterm/ui/node_detail_screen.py b/meshterm/ui/node_detail_screen.py index 8f57666..b016182 100644 --- a/meshterm/ui/node_detail_screen.py +++ b/meshterm/ui/node_detail_screen.py @@ -43,7 +43,8 @@ screen is the node itself, in full: where the sweep archives. After a red confirm, it removes this node from the contact table of the device and from the contacts that MeshTerm remembers for the device. Then the page closes, because the contact that the page shows does not exist any - more. An archive and a restore also close the page. A lock does not. + more. An archive and a restore also close the page. A lock does not. A page that a + conversation opens has only the lock row of this group. * **Routes**: the routes on which the device really heard the node arrive. The shared route graph (:mod:`~meshterm.ui.pathgraph`) draws them from the node to our node, in the inbound direction of the packets: the contact on the left, our node on the right. @@ -1252,29 +1253,38 @@ class ContactPages: twin of :class:`~meshterm.ui.map_screen.Locator`, which ^L uses for the map. The contacts come from the device one time, when the screen opens. + + It is also the ^K key (``lock``) of a conversation: the lock of the author, which + :meth:`lock_state` reads and :meth:`set_locked` writes. The store keeps a lock under + the key of the device, so the pages hold that key too. The state is read from the store + at each paint and never kept here, because the node page can change it too. """ - def __init__(self, ctx: AppContext, contacts: list[Contact]) -> None: + def __init__(self, ctx: AppContext, contacts: list[Contact], *, self_key: str = "") -> None: """Hold the contacts that :meth:`find` searches. Args: ctx: The shared application context (it must be in the interactive menu). contacts: The contacts of the device. + self_key: The public key of the device (hex). The store keeps the locks under + it. With ``""``, no contact can be locked from here. """ self._ctx = ctx self._contacts = contacts + self._self_key = self_key + self._dev_pub = self_key.lower().removeprefix("0x") # The result of each key that find() got. A screen asks at each paint, and the # answer for one key does not change while the contacts stay the same. self._found: dict[str, Contact | None] = {} @classmethod - async def gather(cls, ctx: AppContext) -> ContactPages: + async def gather(cls, ctx: AppContext, *, self_key: str = "") -> ContactPages: """The pages of the contacts that the device has now. With no contacts, nothing is found.""" try: contacts = await ctx.devstate.contacts() except Exception: # noqa: BLE001 - with no contacts, ^O opens only our own page contacts = [] - return cls(ctx, list(contacts)) + return cls(ctx, list(contacts), self_key=self_key) def find(self, key: str | None) -> Contact | None: """The contact with ``key`` (a full key or a key prefix), or ``None``. @@ -1294,14 +1304,39 @@ class ContactPages: ) return self._found[needle] + @property + def can_lock(self) -> bool: + """Whether a lock can be written from here at all: a store, and the key of the device.""" + return getattr(self._ctx, "contact_store", None) is not None and bool(self._dev_pub) + + def lock_state(self, contact: Contact | None) -> bool | None: + """Whether ``contact`` is locked, or ``None`` when it cannot have a lock. + + A lock needs the full key of a live contact, as on the node page (refer to + :meth:`~meshterm.core.contact_store.ContactStore.set_locked`). An archived contact + has no lock, because the lock exists to keep a contact out of the archive. + """ + if contact is None or not self.can_lock: + return None + key = (contact.public_key or "").lower().removeprefix("0x") + if len(key) != 64 or _is_archived(self._ctx, contact, self._self_key): + return None + return self._ctx.contact_store.is_locked(self._dev_pub, key) + + def set_locked(self, contact: Contact, locked: bool) -> None: + """Lock or unlock ``contact``. The caller asked :meth:`lock_state` first.""" + if self.can_lock: + self._ctx.contact_store.set_locked(self._dev_pub, contact, locked) + async def show(self, contact: Contact | None) -> None: """Open the node page of ``contact``, or of our node when it is ``None``. - The page opens to look, not to manage (``manage=False``). The management verbs - (lock, archive, restore, delete) belong to the list that the contact is in, and a - conversation is not that list. + The page opens to look, not to manage (``manage=False``). The verbs that remove a + contact (archive, restore, delete) belong to the list that the contact is in, and a + conversation is not that list. The lock is the exception (``lock=True``): it removes + nothing, and a conversation can already toggle it with ^K (JP, 2026-10-19). """ - await open_node_detail(self._ctx, contact, manage=False) + await open_node_detail(self._ctx, contact, manage=False, lock=True) def _same_key(contact_key: str | None, needle: str) -> bool: @@ -1311,7 +1346,7 @@ def _same_key(contact_key: str | None, needle: str) -> bool: async def open_node_detail( - ctx: AppContext, contact: Contact | None, *, manage: bool = True + ctx: AppContext, contact: Contact | None, *, manage: bool = True, lock: bool | None = None ) -> bool: """Open the Node detail page for a contact (or our node) and run its action loop. @@ -1343,6 +1378,11 @@ async def open_node_detail( is really in. They never belong to a page opened from a list that is about to act on all its rows together. + With ``lock``, a caller that does not manage still lets the page lock and unlock. A + conversation gives ``True`` (refer to :class:`ContactPages`): the lock removes nothing, + and the page that a chat opens is then the way to lock in a room, where the F-key lane + has no free chip for it. + Args: ctx: The shared application context (the interactive TUI must be running). contact: The contact to show, or ``None`` for our node (a page of identity and @@ -1351,6 +1391,8 @@ async def open_node_detail( manage: Whether to show the contact-management actions (lock, archive, restore, delete). ``False`` draws none of them, and then the page can only return ``False``. + lock: Whether to show ``Lock contact`` or ``Unlock contact``. ``None`` follows + ``manage``. Returns: ``True`` if the visit ended because the user archived, restored, or deleted the @@ -1594,48 +1636,32 @@ async def open_node_detail( info_actions.append(_Action("regions", "🔖", "", "Ask which regions it carries")) # -- contact management: the last group of the page, and the only actions that end the # visit. None of them shows when the caller opened the page to look, not to act (refer - # to ``manage``). They never show for our node, or for a contact with no key at - # all, because the write has nothing to use as its address. - archived = manage and _is_archived(ctx, contact, self_key) - manageable = ( - manage - and not you - and contact is not None - and bool(contact.public_key or contact.key_prefix) - ) + # to ``manage``), except the lock when the caller lets the page lock (refer to + # ``lock``). They never show for our node, or for a contact with no key at all, + # because the write has nothing to use as its address. + may_lock = manage if lock is None else lock + archived = (manage or may_lock) and _is_archived(ctx, contact, self_key) + keyed = not you and contact is not None and bool(contact.public_key or contact.key_prefix) + manageable = manage and keyed store = getattr(ctx, "contact_store", None) dev_pub = self_key.lower().removeprefix("0x") whole_key = contact is not None and len(contact.public_key.lower().removeprefix("0x")) == 64 # A lock is a mark of MeshTerm itself, attached to the full key in the contact store, and # only a live contact can have one. Its purpose is to keep a contact out of the archive. - lockable = manageable and not archived and whole_key and store is not None and bool(dev_pub) + lockable = ( + may_lock and keyed and not archived and whole_key and store is not None and bool(dev_pub) + ) base_actions = list(info_actions) def management(locked: bool) -> list[_Action]: """The action rows of the page, with the management group for this lock state last.""" - rows = list(base_actions) - if not manageable: - return rows - # The lock is first. It changes nothing on the device and ends nothing, so it is - # the safest row of the group for the highlight. Then the constructive verb, then - # the destructive verb. Thus a key press by mistake most probably goes to the row - # that the user can undo. Archive and restore are the two halves of one move that - # the user can reverse. They use the ``💾``/``📂`` pair that the lexicon already - # gives to "put away" and "bring back". Only a contact with a full key can be - # written back to the device. Thus a contact with only a key prefix never gets the - # archive, because the archive leaves it with no way back. A locked contact - # never gets the archive at all, and that is the purpose of the lock. - if lockable: - if locked: - rows.append(_Action("unlock", "🔓", "", "Unlock contact")) - else: - rows.append(_Action("lock", "🔒", "", "Lock contact")) - if archived: - rows.append(_Action("restore", "📂", "ok", "Restore to device")) - elif whole_key and not locked: - rows.append(_Action("archive", "💾", "", "Archive contact")) - rows.append(_Action("remove", "🗑", "err", "Delete contact…")) - return rows + return base_actions + _management_rows( + locked=locked, + lockable=lockable, + manageable=manageable, + archived=archived, + whole_key=whole_key, + ) locked = bool(lockable and store.is_locked(dev_pub, contact.public_key)) info_actions = management(locked) @@ -1747,6 +1773,47 @@ async def open_node_detail( return contact_removed +def _management_rows( + *, locked: bool, lockable: bool, manageable: bool, archived: bool, whole_key: bool +) -> list[_Action]: + """The contact-management group of the node page, for one lock state. + + The lock is first. It changes nothing on the device and ends nothing, so it is the + safest row of the group for the highlight. Then the constructive verb, then the + destructive verb. Thus a key press by mistake most probably goes to the row that the + user can undo. Archive and restore are the two halves of one move that the user can + reverse. They use the ``💾``/``📂`` pair that the lexicon already gives to "put away" + and "bring back". Only a contact with a full key can be written back to the device. + Thus a contact with only a key prefix never gets the archive, because the archive + leaves it with no way back. A locked contact never gets the archive at all, and that + is the purpose of the lock. + + A page that may lock but not manage (a page that a conversation opens) has only the + lock row. + + Args: + locked: Whether the contact is locked now. + lockable: Whether the page shows the lock row. + manageable: Whether the page shows archive, restore, and delete. + archived: Whether the contact is archived. Then restore takes the place of archive. + whole_key: Whether the full key of the contact is known, which an archive must have. + """ + rows: list[_Action] = [] + if lockable: + if locked: + rows.append(_Action("unlock", "🔓", "", "Unlock contact")) + else: + rows.append(_Action("lock", "🔒", "", "Lock contact")) + if not manageable: + return rows + if archived: + rows.append(_Action("restore", "📂", "ok", "Restore to device")) + elif whole_key and not locked: + rows.append(_Action("archive", "💾", "", "Archive contact")) + rows.append(_Action("remove", "🗑", "err", "Delete contact…")) + return rows + + def _is_archived(ctx: AppContext, contact: Contact | None, self_key: str) -> bool: """Whether the sweep removed this contact from the device, and MeshTerm keeps it. diff --git a/meshterm/ui/room.py b/meshterm/ui/room.py index bd7411b..8193946 100644 --- a/meshterm/ui/room.py +++ b/meshterm/ui/room.py @@ -454,7 +454,7 @@ async def open_room(ctx: AppContext, conversation: Conversation) -> int: paths=await _make_paths_presenter(ctx, conversation, device), key_of=trace_runner.make_name_key_resolver(contacts, stored_names, heard=named_at), locator=await Locator.gather(ctx), - pages=ContactPages(ctx, list(contacts)), + pages=ContactPages(ctx, list(contacts), self_key=self_key or ""), self_name=self_name, self_key=self_key, ) diff --git a/meshterm/ui/tui/session.py b/meshterm/ui/tui/session.py index 0e48a6d..3b77e3e 100644 --- a/meshterm/ui/tui/session.py +++ b/meshterm/ui/tui/session.py @@ -105,6 +105,10 @@ _CTRL_LETTER_CHORDS: dict[str, str] = { # ^G logs in to the room that a room screen shows. In the input, ^G is not a special key: # it is the bell only in output, and no terminal or multiplexer takes it. "g": "login", + # ^K locks or unlocks the author of the selected message in a conversation (JP, + # 2026-10-19). No terminal or multiplexer takes it, and the raw mode of prompt_toolkit + # has no binding for it. + "k": "lock", "l": "locate", # ^O opens the node page of the author of the selected message. The raw mode of # prompt_toolkit clears IEXTEN, so a terminal on macOS or BSD does not take ^O to diff --git a/tests/conftest.py b/tests/conftest.py index 978a446..c749d65 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -69,19 +69,23 @@ def recording_locator(markers: list) -> object: return locator -def recording_pages(contacts: list) -> object: +def recording_pages(contacts: list, *, store: object = None, self_key: str = "") -> object: """A real :class:`~meshterm.ui.node_detail_screen.ContactPages` whose pages are a list. It finds contacts with the rule of the app. Instead of a node page, :meth:`show` appends - the contact that the page would open on to ``shown`` (``None`` is our node). + the contact that the page would open on to ``shown`` (``None`` is our node). With a + contact ``store`` and the key of the device, ^K can lock the contacts too. """ + from types import SimpleNamespace + from meshterm.ui.node_detail_screen import ContactPages class _Recording(ContactPages): async def show(self, contact) -> None: # noqa: ANN001 - a Contact, or None for us self.shown.append(contact) - pages = _Recording(None, contacts) # type: ignore[arg-type] + ctx = None if store is None else SimpleNamespace(contact_store=store) + pages = _Recording(ctx, contacts, self_key=self_key) # type: ignore[arg-type] pages.shown = [] # type: ignore[attr-defined] return pages diff --git a/tests/test_chat.py b/tests/test_chat.py index d1b5a11..129146e 100644 --- a/tests/test_chat.py +++ b/tests/test_chat.py @@ -3147,6 +3147,124 @@ def test_a_direct_chat_finds_the_page_of_its_peer() -> None: assert cell_len(screen.footer_hint) <= 72 +def test_k_locks_and_unlocks_the_author_of_the_selected_message(tmp_path: Path) -> None: + """^K toggles the lock of the author of the selected message (JP, 2026-10-19). + + It is the third verb about the author, with ^L and ^O, and it finds the same contact. + The hint and the Shift half of F2 say ``lock`` or ``unlock``, so they also show the + state. The status line tells what the key did. Our own message, and an author that is + not a contact, have nothing to lock. + """ + from meshterm.core.contact_store import ContactStore + from meshterm.ui.tui.session import _CTRL_LETTER_CHORDS + from tests.conftest import recording_pages + + assert _CTRL_LETTER_CHORDS["k"] == "lock" + store = ContactStore(tmp_path / "contacts.json") + dev = "cc" * 32 + alice = Contact(name="Alice", public_key="a1" * 32, key_prefix="a1" * 6) + keys = {"alice": "a1" * 32, "bob": "b2" * 32} + screen = ChatScreen( + Conversation(label="#public", is_channel=True, channel_idx=0), + [ + ChatMessage(text="Alice: hi", is_channel=True, channel_idx=0), + ChatMessage(text="Bob: yo", is_channel=True, channel_idx=0), + ChatMessage(text="hello", outbound=True, is_channel=True, channel_idx=0), + ], + send=None, + names={}, + session=_StubSession(), + key_of=lambda name: keys.get(name.casefold()), + pages=recording_pages([alice], store=store, self_key=dev), # type: ignore[arg-type] + ) + # With no selected message, ^K does nothing, and its chip is dim. + lane = screen.picocalc_lyra_lane + assert (lane[1].opp_label, lane[1].opp_action, lane[1].opp_enabled) == ("Lock", "lock", False) + screen.handle("lock") + assert store.locked_keys(dev) == frozenset() + + # Our own message, then Bob, who is not a contact: nothing to lock. + for _ in range(2): + screen.handle("up") + assert "^K" not in screen.footer_hint + assert screen.picocalc_lyra_lane[1].opp_enabled is False + + # Alice is a contact. Shift+F2 sends the same action as ^K. + screen.handle("up") + hint = screen.footer_hint + assert "^K lock" in hint and "^O author" in hint and "↑↓ pick" in hint + assert cell_len(hint) <= 72 + lane = screen.picocalc_lyra_lane + assert lane[1].opp_enabled is True + assert fkeys.PICOCALC_LYRA_DECK.action_for(lane, 7) == "lock" + screen.handle("lock") + assert store.is_locked(dev, "a1" * 32) + assert "Locked Alice" in screen._status + assert "^K unlock" in screen.footer_hint + assert screen.picocalc_lyra_lane[1].opp_label == "Unlock" + + # The same key reverses it. + screen.handle("lock") + assert not store.is_locked(dev, "a1" * 32) + assert screen._status == "Unlocked Alice" + assert "^K lock" in screen.footer_hint + + +def test_the_lock_atom_never_pushes_another_key_out_of_the_hint(tmp_path: Path) -> None: + """With a link and a location, the hint has no room for ^K, so the atom stays out. + + The other keys stay as they are without it (JP, 2026-10-19: "If there's room, put + it."). The chip on the lane and the key itself still work. + """ + from meshterm.core.contact_store import ContactStore + from tests.conftest import recording_locator, recording_pages + + store = ContactStore(tmp_path / "contacts.json") + dev = "cc" * 32 + alice = Contact(name="Alice", public_key="a1" * 32, key_prefix="a1" * 6) + + def build(pages: object) -> ChatScreen: + screen = ChatScreen( + Conversation(label="#public", is_channel=True, channel_idx=0), + [ChatMessage(text="Alice: see https://meshterm.net", is_channel=True, channel_idx=0)], + send=None, + names={}, + session=_StubSession(), + key_of=lambda name: "a1" * 32 if name == "Alice" else None, + locator=recording_locator([_ALICE_AT]), # type: ignore[arg-type] + pages=pages, # type: ignore[arg-type] + ) + screen.handle("up") + return screen + + without = build(recording_pages([alice])).footer_hint + screen = build(recording_pages([alice], store=store, self_key=dev)) + assert "^K" not in screen.footer_hint + assert screen.footer_hint == without + assert screen.picocalc_lyra_lane[1].opp_enabled is True + screen.handle("lock") + assert store.is_locked(dev, "a1" * 32) + + +def test_a_chat_without_a_lock_store_has_no_lock_chip() -> None: + """Where MeshTerm cannot store a lock, the Shift half of F2 is empty, not dim.""" + from tests.conftest import recording_pages + + alice = Contact(name="Alice", public_key="a1" * 32, key_prefix="a1" * 6) + screen = ChatScreen( + Conversation(label="#public", is_channel=True, channel_idx=0), + [ChatMessage(text="Alice: hi", is_channel=True, channel_idx=0)], + send=None, + names={}, + session=_StubSession(), + key_of=lambda name: "a1" * 32, + pages=recording_pages([alice]), # type: ignore[arg-type] + ) + screen.handle("up") + assert screen.picocalc_lyra_lane[1].opp_label == "" + assert "^K" not in screen.footer_hint + + def test_chat_hint_with_all_its_keys_fits_in_72_cells() -> None: """The longest hint (a scoped message of ours, with a link, on the map) fits in 72 cells. diff --git a/tests/test_node_detail.py b/tests/test_node_detail.py index ebf92d6..838a167 100644 --- a/tests/test_node_detail.py +++ b/tests/test_node_detail.py @@ -1460,3 +1460,64 @@ def test_routes_legend_steps_aside_on_a_short_frame() -> None: assert "▲ repeater" in stage(22) # the body of the PicoCalc assert "▲ repeater" not in stage(11) # the body of the Cardputer + + +def test_a_page_that_may_only_lock_shows_only_the_lock_row() -> None: + """The management group of a page that a conversation opens is only Lock or Unlock. + + Archive, restore, and delete belong to the list that the contact is in. A lock removes + nothing, so a conversation can offer it (JP, 2026-10-19). A page that manages keeps its + full group, in the same order as before. + """ + from meshterm.ui.node_detail_screen import _management_rows + + def keys(**state: bool) -> list[str]: + base = {"locked": False, "lockable": True, "manageable": False, "archived": False} + return [row.key for row in _management_rows(**{**base, **state}, whole_key=True)] + + assert keys() == ["lock"] + assert keys(locked=True) == ["unlock"] + assert keys(lockable=False) == [] + assert keys(manageable=True) == ["lock", "archive", "remove"] + assert keys(manageable=True, locked=True) == ["unlock", "remove"] + assert keys(manageable=True, lockable=False, archived=True) == ["restore", "remove"] + + +async def test_a_conversation_opens_the_author_page_with_its_lock_row(monkeypatch) -> None: # noqa: ANN001 + """^O in a conversation opens the page to look, with the lock as its only verb.""" + import meshterm.ui.node_detail_screen as nd + + calls: list[tuple] = [] + + async def _fake(ctx, contact, *, manage=True, lock=None): # noqa: ANN001, ANN202 + calls.append((contact, manage, lock)) + return False + + monkeypatch.setattr(nd, "open_node_detail", _fake) + await nd.ContactPages(None, []).show(None) # type: ignore[arg-type] + assert calls == [(None, False, True)] + + +def test_contact_pages_read_and_write_the_lock_of_a_live_contact(tmp_path) -> None: # noqa: ANN001 + """The pages lock a contact with a full key, and nothing without a store or a device key.""" + from meshterm.core.contact_store import ContactStore + from meshterm.ui.node_detail_screen import ContactPages + + store = ContactStore(tmp_path / "contacts.json") + dev = "cc" * 32 + alice = Contact(name="Alice", public_key="a1" * 32, key_prefix="a1" * 6) + prefix_only = Contact(name="Bob", public_key="", key_prefix="b2" * 6) + pages = ContactPages(SimpleNamespace(contact_store=store), [alice], self_key=dev) # type: ignore[arg-type] + assert pages.can_lock + assert pages.lock_state(alice) is False + pages.set_locked(alice, True) + assert pages.lock_state(alice) is True and store.is_locked(dev, "a1" * 32) + assert pages.lock_state(prefix_only) is None + + # An archived contact has no lock: the lock exists to keep a contact out of the archive. + store.set_locked(dev, alice, False) + store.archive(dev, alice, when=1_700_000_000) + assert pages.lock_state(alice) is None + + assert ContactPages(SimpleNamespace(contact_store=store), [alice]).can_lock is False # type: ignore[arg-type] + assert ContactPages(None, [alice], self_key=dev).can_lock is False # type: ignore[arg-type] diff --git a/tests/test_rooms.py b/tests/test_rooms.py index 5c36345..f400cc8 100644 --- a/tests/test_rooms.py +++ b/tests/test_rooms.py @@ -924,6 +924,28 @@ async def test_o_opens_the_node_page_of_the_author_of_a_post() -> None: assert screen.picocalc_lyra_lane[2].opp_label == "Author" +def test_k_locks_the_author_of_a_post_and_the_lane_keeps_log_in(tmp_path) -> None: # noqa: ANN001 + """^K locks the contact that signed the selected post, as in a channel and a DM. + + In a room, Shift+F2 is ``Log in``, so the lane has no ``Lock`` chip there. On a + handheld, the user locks from the ``Author`` page, which a conversation opens with its + lock row (JP, 2026-10-19). + """ + from meshterm.core.contact_store import ContactStore + from tests.conftest import recording_pages + + store = ContactStore(tmp_path / "contacts.json") + dev = "cc" * 32 + alice = Contact(name="Alice", public_key=ALICE_KEY + "0" * 56, key_prefix=ALICE_KEY + "0000") + screen, _ = _view(_board(), pages=recording_pages([alice], store=store, self_key=dev)) + for _ in range(4): + screen.handle("up") # the oldest post, by Alice + screen.handle("lock") + assert store.is_locked(dev, alice.public_key) + lane = screen.picocalc_lyra_lane + assert (lane[1].opp_label, lane[1].opp_action) == ("Log in", "login") + + def test_the_login_key_is_g_and_o_opens_the_author_page() -> None: """^G logs in to a room, and ^O opens the page of the author of a message (JP, 2026-10-11).""" from meshterm.ui.tui.session import _CTRL_LETTER_CHORDS