^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 <noreply@anthropic.com>
This commit is contained in:
1 parent
60a2df727a
commit
daa26be5df
9 files changed
+442
-60
No files matched your search
@@ -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
|
||||
|
||||
+112
-12
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
+1
-1
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
+7
-3
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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]
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user