Files
rich 233dfc813c
CI / lint (push) Waiting to run
CI / test macos-latest py3.14 (push) Waiting to run
CI / test ubuntu-24.04 py3.10 (push) Waiting to run
CI / test ubuntu-latest py3.11 (push) Waiting to run
CI / test ubuntu-latest py3.12 (push) Waiting to run
CI / test ubuntu-latest py3.13 (push) Waiting to run
CI / test ubuntu-latest py3.14 (push) Waiting to run
CI / test windows-latest py3.10 (push) Waiting to run
CI / test windows-latest py3.14 (push) Waiting to run
CI / build (push) Waiting to run
Issue trailers / check (push) Waiting to run
Merge upstream MeshTerm 0.11.5
Bring in the 65 upstream commits since 0.11.1, with ^K lock and the self chip.
Both sides kept their features. React gives way to Lock in two places. On the
PicoCalc lane, Lock keeps the Shift half of F2 and React is ^E only. In the
hint, ^E react is the first atom to go when ^K lock has no room.

The long-message code now refills the compose line with MentionEditor, which
replaced LineEditor upstream.
2026-10-22 16:53:37 -04:00

2431 lines
114 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# SPDX-License-Identifier: Apache-2.0
"""The live chat screen and the function that opens it.
This is the interactive, full-screen chat: a transcript that scrolls, with an input line
pinned at the bottom. The messages that you send and the messages that come in over the
mesh show together in real time. This module is in the UI layer, the same as the device
picker and the config editor, but it can depend on the context and the services. It
connects the send path of :class:`~meshterm.services.chat_service.ChatService` and the
event hub, which always runs, to a :class:`~meshterm.ui.tui.screen.Screen`.
While the screen is open, it subscribes to the hub. Thus the inbound messages for the
current conversation are appended live. Separately,
:class:`~meshterm.services.chat_service.ChatService` stores each inbound message. Thus
the history is complete, also when the screen is not open.
"""
from __future__ import annotations
import asyncio
import re
import unicodedata
from collections.abc import Awaitable, Callable
from functools import lru_cache
from typing import TYPE_CHECKING, Any
from rich.cells import cell_len
from rich.console import Group, RenderableType
from rich.text import Text
from ..core import links, parts, reactions
from ..core.channels import MENTION, split_channel_sender
from ..core.connection import ContactNotOnDeviceError
from ..core.events import EventKind, MeshEvent
from ..core.models import ChatMessage, Contact, Conversation, Message, utcnow
from ..core.regions import WILDCARD
from ..platforms import Platform, on_platform
from .compose import MentionEditor
from .pathline import PATH_INK, PathHop
from .qr import QrScreen, qr_strip
from .theme import emoticon, name_style, snr_style
from .tui.prompt import (
CHANNEL_BYTE_LIMIT,
DM_BYTE_LIMIT,
byte_counter,
)
from .tui.render import render_hanging, render_lines, right_aligned_tail
from .tui.screen import CANCEL, Screen
from .tui.spinner import Spinner, spinner_interval
from .widgets import (
DESTINATION,
ORIGIN,
author_chip,
hop_chip,
mention_hop,
mention_mark,
picked_hop,
self_chip,
)
if TYPE_CHECKING:
from ..context import AppContext
from .map_render import MapMarker
from .map_screen import Locator
from .node_detail_screen import ContactPages
#: The delivery marks for a *resolved* outbound direct message, at the end of its line:
#: acknowledged, or transmitted but not acknowledged (^R can retry it). These are the
#: ``✓``/``✗`` status marks of the app in their ok/err styles, not the ✅/❌ emoji, which
#: are for the lane of packet-class icons. While the ack is still pending, the line shows an
#: animated spinner instead (refer to :meth:`ChatScreen._delivery_glyph`).
_DELIVERED = ("✓", "ok")
_FAILED = ("✗", "err")
#: A word between whitespace. The wrap moves a word only as a whole. Thus the run that a
#: wrap must not cut a URL in is the word that has the URL, with a bracket or a full stop
#: next to it.
_WORD = re.compile(r"\S+")
#: The column where a URL starts when it is too long to hang under its body: the column
#: after the ``❯`` that is in front of the first line of the selected message. A message
#: that names us has its mark in this column, and the timestamp follows it.
_URL_GUTTER = 2
#: Whether the URLs of a message are drawn as QR codes under it (``Platform.url_codes``,
#: only on the PicoCalc). Bound for each platform, never asked at each frame.
_URL_CODES = False
#: The mark in front of the count of a reaction. Where the font has no emoji, an emoticon
#: such as ``+1`` or ``<3`` stands in for the emoji, and a bare count after it reads as part
#: of it (``+1 3``). There, the count is ``x3``. Bound for each platform.
_COUNT_MARK = ""
@on_platform
def _bind(platform: Platform) -> None:
"""Bind the transcript to the platform (now and at each switch).
The platform says whether codes hang under URLs, and how a reaction count is marked.
"""
global _URL_CODES, _COUNT_MARK
_URL_CODES = platform.url_codes
_COUNT_MARK = "" if platform.emoji else "x"
#: The maximum width of a footer hint, in cells (refer to the UX standards).
_HINT_CELLS = 72
def _fit_hint(atoms: list[str], spare: tuple[str, ...] = ()) -> str:
"""Join the hint atoms that are not empty, and remove ``spare`` atoms until the hint fits.
The ``spare`` atoms go in the given order, and only while the hint is wider than
:data:`_HINT_CELLS`. Thus a key that the line can hold stays on it.
"""
kept = [atom for atom in atoms if atom]
for atom in spare:
if cell_len(" · ".join(kept)) <= _HINT_CELLS:
break
if atom in kept:
kept.remove(atom)
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.
The transcript automatically stays on the newest message (and goes back to the bottom
each time you type or send). ↑ selects a message. The selection moves with
↑↓/PgUp/PgDn, and the viewport moves with it. ^End (or Esc) puts the focus on the
compose line again. Enter sends the current line, or does an action on a selected
message: in a channel, it starts a reply with an ``@mention``, and in a direct chat, it
opens the delivery paths of the message. In the two types of chat, ^P opens the paths
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. ^K locks that author against the archive
sweep, or unlocks it. ^E reacts to the selected message with an emoji (refer to
:mod:`~meshterm.core.reactions`). When no message is selected, Esc leaves the chat.
A reaction is a message on the wire, but the transcript does not show it as one. It shows
each reaction under the message that it reacts to: the emoji and a count, or the names of
the nodes that reacted while that message is selected. A room has no reactions, so a
room shows each message as it arrived.
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
selected. This is the solution for a scoped message that no repeater in range relays.
It comes after an amber confirm, because an unscoped flood gets to each repeater that
the scope kept it from.
"""
floating = False
#: Home and End move the caret of the compose line here. Thus edge scroll does not use
#: them.
home_end_jumps = False
@property
def picocalc_lyra_lane(self):
"""The PicoCalc lane of a conversation: the same lane in a channel, a DM, and a room.
The plain bank of F1 to F3 has the verbs that all three screens share, in the order
of the hint: ``Paths`` (^P), ``URL→QR`` (^U), and ``Map`` (^L). The Shift bank of F1
and F2 has the verbs of one screen only. ``Retry`` (^R) is on the Shift half of F1 in
a DM and in a room, and ``Resend`` (the same ^R, unscoped) is there in a channel. A
room adds ``Log in`` (^G) on the Shift half of F2 (refer to
:class:`~meshterm.ui.room.RoomScreen`). Thus a key does the same thing on each of
the three screens, and the user finds a verb in the same place (JP, 2026-10-10).
The shared verbs act *on* the selected message. ``Paths`` is lit while a message is
selected. ``URL→QR`` shows the codes of the links of the selection on a share
screen, one code at a time with its full URL, and is lit while the selection has a
URL (refer to :meth:`_picked_urls`). On the Cardputer it is the only way to a code.
On the PicoCalc, the transcript already draws each code under its message in the
same size, so there the chip only shows one code alone. ``Map`` is lit while the
author of the selection has a location (refer to :meth:`_locate_target`).
``Author`` (^O, the node page of the author of the selection) is the fourth shared
verb, and the plain bank has only three slots. Thus it is on the Shift half of F3,
behind ``Map``, the other verb about the author. It is lit while the author has a
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.
``React`` (^E) has no slot. It was on the Shift half of F2 before ``Lock`` came, and
the Shift bank has no free slot now. ``Lock`` keeps the slot (Rich, 2026-10-22), so a
reaction is ^E only, and the hint names it when there is room.
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.
``Resend`` sends the selected message (or the newest) again, unscoped (refer to
:meth:`_retry_target`), and it is dim until that message is one of ours that went
out under a region.
The day jump (``ctrl_pageup``/``ctrl_pagedown``) has no chip. ``Day ↑`` and
``Day ↓`` were on F1 and F2, and JP removed them (2026-10-10). The desktop keeps
the jump on ^PgUp and ^PgDn.
F4/F5 keep the shared paging pair. The Shift bank of the shared lane sends the plain
``end``/``home`` actions. But here the cursor of the compose line already uses them
(refer to :meth:`handle`). Thus, without a change, the two keyboard keys go to the
same fallback ("clear the selection, stay at the tail"), and each one seems to
scroll to the bottom. Instead, the two Shift slots send ``ctrl_end``/``ctrl_home``,
and their labels say what these actions do to a conversation: go back to the
*latest* message and the compose line, or go back to the *oldest*. Each jump is
still behind the page key that goes in the same direction (refer to
:data:`~meshterm.ui.tui.fkeys.DEFAULT_LANE`). From the outside, the transcript moves
by one screen, also when it is the selection that moves.
"""
from .tui.fkeys import FPair, default_lane
lane = list(default_lane(nav=bool(self._messages)))
live = bool(self._messages)
if not self._is_channel:
again = ("Retry", "retry")
elif self._resend_unscoped is not None:
again = ("Resend", "retry")
else:
again = ("", "")
lane[0] = FPair(
"Paths",
"paths",
*again,
enabled=self._selected is not None and self._paths is not None,
opp_enabled=self._retry_target() is not None,
)
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 ("", "")
lane[2] = FPair(
*where,
*who,
enabled=self._locate_target() is not None,
opp_enabled=self._author_page() is not None,
)
lane[3] = FPair("Page ↓", "pagedown", "Latest", "ctrl_end", enabled=live, opp_enabled=live)
lane[4] = FPair("Page ↑", "pageup", "Oldest", "ctrl_home", enabled=live, opp_enabled=live)
return lane
def __init__(
self,
conversation: Conversation,
messages: list[ChatMessage],
*,
send: Callable[[str], Awaitable[ChatMessage | None]],
names: dict[str, str],
session, # noqa: ANN001 - a TuiSession, imported late to prevent an import cycle
resend: Callable[[ChatMessage], Awaitable[ChatMessage]] | None = None,
paths: Callable[[ChatMessage], Awaitable[None]] | None = None,
key_of: Callable[[str], str | None] | None = None,
scope: str | None = None,
resend_unscoped: Callable[[ChatMessage], Awaitable[ChatMessage | None]] | None = None,
locator: Locator | None = None,
pages: ContactPages | None = None,
self_name: str | None = None,
reaction_set: list[str] | None = None,
max_parts: int = 1,
part_gap_s: float = 0.0,
self_key: str | None = None,
) -> None:
"""Build the chat screen.
Args:
conversation: The thread that the screen shows (its label is the title of the
screen).
messages: The initial transcript (history), the oldest first.
send: An async callable that sends a line and returns the stored outbound
message (or ``None`` if nothing was sent).
names: A map from contact key prefix to friendly name, for the labels of
inbound direct messages.
session: The running :class:`~meshterm.ui.tui.session.TuiSession`. The screen
uses it to ask for a paint when messages arrive or a send completes.
resend: An async callable that tries again to deliver a direct message that
is not acknowledged, and changes the message in place (direct chats only,
``None`` for channels).
paths: An async callable that shows the delivery paths of the selected
message (the ^P view). ``None`` makes this action do nothing, with no
message.
key_of: Finds the key of the node of a sender from its display name (refer to
:func:`~meshterm.services.trace_runner.make_name_key_resolver`). The key
is the source of the hue of the sender. With ``None`` (or for a name that
it cannot find), the sender gets the grey of an unknown node
(``node.unknown``), because colour is only for identities that have a key.
scope: The send scope of the channel, shown in the title as a bare ``·`` atom
(``#ops · yul``: the title of a channel has no other atom that the user
can think is a region name). ``None`` for a channel that sends under the
default scope of the device, and always ``None`` for a direct chat.
resend_unscoped: An async callable that sends the text of a channel message
again, unscoped, and returns the stored message (channels only. With
``None``, ^R does nothing there).
locator: The located nodes, for ^L: the map on the author of the selected
message (refer to :class:`~meshterm.ui.map_screen.Locator`). With ``None``,
^L does nothing.
pages: The contacts, for ^O: the node page of the author of the selected
message (refer to :class:`~meshterm.ui.node_detail_screen.ContactPages`).
With ``None``, ^O does nothing.
self_name: The name of our node (refer to :func:`our_node`). Our messages and
the mentions of our name show it (:func:`~meshterm.ui.widgets.self_chip`).
A channel reaction names the author of its target, and our node is the
author of each message that we sent. With ``None``, our channel messages
get no reactions.
reaction_set: The emoji that ^E offers, in order. ``None`` offers
:data:`~meshterm.core.reactions.DEFAULT_REACTIONS`.
max_parts: The largest number of parts for a message that is too long for one
packet (refer to :mod:`meshterm.core.parts`). With 1, a message that is
too long is refused.
part_gap_s: The time to wait between two parts of a channel message, so that
the parts do not go out as a burst. A direct part waits for its
acknowledgement, and does not wait this time.
self_key: Our public key, which gives the hue of that chip.
"""
super().__init__()
self._self_name = self_name
self._self_key = self_key
self._scope = scope if conversation.is_channel else None
self.title = f"{conversation.label} · {self._scope}" if self._scope else conversation.label
self._is_channel = conversation.is_channel
# Many voices or one. The user reads a channel and a room in the same way: each
# message under its author, and Enter on a message starts a reply with an
# @mention. A direct chat has two persons, and Enter on a message opens its paths.
self._multiparty = conversation.is_channel or conversation.is_room
# Whether typing goes to the compose line. It is off where no typed text can be
# sent (a room that keeps nothing from us). Thus the keyboard keys never fill a
# line that the user cannot see.
self._composing = True
self._key_of: Callable[[str], str | None] = key_of or (lambda name: None)
# The one remote sender of a direct thread is the peer. Its key gives the colour of
# the header, also when the resolver cannot find the display name.
contact = conversation.contact
self._peer_key = (
""
if conversation.is_channel or contact is None
else (contact.public_key or contact.key_prefix or "")
)
# The reactions of the conversation, folded onto their targets. A room has none.
self._fold = (
None
if conversation.is_room
else reactions.ReactionFold(channel=conversation.is_channel, self_name=self_name)
)
self._reaction_set = reaction_set or reactions.DEFAULT_REACTIONS.split()
self._peer_label = conversation.label
self._messages = [m for m in messages if not self._folds(m)]
self._send = send
self._resend = resend
self._resend_unscoped = resend_unscoped if conversation.is_channel else None
self._max_parts = max(1, max_parts)
self._part_gap_s = max(0.0, part_gap_s)
self._paths = paths
self._locator = locator
self._pages = pages
self._names = names
self._session = session
self._editor = MentionEditor()
self._sending = False
# It turns while a direct message is in flight, so that the glyph at the end of the
# message spins (instead of a static hourglass) until the ack resolves. All messages
# share it: only one send or retry is ever in flight at a time (``_sending``
# controls the two).
self._spinner = Spinner()
self._status = ""
self._stick = True # keep the newest message visible until the user scrolls up
self._paths_open = False # one paths dialog at a time
self._map_open = False # one map at a time
self._page_open = False # one node page at a time
self._codes_open = False # one share screen of links at a time
self._paste_open = False # one paste-confirm dialog at a time
self._resend_open = False # one resend-unscoped confirm at a time
self._react_open = False # one reaction dialog at a time
# The selection: the index of the highlighted message (or None when the compose
# line has the focus), and the body line where it rendered, so that the frame keeps
# it visible.
self._selected: int | None = None
self._selected_line: int | None = None
# One line after the last rendered row of the selected message, so that its full
# block stays visible, not only its head (refer to render_body).
self._selected_end: int | None = None
# The cache of the output of _render_grouped for each message. Thus a paint that
# something outside the transcript causes (a key press, the 1 s idle tick, an
# animation step of a spinner) renders again only the rows that changed. Refer to
# _render_grouped.
self._render_cache: dict | None = None
# Strong references to the sends, resends, and spinner tickers that a key handler
# starts. The event loop holds only a *weak* reference. Thus the garbage collector
# can remove a task that nothing else names while it runs: the result is a message
# that is only half transmitted, or a spinner that stops. Each task removes itself
# from the set when it finishes.
self._tasks: set[asyncio.Task] = set()
def _spawn(self, coro) -> asyncio.Task: # noqa: ANN001 - any coroutine of this screen
"""Start ``coro`` on the loop and keep a reference to it until it finishes."""
task = asyncio.ensure_future(coro)
self._tasks.add(task)
task.add_done_callback(self._tasks.discard)
return task
@property
def footer_hint(self) -> str:
"""The key hint. It shows whether a message is selected and what Enter does to it.
Only the keys that do something show. ``^P paths`` must have a selected message to
show the paths *of*. ``^U QR`` must have a selected message with a URL in it
(refer to :meth:`_picked_urls`). ``^L map`` must have a selected message whose
author has a location (refer to :meth:`_locate_target`). ``^O author`` must have a
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)
# ^E react gives way to ^K, as React gives way to Lock on the Shift half of F2.
if (
cell_len(longer) <= _HINT_CELLS
and atom in longer.split(" · ")
and _atom_set(hint) - {"^E react"} <= _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 ""
who = "^O author" if self._author_page() is not None else ""
react = "^E react" if self._can_react() else ""
react_first = ("^E react",) if lock else ()
if self._is_channel and self._retry_target() is not None:
# A selected scoped message of ours can be sent again unscoped, and the hint
# must name that key. The "(@mention)" of the reply and ^End are removed to
# keep the line inside 72 cells (Esc still cancels the selection, and Enter
# still says what it does). If the line is still too long, ↑↓ goes first,
# then ^P, which the hint of each other selected message names, then ^O.
# The message is ours here, so ^O only opens our own page.
return _fit_hint(
[
"Enter reply",
"^P paths",
code,
where,
who,
lock,
react,
"^R resend unscoped",
"↑↓ pick",
"Esc cancel",
],
spare=(*react_first, "↑↓ pick", "^P paths", "^O author", "^E react"),
)
if self._replies:
# With all the keys of a selection, the long words do not fit in 72 cells.
# Then the hint uses the short words of the resend hint above.
hint = _fit_hint(
[
"Enter reply (@mention)",
"^P paths",
code,
where,
who,
lock,
react,
"↑↓ pick",
"^End/Esc cancel",
],
spare=("↑↓ pick",),
)
if cell_len(hint) <= _HINT_CELLS:
return hint
# ^E goes after ↑↓ when even the short words do not fit. The keys of the
# selection that were here before ^E keep their places.
return _fit_hint(
[
"Enter reply",
"^P paths",
code,
where,
who,
lock,
react,
"↑↓ pick",
"Esc cancel",
],
spare=(*react_first, "↑↓ pick", "^E react"),
)
return _fit_hint(
["Enter paths", code, where, who, lock, react, "↑↓ pick", "^End/Esc cancel"],
spare=(*react_first, "↑↓ pick", "^E react"),
)
if self._retry_target() is not None:
if self._is_channel:
return "Enter send · ↑ pick a message · ^R resend unscoped · Esc back"
return "Enter send · ↑ pick a message · ^R retry failed · Esc back"
return "Enter send · ↑ pick a message · Esc back"
# --- live updates --------------------------------------------------------
def append(self, message: ChatMessage) -> None:
"""Append an inbound message to the transcript and paint.
A reaction goes onto the message that it reacts to, and the transcript does not
move for it.
"""
if self._folds(message):
self._session.invalidate()
return
self._messages.append(message)
# Do not pull the viewport to the tail while the user selects a message to reply to.
if self._selected is None:
self._stick = True
self._session.invalidate()
# --- rendering -----------------------------------------------------------
def render_body(self, width: int) -> list[str]:
"""Render the transcript, a divider, the input line, and the status, if there is one."""
self._selected_line = None
self._selected_end = None
if not self._messages:
lines = render_lines(Text(self._empty_text, style="muted"), width)
else:
# Direct threads and channel threads use the same grouped transcript, in the
# style of Discord or Slack: consecutive messages from one sender share a
# coloured header, with day dividers between them. Because the render is for
# each message, we can also store where the selected message is (refer to
# _selected_line / cursor_line). The user can select in the two types of thread.
# The only difference is what Enter then does with the selection: a reply in a
# channel, or the delivery paths in a direct thread.
if self._selected is not None:
self._selected = max(0, min(self._selected, len(self._messages) - 1))
lines = self._render_grouped(width)
compose = self._compose_line(width)
footer_parts: list[RenderableType] = [
Text("─" * width, style="muted"),
compose,
]
if self._selected is not None:
footer_parts.append(self._pick_banner())
if self._status:
footer_parts.append(Text(self._status, style="muted"))
lines += render_lines(Group(*footer_parts), width)
# Stay at the bottom: give the frame an offset that is too large, so that it clamps
# the viewport to the last lines (input and latest messages). A scroll up clears
# the stick.
if self._stick:
self.scroll = len(lines)
elif self._selected_end is not None:
# The selection keeps its *full* message visible, with the codes under its text.
# The frame keeps only the one line that cursor_line names, the head of the
# message. With only that line, a selection that moves down stops the head on
# the bottom row, and the codes are below the viewport. Scroll far enough to
# show the last row of the message (one extra line for the day divider pinned
# at the top). The frame then moves the viewport up again only if that pushes
# the head off the top.
self.scroll = max(self.scroll, self._selected_end - self._scroll_viewport + 1)
return lines
def cursor_line(self) -> int | None:
"""Keep the selected reply target visible.
When nothing is selected, the scroll is free (the stick controls it).
"""
return self._selected_line
#: The text of an empty transcript.
_empty_text = "No messages yet — say hello!"
@property
def _replies(self) -> bool:
"""Whether Enter on a selected message replies to it (or else opens its paths).
This is true where there are many voices and an @mention says which one gets the
answer, and only while there is a compose line for the reply.
"""
return self._multiparty and self._composing
def _compose_line(self, width: int) -> RenderableType:
"""The compose line under the transcript: the input, with its byte limit.
The byte counter is pinned to the right edge of the *last* line of the input. Thus,
when the text wraps onto a second line, the counter stays in the bottom-right
corner and does not follow the cursor down the wrap. Only when the last line is
already full to the edge does the counter go onto its own right-aligned line.
Each complete mention in the line shows as its chip, the same chip as in the
transcript (refer to :class:`~meshterm.ui.compose.MentionEditor`). A chip never
breaks across two lines.
"""
limit = self._byte_limit()
input_line = self._editor.render_line(
self._mention_hop, overflow_at=self._overflow_at(limit)
)
whole = [(s.start, s.end) for s in input_line.spans if str(s.style) == PATH_INK]
return right_aligned_tail(input_line, self._byte_counter(limit), width, whole=whole)
# --- outgoing byte limit -------------------------------------------------
def _byte_limit(self) -> int:
"""The UTF-8 byte limit for a message in this conversation (channel or direct)."""
return CHANNEL_BYTE_LIMIT if self._is_channel else DM_BYTE_LIMIT
def _used_bytes(self) -> int:
"""The UTF-8 byte length of the current compose buffer: what counts against the limit."""
return len(self._editor.text.encode("utf-8"))
def _overflow_at(self, limit: int) -> int | None:
"""The index of the first compose character that cannot be sent, or ``None``.
With no parts, this is the first character with bytes past ``limit``. With parts,
it is the first character that does not fit in the last part (refer to
:func:`~meshterm.core.parts.overflow_at`). The count goes by character (not by
byte), so multibyte input stays complete: an emoji or an accented letter is fully
under or fully over the line, never divided in the middle of its sequence.
"""
return parts.overflow_at(self._editor.text, limit, self._max_parts)
def _parts(self, text: str) -> list[str] | None:
"""The messages that ``text`` goes out as, or ``None`` when it has too many parts."""
return parts.split(text, self._byte_limit(), self._max_parts)
def _byte_counter(self, limit: int) -> Text:
"""The inline ``used/limit`` counter: the shared compose gauge (:func:`byte_counter`).
When the text goes out as more than one part, the counter starts with the number
of parts, and the gauge shows the bytes of the last part, with its part number.
Thus the gauge tells how much the user can add before one more part starts.
"""
cut = self._parts(self._editor.text.strip())
if cut is None or len(cut) < 2:
return byte_counter(self._used_bytes(), limit)
counter = Text(f"{len(cut)} parts · ", style="muted")
counter.append_text(byte_counter(len(cut[-1].encode("utf-8")), limit))
return counter
def _name(self, peer: str | None) -> str | None:
"""Find the contact name for a sender key prefix (exact match, then prefix match)."""
if not peer:
return None
needle = peer.lower()
if needle in self._names:
return self._names[needle]
for prefix, name in self._names.items():
if prefix.startswith(needle) or needle.startswith(prefix):
return name
return None
# --- grouped rendering ---------------------------------------------------
def _render_grouped(self, width: int) -> list[str]:
"""Render the transcript to ANSI lines, and keep the row of the selected message.
This serves direct threads and channel threads. Consecutive messages from the same
sender on the same day share one coloured header, with each message body indented
below it. A divider marks each new day, and a blank line separates two different
sender groups. The render goes one message at a time (not as one Group). Thus we
can store the body line of the selected message in :attr:`_selected_line`, so that
the frame can scroll it into the viewport. The user can select in the two types of
thread (refer to :meth:`handle`).
Things that change nothing here cause a paint all the time: the idle tick, a key
press, the ack spinner. Thus this method caches the rendered lines of each message
in :attr:`_render_cache`, and for each message it puts in the cached slice instead
of a new render. A message renders again only when this is necessary:
* it still waits for its ack (the spinner draws its last glyph again at each
tick),
* it is the selected message (the selection sets its style, not the message), or
* its identity or ``acked`` is different from the cache (it was appended, it was
replaced in place when a pending bubble resolved, or it was removed), or
* its number of reactions is different from the cache (a reaction arrived).
A change of the locks draws all of the transcript again, because the sender chip of
a locked contact has a padlock (refer to :meth:`_group_header`). The locks change
rarely: ^K here, or the lock row of the node page of an author.
Most importantly, this works for each message. It is not a cached prefix that
stops at the first such row. Thus a message stays cheap to draw again, however far
back it is in a long transcript. With a prefix that stops, a page up through the
history causes a full render again at each key press. With this method, it does
not. The grouping context (day and sender headers) is cheap to calculate again in
all cases. Thus it is calculated new each time, and never taken from the cache.
"""
messages = self._messages
total = len(messages)
cache = self._render_cache
spacing = self.section_spacing()
locks = self._pages.locked_keys() if self._pages is not None else frozenset()
if (
cache is not None
and cache["width"] == width
and cache["spacing"] == spacing
and cache["locks"] == locks
):
cached_ids = cache["ids"]
cached_acked = cache["acked"]
cached_selected = cache["selected"]
cached_reacts = cache["reacts"]
cached_boundaries = cache["boundaries"]
cached_lines = cache["lines"]
cache_count = len(cached_ids)
else:
cached_ids = cached_acked = cached_selected = cached_reacts = cached_boundaries = ()
cached_lines = []
cache_count = 0
lines: list[str] = []
# Store each day divider as a sticky block of one row. Thus, when the divider of
# the top visible message scrolls off, it is pinned to the top row again. This is
# the same base Screen.sticky_block that the conversation picker uses for its
# section headings. A day has nothing to say except its date, so the block is only
# the divider. The heading of a select list can also have its description.
self._sticky_headers = []
ids: list[int] = []
ackeds: list[bool | None] = []
selecteds: list[bool] = []
reacts: list[int] = []
boundaries: list[int] = []
prev_group: tuple[bool, str] | None = None
prev_day = None
# The first message of the group of the selected message. Its slice has the author
# chip of the group, which is white while the selection is in the group.
picked_head = self._picked_head()
for idx in range(total):
message = messages[idx]
stamp = message.created_at.astimezone()
day = stamp.date()
sender, body = self._sender_and_body(message)
# Group by (are-we-the-sender, display name), not by the name alone. Thus our
# messages never join with those of a remote sender that has the same name.
group = (message.outbound, sender)
new_day = day != prev_day
selected = idx == self._selected
# A slice that the selection changes renders again, now and on the next paint
# after the selection leaves it: the selected message, and the head of its group.
marked = selected or idx == picked_head
pending = message.acked is None and message.outbound and not message.is_channel
react_count = self._fold.count(message) if self._fold is not None else 0
reusable = (
not marked
and not pending
and idx < cache_count
and not cached_selected[idx]
and cached_ids[idx] == id(message)
and cached_acked[idx] == message.acked
and cached_reacts[idx] == react_count
)
# A blank line closes the messages of an author and the messages of a day, where
# the preferences ask for it (:meth:`~meshterm.ui.tui.screen.Screen.section_spacing`,
# the same test as the section headings of a list). It is the first line of the
# slice of the message that starts the next group, so a cached slice brings it
# back with it.
gap = spacing and bool(lines) and (new_day or group != prev_group)
if reusable:
start = cached_boundaries[idx - 1] if idx > 0 else 0
slice_lines = cached_lines[start : cached_boundaries[idx]]
if new_day:
at = int(gap) # the divider comes after the gap
self._sticky_headers.append((len(lines) + at, [slice_lines[at]]))
lines += slice_lines
else:
if gap:
lines.append("")
if new_day:
# A day divider is the section heading of this transcript: it pins to the
# top row while its day scrolls under it, and ^PgUp/^PgDn step by it.
# Thus it has the heading style of the app (``── Label ──`` in the bold
# ``heading`` grey, refer to menus.section_heading). The rule above the
# compose line is plain ``muted``, because that rule is chrome.
divider = render_lines(
Text(f"── {stamp:%a} {stamp:%b} {stamp.day} ──", style="heading"), width
)
self._sticky_headers.append((len(lines), [divider[0]]))
lines += divider
if new_day or group != prev_group:
header = self._group_header(sender, message, picked=idx == picked_head)
lines += render_lines(header, width)
if selected:
# The first message has the head of the transcript: nothing comes before
# its day divider. Thus its selection anchors at line 0, not at its own
# body. When the anchor was on the body, the viewport at the very top
# was scrolled down two lines: the divider was only pinned, the sender
# chip was not visible, and the ↑ of the title bar was lit over a
# transcript with nothing above it.
self._selected_line = 0 if idx == 0 else len(lines)
lines += self._body_lines(body, message, width, selected=selected)
if selected:
self._selected_end = len(lines)
prev_group, prev_day = group, day
ids.append(id(message))
ackeds.append(message.acked)
selecteds.append(marked)
reacts.append(react_count)
boundaries.append(len(lines))
self._render_cache = {
"width": width,
"spacing": spacing,
"locks": locks,
"ids": ids,
"acked": ackeds,
"selected": selecteds,
"reacts": reacts,
"lines": lines,
"boundaries": boundaries,
}
return lines
def _sender_and_body(self, message: ChatMessage) -> tuple[str, str]:
"""Return the display sender and the clean body of a message.
Our messages are ``you``. An inbound channel message has a ``Name: `` prefix, which
we move into the sender (``·`` when there is no prefix). An inbound direct message
gets the sender from the resolved contact name (or the raw key, or ``?``).
"""
if message.outbound:
return "you", message.text
if message.is_channel:
name, body = split_channel_sender(message.text)
return (name or "·"), body
return (self._name(message.peer) or message.peer or "?"), message.text
def _picked_head(self) -> int | None:
"""The index of the first message of the group of the selected message, or ``None``.
A group is a run of messages from one sender on one day, under one author chip
(refer to :meth:`_render_grouped`). The chip is drawn with the first message of the
run, so that message is the one to draw again when the selection is in the run.
"""
selected, messages = self._selected, self._messages
if selected is None or not 0 <= selected < len(messages):
return None
def group_of(message: ChatMessage) -> tuple:
day = message.created_at.astimezone().date()
return (day, message.outbound, self._sender_and_body(message)[0])
group = group_of(messages[selected])
head = selected
while head > 0 and group_of(messages[head - 1]) == group:
head -= 1
return head
def _group_header(self, sender: str, message: ChatMessage, *, picked: bool = False) -> Text:
"""Build the sender header that starts a group: the name, drawn as a chip.
The chip makes a *label* different from the prose under it. A name alone above its
messages then reads as a tag, not as a coloured word that a person typed. It is the
chip of a path line (refer to :func:`~meshterm.ui.widgets.name_chip`). Thus a
sender looks exactly like that node as a hop in a route.
Our messages are the exception. They go under our name, as ``★ Name`` on a chip
in white (refer to :func:`~meshterm.ui.widgets.self_chip`). A route
shows our end as the bare ``★``, but a chat is a conversation between names.
Our chip comes from the direction of the message (``outbound``), not from the
``"you"`` label. Thus a remote sender with the name ``you`` still gets the hue of
its key, and does not look like our node.
The sender is the origin of the route of each message under it. Thus the chip
closes on the point, the mark of a route that goes on. An ``@mention`` in a body is
the other end, and it opens on the notch (refer to :meth:`_render_mentions`).
A sender that is a locked contact has the padlock before its name, in the place of
the ``★`` of our chip (JP, 2026-10-19). Thus the chip shows the state that ^K
toggles.
With ``picked``, the selected message is in this group, and the label of the chip
is white, as the text of that message is (refer to
:func:`~meshterm.ui.widgets.picked_hop`, JP, 2026-10-19). Our chip already has a
white label.
"""
if message.outbound:
return self_chip(self._self_name, self._self_key, role=ORIGIN)
# ``·`` is a channel line that arrived with no sender prefix: there is no key to use.
key = None if sender == "·" else self._header_key(sender, message)
pages = self._pages
locked = key is not None and pages is not None and pages.lock_state(pages.find(key)) is True
return author_chip(sender, key, locked=locked, picked=picked)
def _header_key(self, sender: str, message: ChatMessage) -> str | None:
"""The key that gives the colour of the sender chip of a group: the key of the name.
This is a hook, not a call, because a room knows more than a name. Each post has the
key prefix of its author, which gives the colour of the chip with no name lookup
(refer to :class:`~meshterm.ui.room.RoomScreen`).
"""
return self._sender_key(sender)
def _body_lines(
self, body: str, message: ChatMessage, width: int, *, selected: bool = False
) -> list[str]:
"""Render one message to ANSI lines, with its own time and a hanging indent.
The timestamp of each message is here (not on the group header), so that each
message shows the time when it was sent, also when one sender has many messages in
a group. Without this, a run of messages from the same sender seems to share one
time. A long body wraps with a hanging indent, so that the next lines align under
the body and not under the timestamp gutter. When ``selected`` is true, the line
is marked as the reply target (the same as the ``❯`` pointer and the highlight of
the select screen).
A message that mentions our name has a mark at the left of its time: the white
``★`` of our node (refer to :func:`~meshterm.ui.widgets.mention_mark`, JP,
2026-10-13). The chip of the mention in the body has our white label, but no star
(refer to :func:`~meshterm.ui.widgets.mention_hop`). Thus the mark of a message that
names us is in one column, where the eye finds it when it goes down the transcript.
Each time has the two cells of that column in front of it, so that all the times stay
in one column too.
A URL is never cut where the screen can hold it. A URL that is too long for the
body lane moves out to the column after the ``❯``, on its own line (refer to
:func:`~meshterm.ui.tui.render.render_hanging`). This is because a terminal opens
a link, and a user copies one, only while it is on one line.
Where the platform draws them (:data:`_URL_CODES`, the PicoCalc), each URL in the
body gets a QR code under it. The codes are side by side, at the indent of the
body. Thus the user can open a link from the handheld on a phone, and does not
have to type it.
"""
stamp = message.created_at.astimezone()
prefix = Text()
prefix.append("❯ " if selected else " ", style="cursor" if selected else None)
if self._names_us(body):
prefix.append_text(mention_mark(self._self_key))
prefix.append(" ")
else:
prefix.append(" ")
prefix.append(f"{stamp:%H:%M} ", style="cursor" if selected else "muted")
body_text = self._body_text(body, message, selected=selected)
whole = [m.span() for m in _WORD.finditer(body_text.plain) if links.urls(m.group())]
# A mention is a chip, and a chip never breaks across two lines.
whole += [(s.start, s.end) for s in body_text.spans if str(s.style) == PATH_INK]
lines = render_hanging(
prefix, body_text, width, indent=prefix.cell_len, whole=whole, gutter=_URL_GUTTER
)
urls = links.urls(body) if _URL_CODES else []
if urls:
lines += render_lines(qr_strip(urls, width, indent=prefix.cell_len), width)
badges = self._fold.badges(message) if self._fold is not None else []
if badges:
row, runs = self._badge_text(badges, selected=selected)
lines += render_hanging(
Text(" " * prefix.cell_len), row, width, indent=prefix.cell_len, whole=runs
)
return lines
def _badge_text(
self, badges: list[reactions.Badge], *, selected: bool
) -> tuple[Text, list[tuple[int, int]]]:
"""The reactions on one message, as one line under its body, and the runs to keep whole.
Each emoji has its count after it. The count is ``accent`` when our node is one of
the nodes that reacted, else ``muted``. On the selected message, the names of the
nodes take the place of the count, each in the hue of its node, and our node is the
white ``you``. Where the font has no emoji, an emoticon stands in for each default
reaction (refer to :func:`~meshterm.ui.theme.emoticon`).
"""
text = Text()
runs: list[tuple[int, int]] = []
for index, badge in enumerate(badges):
if index:
text.append(" ")
start = len(text)
text.append(emoticon(badge.emoji))
text.append(" ")
if not selected:
count = f"{_COUNT_MARK}{len(badge.reactors)}"
text.append(count, style="accent" if badge.ours else "muted")
runs.append((start, len(text)))
continue
for number, reactor in enumerate(badge.reactors):
if number:
text.append(", ", style="muted")
if reactor.ours:
text.append("you", style="you")
else:
name = reactor.name or self._peer_label
key = self._peer_key if reactor.name is None else self._sender_key(name)
text.append(name, style=name_style(name, key or None))
if not number:
runs.append((start, len(text))) # the emoji never ends a line alone
return text, runs
def _body_text(self, body: str, message: ChatMessage, *, selected: bool) -> Text:
"""Build the styled body of a message: mentions in colour, then the glyphs at the end."""
text = self._render_mentions(body, selected=selected)
if message.outbound:
# A direct message shows its own delivery (spinner → ✓/✗, and a retry is
# possible). A channel broadcast has no ack. Thus mark only a channel message
# that did not leave the companion.
if message.is_channel:
if message.acked is False:
text.append(" ⚠ no ack", style="warn")
text.append_text(self._scope_note(message))
else:
text.append(" ")
text.append_text(self._delivery_glyph(message.acked))
if message.snr is not None:
text.append(f" {message.snr:+.0f} dB", style=snr_style(message.snr))
return text
def _scope_note(self, message: ChatMessage) -> Text:
"""A muted note after a sent message that went out under a scope not in the title.
This note shows only in a channel whose title names a scope, and only for the
exceptions to that scope: an unscoped resend, or a message sent before the scope
was set or changed. On each other line, a note only repeats the title. In
a channel with no scope of its own, the default scope of its messages belongs to
the device. It is not news to show on each line.
"""
if not self._scope or not message.scope or message.scope == self._scope:
return Text()
if message.scope == WILDCARD:
return Text(" · unscoped", style="muted")
return Text(f" · scope {message.scope}", style="muted")
def _render_mentions(self, body: str, *, selected: bool) -> Text:
"""Render body text, and change each ``@[Name]`` token to the chip of that node.
The reply flow puts an ``@[Name]`` token in the compose line (refer to
:meth:`_begin_reply`). Here it shows as the name in a chip of the hue of that node.
Thus the user sees that a mention belongs to the node that it names. The text
around the mentions keeps the base style of the line (``cursor`` when the message
is the selected reply target, or else no style). A mention of our own name has our
chip, in white, and the line has our white ``★`` at the left of its time
(refer to :meth:`_body_lines`). Thus a message that names us is easy to see.
The chip opens on the notch, because the node that a mention names is the
destination of a route that came from the sender (refer to :meth:`_group_header`,
whose chip closes on the point). The chip has no ``@``, because the chip already
says that the name is a mention (JP, 2026-10-13). Arrow mode has no chip, so there a
mention keeps its ``@`` (refer to :func:`~meshterm.ui.widgets.mention_hop`).
On the selected message, the label of each chip is white, as the text around it is
(refer to :func:`~meshterm.ui.widgets.picked_hop`, JP, 2026-10-19).
"""
base = "cursor" if selected else None
text = Text()
pos = 0
for match in MENTION.finditer(body):
if match.start() > pos:
text.append(body[pos : match.start()], style=base)
hop = self._mention_hop(match.group(1))
if selected:
hop = picked_hop(hop) # the chips turn white with the text around them
text.append_text(hop_chip(hop, role=DESTINATION))
pos = match.end()
if pos < len(body):
text.append(body[pos:], style=base)
return text
def _mention_hop(self, name: str) -> PathHop:
"""The hop of a mention of ``name``: in the transcript, and in the compose line.
The two use this one method, so a chip in the compose line is the chip that the
message will show after the send. A mention of our name has our key and the chip
with the white label. Each other name has the key that the names of the senders
give (refer to :meth:`_sender_key`).
"""
if self._self_name and name == self._self_name:
return mention_hop(name, self._self_key, you=True)
return mention_hop(name, self._sender_key(name, mention=True))
def _names_us(self, body: str) -> bool:
"""Whether ``body`` has a mention of our name (it gets a mark before its time)."""
if not self._self_name:
return False
return any(match.group(1) == self._self_name for match in MENTION.finditer(body))
def _delivery_glyph(self, acked: bool | None) -> Text:
"""Map the ``acked`` state of an outbound direct message to the mark at its end.
A message that still waits for its ack (``acked is None``) shows the current
animation step of the spinner. :meth:`_spin_while` animates it while the send is in
flight. A resolved message shows the delivered ``✓`` (ok) or the unacknowledged
``✗`` (err).
"""
if acked is None:
return self._spinner.text()
glyph, style = _DELIVERED if acked else _FAILED
return Text(glyph, style=style)
def _pick_banner(self) -> Text:
"""A one-line note above the input while a message is selected.
In a channel, it starts with the reply (what Enter does there). In a direct chat,
it starts with the paths view (what Enter does there). The two say what the
selection is for. Thus the user never sees a highlight that has no explanation.
"""
message = self._messages[self._selected]
sender, _ = self._sender_and_body(message)
who = "this message" if message.outbound or sender == "·" else sender
if self._replies:
return Text(
f"↩ Enter to reply to {who} with an @mention · End to cancel",
style="accent",
)
return Text("Enter to see the paths this message took · End to cancel", style="accent")
def _sender_key(self, sender: str, *, mention: bool = False) -> str | None:
"""The key of a sender name, or ``None`` when nothing gives a key for it.
The contacts first, then the names that the recorder stored. In a *direct* thread,
a sender label with no key falls back to the key of the peer, because the only two
persons in the conversation are we and the peer. A mention names any node, so
that fallback must not apply to a mention. Thus a mention that is not resolved
stays grey in a direct chat, exactly as it does in a channel.
A name with no key is drawn in the *node* grey, never in ``muted`` (the chip does
this, refer to :func:`~meshterm.ui.widgets.name_chip`). An unidentified sender is
content that you can still do actions on (select the message, open its paths),
not chrome. On the console, ``muted`` is the dark slot and ``node.unknown`` is the
light one (JP, 2026-08-12).
The sender chip (:meth:`_group_header`) and the mention chips
(:meth:`_render_mentions`) share this method. Thus the two cannot disagree about
the identity of a name.
"""
key = self._key_of(sender)
if not key and not self._multiparty and not mention:
key = self._peer_key or None
return key
# --- input ---------------------------------------------------------------
def handle(self, action: str, data: str = "") -> None:
"""Dispatch a key: select messages and do actions on them, edit the compose line, or leave.
The two types of chat share one model. When no message is selected, Enter sends,
and typing edits the compose line. An amber dialog asks the user to confirm a paste
(Ctrl-V, or the bracketed paste of a terminal) before the paste goes into the line
(refer to :meth:`_begin_paste`). ↑ selects the newest message. The selection then
moves with ↑↓, PgUp/PgDn (one screen), Ctrl+Home (the very first message), and
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, ^L the map on its author, and ^E the
reaction dialog. ^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:
if self._replies:
self._begin_reply()
else:
self._open_paths(self._selected)
else:
self._submit()
elif action == "paste":
self._begin_paste(data)
elif action == "paths":
self._open_paths(self._selected)
elif action == "url_code":
self._open_codes()
elif action == "locate":
self._open_map()
elif action == "author_page":
self._open_author_page()
elif action == "react":
self._begin_react()
elif action == "lock":
self._toggle_lock()
elif action == "retry":
if self._is_channel:
self._begin_resend_unscoped()
else:
self._retry()
elif action == "escape":
if self._selected is not None:
self._clear_selection() # the first Esc clears the selection, the next leaves
self._session.invalidate()
else:
self.resolve(CANCEL)
elif action == "up":
self._move_selection(-1)
elif action == "pageup":
self._move_selection(-self._page_step)
elif action == "down":
self._move_selection(1)
elif action == "pagedown":
self._move_selection(self._page_step)
elif action == "ctrl_home":
if self._messages:
self._selected = 0
self._stick = False
elif action == "ctrl_pageup":
self._select_section(-1)
elif action == "ctrl_pagedown":
self._select_section(1)
elif action == "ctrl_end":
self._clear_selection()
self._stick = True # jump back to the live tail and the compose line
else:
if self._composing and self._editor.edit(action, data):
# An edit of the compose line puts the focus there. Nothing stays selected.
self._status = "" # an edit clears the "too long" notice
self._clear_selection()
self._stick = True
def _open_paths(self, index: int | None) -> None:
"""Float the delivery-paths view for the selected message (one dialog at a time).
``index`` is the selection. Thus ``None`` (nothing selected) opens nothing. A path
is a route that one *specific* message walked. When the code guessed the latest
message, it showed the paths of the message that was at the bottom of the
transcript at that time.
"""
if index is None or not self._messages or self._paths is None or self._paths_open:
return
message = self._messages[max(0, min(index, len(self._messages) - 1))]
self._paths_open = True
async def run() -> None:
try:
await self._paths(message)
finally:
self._paths_open = False
self._session.invalidate()
self._session.run_detached(run())
def _locate_target(self) -> MapMarker | None:
"""The marker of the author of the selected message, or ``None`` when ^L does nothing.
The author is the node whose key gives the colour of the chip of the message (refer
to :meth:`_header_key`). Our own message has our node as its author. A message with
no sender, or an author that has no location, has no marker.
"""
if self._locator 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 self._locator.you()
sender, _body = self._sender_and_body(message)
if sender == "·":
return None
return self._locator.find(self._header_key(sender, message))
def _open_map(self) -> None:
"""Open the map on the author of the selected message (^L). One map at a time.
The map is a screen, and it goes on the stack above the chat. Esc on the map comes
back to the chat, with the same message selected.
"""
target = self._locate_target()
if target is None or self._locator is None or self._map_open:
return
locator = self._locator
self._map_open = True
async def run() -> None:
try:
await locator.show(target)
finally:
self._map_open = False
self._session.invalidate()
self._session.run_detached(run())
def _author_page(self) -> Callable[[], Awaitable[None]] | None:
"""The node page that ^O opens for the selected message, or ``None`` when there is none.
The page belongs to the author of the message: our node for a message of ours, or
the contact that the key of the author finds (refer to :meth:`_header_key`, the
same key that gives the colour of the sender chip). A message with no sender, or an
author that is not a contact, has no page. The hint and the F-key lane ask this at
each paint, so that they name ^O only when it acts.
"""
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 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
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.
The page is a screen, and it goes on the stack above the chat. Esc on the page comes
back to the chat, with the same message selected.
"""
page = self._author_page()
if page is None or self._page_open:
return
self._page_open = True
async def run() -> None:
try:
await page()
finally:
self._page_open = False
self._session.invalidate()
self._session.run_detached(run())
def _picked_urls(self) -> list[str]:
"""The URLs in the body of the selected message, in order. None if nothing is selected."""
if self._selected is None or not self._messages:
return []
message = self._messages[max(0, min(self._selected, len(self._messages) - 1))]
return links.urls(self._sender_and_body(message)[1])
def _open_codes(self) -> None:
"""Show the links of the selected message as QR codes on the share screen (^U).
This is the share screen of a channel or of a contact card
(:class:`~meshterm.ui.qr.QrScreen`), full-frame. Thus a phone can read a link from
here and open it. Many links are on one screen that ←→ step through, in the order of
the message. The same as ^P, it acts on the *selection*: a link belongs to one
message, and when nothing is selected, the screen cannot know which message. One
share screen at a time.
"""
urls = self._picked_urls()
if not urls or self._codes_open:
return
self._codes_open = True
async def run() -> None:
try:
await self._session.run_screen(QrScreen(*urls, title="Links"))
finally:
self._codes_open = False
self._session.invalidate()
self._session.run_detached(run())
def _begin_paste(self, data: str) -> None:
"""Confirm a clipboard paste on an amber dialog, then insert it into the compose line.
A chat message goes out over the air. A paste can be much larger than a key press,
or it can have content that the user did not want to broadcast. Thus a paste must
go through a Cancel/Paste confirm (the amber ``danger`` tier: disruptive, not a loss
of data), and does not go directly into the line. Newlines and control characters
become spaces (a message is one line). An empty result never opens the dialog. One
paste dialog at a time, scheduled from the key handler, the same as the paths view.
"""
if self._paste_open or not self._composing:
return
clean = "".join(ch if ch.isprintable() else " " for ch in data).strip()
if not clean:
return
self._paste_open = True
count = len(clean)
async def run() -> None:
try:
confirmed = await self._session.button_dialog(
Text(
f"Paste {count} character{'s' if count != 1 else ''} into your message?",
style="warn",
),
[("Cancel", False), ("Paste", True)],
title="Paste",
default=1,
border_style="warn",
footer_hint="←→ choose · Enter select · Esc cancel",
)
finally:
self._paste_open = False
if confirmed:
# Put it in the compose line: clear the selection, go back to the tail, and
# insert the run through the shared editor. If the result is over the limit,
# the byte gauge marks it, exactly as it marks typing past the limit.
self._clear_selection()
self._stick = True
self._editor.edit("text", clean)
self._status = ""
self._session.invalidate()
self._spawn(run())
def _move_selection(self, delta: int) -> None:
"""Move the reply selection by ``delta`` messages (negative = toward older).
The selection starts from the compose line only on a move up (``delta < 0``). A
move past the newest message clears the selection and stays at the tail again.
"""
if not self._messages:
return
last = len(self._messages) - 1
if self._selected is None:
if delta < 0:
self._selected = last
self._stick = False
return
target = self._selected + delta
if target > last:
self._clear_selection()
self._stick = True
else:
self._selected = max(0, target)
self._stick = False
def _select_section(self, direction: int) -> None:
"""Move the reply selection to the first message of the previous or next day.
This is the day jump of the transcript (Ctrl+PageUp/PageDown), for the selection.
Down moves to the first message of the next day (or to the tail when there is no
later day). Up moves to the first message of the current day, or to the first
message of the previous day when the selection is already on a first message.
"""
if not self._messages:
return
starts = self._day_start_indices()
current = self._selected if self._selected is not None else len(self._messages) - 1
if direction > 0:
target = next((s for s in starts if s > current), None)
if target is None:
self._clear_selection()
self._stick = True
return
else:
governing = max((s for s in starts if s <= current), default=0)
target = (
governing
if governing < current
else max((s for s in starts if s < current), default=0)
)
self._selected = target
self._stick = False
def _day_start_indices(self) -> list[int]:
"""The indexes of the messages that start a new local day: the transcript dividers."""
starts: list[int] = []
prev_day = None
for i, message in enumerate(self._messages):
day = message.created_at.astimezone().date()
if day != prev_day:
starts.append(i)
prev_day = day
return starts
def _clear_selection(self) -> None:
"""Clear the reply selection (the focus goes back to the compose line)."""
self._selected = None
self._selected_line = None
def _begin_reply(self) -> None:
"""Put an ``@mention`` of the sender of the selected message at the cursor.
The mention goes in at the cursor of the compose line, and the text that the user
already typed stays. Thus a message can name more than one node: select a message,
press Enter, select another, and press Enter again. A space comes before the
mention when it would touch the word in front of it, and after it when nothing
follows or a word would touch it. The mention is a chip from that moment, and the
cursor goes after it (refer to :class:`~meshterm.ui.compose.MentionEditor`).
A message of ours, or one with no sender, gives no name to mention. There the
line gets a bare ``@``, and the user types the name.
"""
message = self._messages[self._selected]
sender, _ = self._sender_and_body(message)
named = not message.outbound and sender != "·"
text, cursor = self._editor.text, self._editor.cursor
before = " " if cursor > 0 and not text[cursor - 1].isspace() else ""
if named:
after = " " if cursor == len(text) or not text[cursor].isspace() else ""
self._editor.insert(f"{before}@[{sender}]{after}")
else:
self._editor.insert(f"{before}@")
self._clear_selection()
self._stick = True
self._session.invalidate()
def _submit(self) -> None:
"""Send the current input line as a message (scheduled from the key handler).
A line that is too long for one packet goes out as parts, each with its part number
(refer to :mod:`meshterm.core.parts`), when the ``message_parts_max`` preference
permits more than one part. A line that is too long for all the parts is refused.
The buffer stays the same (so that the user can make it shorter), and the status
shows why, the same as the red overflow that the compose bar already shows.
"""
text = self._editor.text.strip()
if not text or self._sending:
return
cut = self._parts(text)
if cut is None:
if self._max_parts < 2:
over = self._used_bytes() - self._byte_limit()
self._status = f"Too long by {over} byte{'s' if over != 1 else ''} — trim to send."
else:
self._status = f"Too long for {self._max_parts} parts — trim to send."
self._session.invalidate()
return
self._editor = MentionEditor()
self._sending = True
self._stick = True
if self._is_channel:
self._status = "sending…"
self._session.invalidate()
self._spawn(self._send_channel(cut))
else:
self._status = ""
self._session.invalidate()
self._spawn(self._send_direct(cut))
def _sending_part(self, index: int, total: int) -> None:
"""Show which part goes out now, when a message has more than one."""
if total > 1:
self._status = f"sending part {index} of {total}…"
self._session.invalidate()
def _keep_unsent(self, cut: list[str], first: int) -> None:
"""Put the text of the parts that did not go out back in the compose line.
``first`` is the index in ``cut`` of the first part that did not go out. The text
goes back without its part numbers, so that Enter cuts it again. If the user typed
something new in the meantime, the line stays as it is, and the text is lost.
"""
if self._editor.text or first >= len(cut):
return
total = len(cut)
rest = " ".join(parts.unnumbered(cut[i], i + 1, total) for i in range(first, total))
self._editor = MentionEditor(rest)
async def _send_channel(self, cut: list[str]) -> None:
"""Broadcast each part of a channel message, and append each when the device takes it.
Between two parts, the send waits ``part_gap_s``, so that the floods do not go out
as a burst. If a part fails, the parts after it do not go out, and their text goes
back in the compose line.
"""
sent = 0
try:
for i, text in enumerate(cut):
if i:
await asyncio.sleep(self._part_gap_s)
self._sending_part(i + 1, len(cut))
message = await self._send(text)
sent += 1
if message is not None:
self._admit(message)
self._session.invalidate()
self._status = ""
except Exception as exc: # noqa: BLE001 - show the error inline, keep the chat open
self._status = f"send failed: {exc}"
self._keep_unsent(cut, sent)
finally:
self._sending = False
self._stick = True
self._session.invalidate()
async def _spin_while(self, coro: Awaitable[Any]) -> Any:
"""Await ``coro`` while the delivery spinner animates on the message in flight.
A background timer moves the shared spinner forward and paints every
:func:`~meshterm.ui.tui.spinner.spinner_interval` seconds (the spin rate of the
platform). Thus the glyph at the end of a pending message spins until the ack
resolves. Before the method returns, it always cancels the timer (and awaits it, so
that it cannot stay after the send as a stray pending task). The spinner is only
for the look. Thus the method ignores each problem in the animation, so that the
problem cannot break the send.
"""
self._spinner.reset()
async def animate() -> None:
while True:
await asyncio.sleep(spinner_interval())
self._spinner.tick()
self._session.invalidate()
ticker = self._spawn(animate())
try:
return await coro
finally:
ticker.cancel()
try:
await ticker
except asyncio.CancelledError:
pass
except Exception: # noqa: BLE001 - a spinner problem must never break a send
pass
async def _send_direct(self, cut: list[str]) -> None:
"""Send each part of a direct message, and show each as an optimistic bubble.
Each part shows as a pending bubble while its delivery is in flight. On success,
the stored message replaces the bubble. The stored message has its resolved
``acked`` state and its row id, so that the user can retry a ✗ later in place. A
part goes out when the part before it is acknowledged or its wait ends. A part with
no acknowledgement can still have arrived, so the parts after it go out too, and ^R
retries each ✗ in turn. A hard failure (the companion refuses the send) removes the
bubble, shows the error inline, and stops the parts after it. Their text goes back
in the compose line.
"""
sent = 0
try:
for i, text in enumerate(cut):
self._sending_part(i + 1, len(cut))
pending = ChatMessage(text=text, outbound=True, created_at=utcnow())
self._messages.append(pending)
self._session.invalidate()
try:
message = await self._spin_while(self._send(text))
except Exception:
self._discard(pending)
raise
if message is None: # nothing went out (the contact was not restored)
self._discard(pending)
self._keep_unsent(cut, sent)
break
sent += 1
self._swap(pending, message)
self._status = ""
except Exception as exc: # noqa: BLE001 - show the error inline, keep the chat open
self._status = f"send failed: {exc}"
self._keep_unsent(cut, sent)
finally:
self._sending = False
self._stick = True
self._session.invalidate()
def _retry_target(self) -> ChatMessage | None:
"""The message that ^R sends again, or ``None`` when there is nothing to retry.
In a direct chat, this is the newest outbound message that went out and was never
acknowledged. In a channel, nothing is ever acknowledged. There, it is a message
that *we* sent under a region, and ^R sends it again unscoped: the **selected**
message when a message is selected (the user pointed at it), or else the newest
message that we sent. With a selection, only the selection counts. If the selected
message is not a scoped message of ours, ^R offers nothing, and does not go past it
to another message without a sign. With no selection, only the newest counts.
When its unscoped copy has gone, that copy is the newest, and nothing remains to
offer. In the two cases, this is true only while a resend can start: no send is
already in flight, and a resend path is connected.
"""
if self._is_channel:
if self._sending or self._resend_unscoped is None:
return None
if self._selected is not None:
candidate: ChatMessage | None = self._messages[self._selected]
else:
candidate = next((m for m in reversed(self._messages) if m.outbound), None)
if (
candidate is None
or not candidate.outbound
or not candidate.scope
or candidate.scope == WILDCARD
):
return None
return candidate
if self._sending or self._resend is None:
return None
return next(
(m for m in reversed(self._messages) if m.outbound and m.acked is False),
None,
)
def _retry(self) -> None:
"""Try again to deliver the most recent unacknowledged direct message (Ctrl-R)."""
target = self._retry_target()
if target is None:
return
self._sending = True
target.acked = None # back to the spinner while the retry is in flight
self._status = "retrying…"
self._stick = True
self._session.invalidate()
self._spawn(self._resend_message(target))
def _begin_resend_unscoped(self) -> None:
"""Confirm, then send a scoped channel message again, unscoped (^R).
The dialog is amber (``danger``). Nothing is lost, but each repeater that permits
an unscoped flood relays it: all the mesh that the scope kept the message out of.
Thus it is a choice made on purpose, and the dialog names the region that the
message leaves. The resend is a new message on the air (a channel message has no
identity to deliver again), so it is appended as a new message. A radio that
cannot send unscoped refuses before anything goes out, and the status line says
why.
"""
target = self._retry_target()
if target is None or self._resend_open:
return
self._resend_open = True
preview = target.text if len(target.text) <= 32 else target.text[:31] + "…"
async def run() -> None:
try:
confirmed = await self._session.button_dialog(
Text(
f"Send “{preview}” again, unscoped? Every repeater that relays "
f"unscoped floods will carry it, not only those in {target.scope}.",
style="warn",
),
[("Cancel", False), ("Resend", True)],
title="Resend unscoped",
default=1,
border_style="warn",
footer_hint="←→ choose · Enter select · Esc cancel",
)
finally:
self._resend_open = False
if not confirmed or self._sending or self._resend_unscoped is None:
self._session.invalidate()
return
self._sending = True
self._status = "resending unscoped…"
# The resend goes to the tail as a new message. The selection that chose it is
# done.
self._clear_selection()
self._stick = True
self._session.invalidate()
try:
message = await self._resend_unscoped(target)
if message is not None:
self._admit(message)
self._status = ""
except Exception as exc: # noqa: BLE001 - show the error inline, keep the chat open
self._status = f"resend failed: {exc}"
finally:
self._sending = False
self._stick = True
self._session.invalidate()
self._spawn(run())
async def _resend_message(self, message: ChatMessage) -> None:
"""Run a retry to its end, and refresh the delivery state of the message in place."""
assert self._resend is not None
try:
await self._spin_while(self._resend(message))
except Exception as exc: # noqa: BLE001 - show the error inline, keep the chat open
message.acked = False
self._status = f"retry failed: {exc}"
else:
self._status = "" if message.acked else "still no ack — ^R to retry"
finally:
self._sending = False
self._stick = True
self._session.invalidate()
def _swap(self, old: ChatMessage, new: ChatMessage) -> None:
"""Replace an optimistic bubble with its stored message, in place.
The stored message is the one that a reaction can name, because it has the
timestamp that went out. Thus the fold takes it here, and not the bubble.
"""
if self._folds(new):
self._discard(old)
return
try:
self._messages[self._messages.index(old)] = new
except ValueError: # pragma: no cover - the bubble is always still there
self._messages.append(new)
def _folds(self, message: ChatMessage) -> bool:
"""Give ``message`` to the fold. ``True`` for a reaction, which the transcript omits."""
return self._fold is not None and self._fold.take(message)
def _admit(self, message: ChatMessage) -> None:
"""Append a message that we sent, unless it is a reaction (the fold shows that one)."""
if not self._folds(message):
self._messages.append(message)
# --- reactions -------------------------------------------------------------
def _react_target(self) -> tuple[ChatMessage, str | None, str, int] | None:
"""The selected message, and the author, body, and timestamp that a reaction names it by.
``None`` when nothing is selected, or when a reaction cannot name the selection: a
room, a message that is still in flight, or a channel message with no author (a
line with no ``Name: `` prefix, or one of ours when our name is not known).
"""
if self._fold is None or self._selected is None or not self._messages:
return None
message = self._messages[min(self._selected, len(self._messages) - 1)]
named = self._fold.target_of(message)
if named is None:
return None
author, body, timestamp = named
if self._is_channel and not author:
return None
return message, author, body, timestamp
def _can_react(self) -> bool:
"""Whether ^E can start a reaction: a selection that a reaction can name, and no send."""
return not self._sending and self._composing and self._react_target() is not None
def _begin_react(self) -> None:
"""Ask which emoji, then send the reaction to the selected message (^E).
The dialog is a question, so it floats as a box over the chat. The emoji of the
``reaction_emoji`` preference come first, then each other emoji (refer to
:func:`emoji_catalog`). Each row has the name of its emoji, so a typed word such as
``fire`` or ``heart`` filters the list to the emoji that it names. A row that our
node already sent to this message says so. A second copy of it would only count one
time, so the selection of that row sends nothing. The reaction goes out as a usual
message in the conversation. When it is stored, the fold puts it under its target
(refer to :meth:`_admit`).
"""
target = self._react_target()
if target is None or self._sending or self._react_open or self._fold is None:
return
message, author, body, timestamp = target
fold = self._fold
self._react_open = True
sender, _ = self._sender_and_body(message)
who = "your message" if message.outbound else sender
async def run() -> None:
from .menus import menu_rows, section_heading
def row(emoji: str) -> tuple[str, str, str]:
name = reactions.emoji_name(emoji)
sent = fold.reacted(message, emoji)
return emoticon(emoji), f"{name} · sent" if sent else name, emoji
mine = list(self._reaction_set)
others = [emoji for emoji in emoji_catalog() if emoji not in mine]
rows = menu_rows([row(emoji) for emoji in mine + others])
items = [
section_heading("Your reactions"),
*rows[: len(mine)],
section_heading("All emoji"),
*rows[len(mine) :],
]
try:
emoji = await self._session.select(
f"React — {who}", items, filterable=True, floating=True
)
finally:
self._react_open = False
if not emoji or self._sending:
self._session.invalidate()
return
if fold.reacted(message, emoji):
self._status = "you already reacted with that"
self._session.invalidate()
return
if self._is_channel:
text = reactions.channel_text(emoji, author or "", body, timestamp)
else:
text = reactions.direct_text(emoji, body, timestamp)
self._sending = True
self._status = "reacting…"
self._session.invalidate()
try:
sent_message = await self._send(text)
if sent_message is not None:
self._admit(sent_message)
no_ack = not self._is_channel and sent_message.acked is False
self._status = "reaction sent · no ack" if no_ack else ""
else:
self._status = ""
except Exception as exc: # noqa: BLE001 - show the error inline, keep the chat open
self._status = f"reaction failed: {exc}"
finally:
self._sending = False
self._session.invalidate()
self._spawn(run())
def _discard(self, message: ChatMessage) -> None:
"""Remove an optimistic bubble that never became a real message."""
try:
self._messages.remove(message)
except ValueError: # pragma: no cover - a defensive check
pass
#: The blocks of Unicode where an emoji with the emoji presentation can be. A wide symbol
#: between them is a CJK symbol or radical, not an emoji.
_EMOJI_BLOCKS = ((0x2000, 0x2E7F), (0x1F000, 0x1FAFF))
#: The I Ching symbols, which Unicode 16 draws wide. They are symbols, not emoji: the
#: firmware and the other apps never offer them.
_NOT_EMOJI = frozenset(range(0x268A, 0x2690)) | frozenset(range(0x2630, 0x2638))
@lru_cache(maxsize=1)
def emoji_catalog() -> tuple[str, ...]:
"""Each single emoji that the reaction dialog offers, in the order of Unicode.
The standard library has no list of emoji. Thus the catalog takes two sets. The first is
each symbol in the emoji blocks that Unicode draws wide by default (``🔥``, ``⌚``). The
second is the emoji that are text by default (``❤``), which the table of Rich lists.
Each of these gets the variation selector that asks for the emoji form (``❤️``), as the
apps send it. A flag, a skin tone, and a joined sequence are not in the catalog, but a
reaction with one still shows when it arrives. The names come from the Unicode database
of the Python that runs MeshTerm, so a newer Python offers newer emoji.
"""
from rich._unicode_data import load
found: dict[int, str] = {}
for first, last in _EMOJI_BLOCKS:
for cp in range(first, last + 1):
char = chr(cp)
if cp in _NOT_EMOJI:
continue
if unicodedata.category(char) == "So" and unicodedata.east_asian_width(char) == "W":
found[cp] = char
for char in load("auto").narrow_to_wide:
if not char.isascii() and len(char) == 1:
found.setdefault(ord(char), char + "\ufe0f")
return tuple(
emoji
for _, emoji in sorted(found.items())
if not reactions._is_regional(emoji[0]) and reactions.is_reaction_emoji(emoji)
)
async def our_node(ctx: AppContext) -> tuple[str | None, str | None]:
"""The name and the public key of our node, from the cache of the session.
A chat shows them on our messages (refer to :func:`~meshterm.ui.widgets.self_chip`),
and a direct chat names one end of each of its packets with the key. The read uses the
cache, so it does not ask the device again.
Returns:
``(name, key)``. An item is ``None`` when the device did not report it, or when the
read failed. Our node with no name only keeps the bare ``★`` in a chat.
"""
try:
info = await ctx.devstate.self_info()
except Exception: # noqa: BLE001 - our node with no name keeps the bare star
return None, None
return str(info.get("name") or "") or None, str(info.get("public_key") or "") or None
async def open_chat(ctx: AppContext, conversation: Conversation) -> int:
"""Open the live chat screen for ``conversation`` and run it until the user closes it.
This function makes sure that the listener and the message recorder run, reads the
stored transcript, subscribes to the hub so that inbound messages for this thread are
appended live, and pushes the screen. At exit, it always removes the subscription and
the marker of the active conversation.
Args:
ctx: The shared application context (it must run the interactive TUI surface).
conversation: The channel or contact conversation to open.
Returns:
The number of messages in the transcript when the screen closed.
Raises:
RuntimeError: If the call is not from the interactive menu (no full-screen
session).
"""
from .surface import TuiUi
if not isinstance(ctx.ui, TuiUi): # pragma: no cover - the caller is only in the menu
raise RuntimeError("live chat is only available in the interactive menu")
if conversation.is_room:
from .room import open_room
return await open_room(ctx, conversation)
session = ctx.ui.session
device = await ctx.device()
try:
await ctx.chat.start() # start to store inbound messages, if it did not already
except Exception: # noqa: BLE001 - the hub may already run. The recorder is best-effort
pass
from ..services import trace_runner
history = ctx.repo.recent_chat_messages(
is_channel=conversation.is_channel,
channel_id=conversation.channel_id,
peer=conversation.peer,
limit=ctx.preferences.chat_history_limit,
)
contacts = await ctx.devstate.contacts()
names = _contact_names(contacts)
stored_names, named_at = ctx.repo.node_names_with_times()
key_of = trace_runner.make_name_key_resolver(contacts, stored_names, heard=named_at)
async def send(text: str) -> ChatMessage | None:
if conversation.is_channel:
assert conversation.channel_idx is not None
return await ctx.chat.send_channel(
conversation.channel_idx, text, label=conversation.label
)
assert conversation.contact is not None
return await _with_restore(ctx, lambda: ctx.chat.send_direct(conversation.contact, text))
resend: Callable[[ChatMessage], Awaitable[ChatMessage]] | None = None
resend_unscoped: Callable[[ChatMessage], Awaitable[ChatMessage | None]] | None = None
scope: str | None = None
if conversation.is_channel:
scope = ctx.chat.channel_scope(conversation.channel_id)
async def resend_unscoped(message: ChatMessage) -> ChatMessage | None:
assert conversation.channel_idx is not None
return await ctx.chat.send_channel(
conversation.channel_idx, message.text, label=conversation.label, scope=WILDCARD
)
else:
async def resend(message: ChatMessage) -> ChatMessage:
assert conversation.contact is not None
return await _with_restore(
ctx, lambda: ctx.chat.resend_direct(conversation.contact, message)
)
from .map_screen import Locator
from .node_detail_screen import ContactPages
paths = await _make_paths_presenter(ctx, conversation, device)
self_name, self_key = await our_node(ctx)
screen = ChatScreen(
conversation,
history,
send=send,
names=names,
session=session,
resend=resend,
paths=paths,
key_of=key_of,
scope=scope,
resend_unscoped=resend_unscoped,
locator=await Locator.gather(ctx),
pages=await ContactPages.gather(ctx, self_key=self_key or ""),
self_name=self_name,
self_key=self_key,
reaction_set=reactions.reaction_set(ctx.preferences.reaction_emoji),
max_parts=ctx.preferences.message_parts_max,
part_gap_s=ctx.preferences.trace_cooldown_s,
)
ctx.chat.set_active(conversation.key)
def on_event(event: MeshEvent) -> None:
message = event.message
if message is not None and _belongs(message, conversation):
peer_name = names.get((message.sender or "").lower())
screen.append(
ChatMessage.from_message(
message, peer_name=peer_name, channel_id=conversation.channel_id
)
)
unsubscribe = ctx.events.subscribe(on_event, EventKind.MESSAGE)
try:
await session.run_screen(screen)
finally:
unsubscribe()
ctx.chat.set_active(None)
return len(screen._messages)
async def _with_restore(ctx: AppContext, attempt: Callable[[], Awaitable[Any]]) -> Any:
"""Run a direct send. If the device forgot the contact, offer to restore it, then retry.
This is the one rejection from which a send can recover: the firmware has no contact
for the recipient (refer to :class:`~meshterm.core.connection.ContactNotOnDeviceError`),
and one write corrects it. The user is asked first (:func:`_restore_contact`). If the
user declines, the error is raised again. Thus the chat shows the refusal exactly as it
shows each other failed send.
Args:
ctx: The shared application context.
attempt: The send to run. It is called a second time, with no change, when the
contact is back.
Returns:
The return value of ``attempt``.
Raises:
Exception: Each error that ``attempt`` raises. A rejection for a forgotten contact
is raised again only when the user declined the offer to add it back.
"""
try:
return await attempt()
except ContactNotOnDeviceError as missing:
if not await _restore_contact(ctx, missing):
raise
return await attempt()
async def _restore_contact(ctx: AppContext, missing: ContactNotOnDeviceError) -> bool:
"""Offer to write a forgotten contact back to the device. ``True`` if it now has it.
A direct message is addressed through the contact entry of the *device*. Thus MeshTerm
cannot send a message to a contact that the firmware removed, also when MeshTerm still
lists it. (The list on a screen is the union of the table of the device and the
contacts that MeshTerm keeps for it: refer to :mod:`meshterm.core.contact_store`.) The
contact that we already have holds all the data that the entry must have. Thus one
write is the repair, and the send that got the rejection tries again after it.
The function offers the write and does not do it silently. The write changes what the
device stores, and a full contact table refuses it (the user must see that, and it must
not be hidden).
Args:
ctx: The shared application context.
missing: The rejection, with the contact that the device could not find.
Returns:
``True`` when the contact is on the device (retry the send). ``False`` if the user
declined. Then the caller raises the error again, so that the chat shows the
refusal.
Raises:
DeviceCommandError: If the device refused the write (most often, a full table).
"""
contact = missing.contact
add = await ctx.ui.dialog(
f"{contact.name} isn't in this device's contacts, so the radio can't address it. "
"Add it back and send?",
[("Cancel", False), ("Add & send", True)],
title="Contact not on device",
default=1,
)
if not add:
return False
device = await ctx.device()
await device.add_contact(contact)
# The table of the device changed under the session cache. The next read gets it again
# (with the route that the firmware learns for the contact from now on).
ctx.devstate.invalidate_contacts()
return True
def _contact_names(contacts: list[Contact]) -> dict[str, str]:
"""Build a map from key prefix to name, for the labels of inbound direct messages."""
names: dict[str, str] = {}
for contact in contacts:
for key in (contact.key_prefix, contact.public_key[:12]):
if key:
names[key.lower()] = contact.name
return names
def _belongs(message: Message, conversation: Conversation) -> bool:
"""Whether an inbound message belongs to the open conversation.
Args:
message: The received message.
conversation: The conversation that the screen shows now.
Returns:
``True`` if the message must be appended to this transcript.
"""
if conversation.is_channel:
return message.is_channel and message.channel == conversation.channel_idx
if message.is_channel:
return False
sender = (message.sender or "").lower()
peer = (conversation.peer or "").lower()
if not sender or not peer:
return False
return sender.startswith(peer) or peer.startswith(sender)
# --- message paths (the ^P view) -----------------------------------------------------
def _sent_scope_line(ctx: AppContext, message: ChatMessage, *, relayed: bool) -> Text | None:
"""The scope of a message that we sent, for the paths dialog, and why it can be lost.
The line comes from the scope stored on the message at send time
(:attr:`ChatMessage.scope`), never from the current scope of the channel, which may
have changed since then. A scoped message can have no stored relayed copy *and* no
repeater that was ever heard here to relay its region
(:meth:`~meshterm.core.region_store.RegionStore.carriers`). Then the line gives the
most probable reason in plain words: only repeaters that relay its region relay a
scoped flood, and no known repeater in range does.
Args:
ctx: The shared application context (for the region store).
message: The message whose paths are open.
relayed: Whether a copy of it was heard when it came back.
Returns:
The line, or ``None`` for a message with no stored scope (inbound, direct, or
sent before MeshTerm stored scopes).
"""
from ..core.regions import UNSCOPED, Scope
from .widgets import scope_text
if not message.outbound or not message.scope:
return None
line = Text("sent ", style="muted")
if message.scope == WILDCARD:
line.append_text(scope_text(UNSCOPED))
return line
line.append("under ", style="muted")
line.append_text(scope_text(Scope("scoped", message.scope)))
store = getattr(ctx, "region_store", None)
carriers = len(store.carriers(message.scope)) if store is not None else 0
if not relayed and not carriers:
line.append(" — no repeater known here carries it", style="warn")
elif carriers:
line.append(
f" · {carriers} known repeater{'s' if carriers != 1 else ''} carr"
f"{'y' if carriers != 1 else 'ies'} it",
style="muted",
)
return line
async def _make_paths_presenter(
ctx: AppContext,
conversation: Conversation,
device, # noqa: ANN001 - the core Device. Its type is set at the source
) -> Callable[[ChatMessage], Awaitable[None]]:
"""Build the async presenter for the ^P delivery-paths view of the chat.
It collects what the presenter must have, one time each time the chat opens:
* a resolver of hop names over the contacts and all the names that the recorder ever
heard (the rule of the app is that a node that has a name never shows as a bare
hash),
* the name of our node, for the white ``you``,
* the width of the routing prefix, for the lit hashes, and
* for a channel, its secret. The function reads it from the device when the
conversation did not have one (a conversation from the picker knows its identity,
but not always its key).
Args:
ctx: The shared application context.
conversation: The conversation that the chat screen opens.
device: The connected device (the caller already awaited it).
Returns:
An async callable that shows the paths of one message in a floating window.
"""
from ..services import trace_runner
from ..services.message_paths import (
channel_arrivals,
collapse,
direct_arrivals,
distinct_paths,
message_scope,
)
from .message_paths_screen import MessagePathsScreen
session = ctx.ui.session
contacts = await ctx.devstate.contacts()
stored_names, named_at = ctx.repo.node_names_with_times()
resolve = trace_runner.make_node_resolver(contacts, stored_names)
type_of = trace_runner.make_node_type_resolver(contacts)
key_of = trace_runner.make_name_key_resolver(contacts, stored_names, heard=named_at)
prefix_bytes = await ctx.devstate.routing_prefix_bytes()
# Our key names one end of each direct packet in this conversation. The key of the
# contact names the other end (refer to :func:`direct_arrivals`). Our node with no name
# only gets no white highlight.
self_name, self_key = await our_node(ctx)
secret = conversation.secret
if conversation.is_channel and not secret and conversation.channel_idx is not None:
try:
payload = await device.get_channel(conversation.channel_idx)
raw = (payload or {}).get("channel_secret")
secret = bytes(raw) if raw else None
except Exception: # noqa: BLE001 - no key, no decrypt. The view says so correctly
secret = None
async def present(message: ChatMessage) -> None:
if conversation.is_channel and secret:
arrivals = channel_arrivals(
ctx.repo, message, channel_name=conversation.label, secret=secret
)
matched = True
summary = (
f"heard {len(arrivals)} time{'s' if len(arrivals) != 1 else ''}"
f" · {distinct_paths(arrivals)} distinct "
f"path{'s' if distinct_paths(arrivals) != 1 else ''}"
if arrivals
else "no copies in the packet log"
)
elif conversation.is_channel:
await session.scroll(
Text(
"This channel's key isn't at hand, so overheard frames can't be "
"matched to the message.",
style="muted",
),
title="Message paths",
)
return
else:
contact = conversation.contact
peer_key = conversation.peer or (
(contact.public_key or contact.key_prefix) if contact else None
)
arrivals, exact = direct_arrivals(
ctx.repo, message, self_key=self_key, peer_key=peer_key
)
# An exact match says the same as the channel view: these packets are this
# message. The shared MAC of the packets gives the match. Thus it is correct,
# also when MeshTerm cannot read one word of them.
matched = exact
arrivals = collapse(arrivals)
heard = sum(a.copies for a in arrivals)
if exact:
summary = (
f"heard {heard} time{'s' if heard != 1 else ''}"
f" · {len(arrivals)} distinct path{'s' if len(arrivals) != 1 else ''}"
if arrivals
else "no copies in the packet log"
)
else:
summary = (
"direct frames are encrypted — matched by address and time"
if peer_key and self_key
else "direct frames are encrypted — matched by time alone"
)
# The two ends of the path: the node that the message started from, and the node
# that it was addressed to. Our sends go from our node to the peer. An inbound
# channel message names its sender on the air and is addressed to all nodes, so it
# ends on our node. The origin of an inbound message in a direct chat is the peer of
# the conversation.
destination: str | None = self_name
if message.outbound:
source = self_name
# A channel broadcast is addressed to no specific node, and the copies that we
# log are its rebroadcasts that come back. Thus it really ends on our node. A
# direct send does not: it went to one node, and its route ends there.
if not conversation.is_channel:
destination = conversation.label
elif conversation.is_channel:
source, _body = split_channel_sender(message.text)
else:
source = conversation.label
await session.run_screen(
MessagePathsScreen(
message,
arrivals,
matched=matched,
resolve=resolve,
prefix_bytes=prefix_bytes,
self_name=self_name,
summary=summary,
source=source or None,
destination=destination or None,
type_of=type_of,
key_of=key_of,
scope=message_scope(arrivals, ctx.region_store.scope_of),
sent_scope=_sent_scope_line(ctx, message, relayed=bool(arrivals)),
)
)
return present