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

1521 lines
64 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 ``chat`` tool: channel messages and direct messages over the mesh.
In the menu, it opens a conversation picker, and then a live chat on the full screen
(refer to :mod:`meshterm.ui.chat`). There, the sent messages and the received messages
stream together. For a room server, it opens the board of the room instead
(:mod:`meshterm.ui.room`). On the CLI, it has the ``send``, ``history``, ``list``, and
``listen`` subcommands for scripts. You address a room with ``--to``, the same as a
contact. This tool only *lists* channels and rooms, so that you can select one. The
``channels`` tool configures the channel slots, and the ``rooms`` tool joins a room.
The :class:`~meshterm.services.chat_service.ChatService` always runs and stores each
received message in the history. Each sent message is stored when it is sent. Thus
``history`` shows the full transcript, whichever path made each message.
"""
from __future__ import annotations
import asyncio
import time
from collections.abc import Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
import typer
from rich.cells import cell_len
from rich.text import Text
from ..context import AppContext
from ..core import exitcodes, reactions
from ..core.channels import (
CHANNEL_SLOT_PROBE_CAP,
DEFAULT_PUBLIC_SECRET,
MENTION,
channel_hash,
channel_identity,
is_public_channel,
split_channel_sender,
)
from ..core.connection import Device
from ..core.events import EventKind, MeshEvent
from ..core.models import (
NODE_TYPE_CHAT,
NODE_TYPE_LABELS,
NODE_TYPE_ROOM,
ChatMessage,
Contact,
Conversation,
NodeResolver,
is_direct_messageable,
is_room,
)
from ..services.trace_runner import (
NameKeyResolver,
make_name_key_resolver,
make_node_resolver,
)
from ..ui import renderers
from ..ui.fields import ChannelRef, NodeRef
from ..ui.menus import Lane, column_header, fit_cells, section_heading
from ..ui.termfont import powerline_enabled
from ..ui.theme import emoticon, name_style
from ..ui.tui import Choice, DeleteRequest, Separator
from ..ui.widgets import (
NODE_GLYPHS,
ORIGIN,
age_seconds,
author_chip,
channel_glyph,
format_age,
mention_chip,
mention_mark,
self_chip,
)
from .base import Tool, ToolResult, register
if TYPE_CHECKING:
from ..core.channel_probe import ChannelSlot
from ..ui.report import Facts, Listing
#: The number of recent messages that ``chat history`` prints by default.
_HISTORY_LIMIT = 50
@register
class ChatTool(Tool):
"""Send and receive channel and direct messages, with a live interactive chat."""
name = "chat"
title = "Chat"
icon = "💬"
help = "Channel and direct messaging, with history"
category = "Message"
order = 10
async def prompt_params(self, ctx: AppContext) -> dict[str, Any] | None:
"""Nothing to collect here. The conversation picker is in :meth:`run`.
The picker must *stay pushed* while a chat runs. Thus, when the user leaves a
thread, the user goes back to the same list that opened it, with the same
highlight and the same typed filter. A prompt here resolves, and pops, before the
tool runs.
Args:
ctx: The shared application context.
Returns:
``{"live": True}``: the marker of the menu for the interactive path.
"""
return {"live": True}
async def run(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
"""Open a live chat (menu), or do a scripted message action (CLI).
Args:
ctx: The shared application context.
params: ``conversation`` (menu), or a ``cli_action`` with its arguments.
Returns:
A :class:`ToolResult` with a summary of what occurred.
"""
action = params.get("cli_action")
if action is not None:
return await self._run_cli(ctx, action, params)
return await self._run_live(ctx)
# -- interactive picker -----------------------------------------------------
async def _run_live(self, ctx: AppContext) -> ToolResult:
"""Keep the conversation picker pushed, and open chats above it until the user leaves.
One screen stays for the full visit. Thus, when the user leaves a thread, the user
goes back to the row that opened it, and the typed filter still narrows the list.
Before, each round made the picker again from nothing, and a ``default=`` restore
put the highlight back. That restored only the highlight, and only while its row
was still there.
The rows *are* data. An exchange moves its thread up in the order of recency. A
delete of a history makes its dot hollow and moves the row down to the alphabetical
end of the list. Thus, after each chat and each delete, the rows are read again and
replaced in place. The highlight follows its thread to its new position.
Args:
ctx: The shared application context.
Returns:
A :class:`ToolResult` that counts the opened conversations and the messages that
the last one showed.
"""
from ..ui.chat import open_chat
from ..ui.tui import SelectScreen
from ..ui.tui.screen import CANCEL
picker = SelectScreen(
"Chat — pick a conversation",
await self._picker_items(ctx),
# Enter pushes the chat screen, so the committing verb is `open`. The base hint
# does not contain the scroll atom or the erase atom. The select list adds each
# one only when its key does something: ←→ only while the highlighted row is
# too long, and Del only on a thread that has history to lose. The three
# together must fit in 72 cells (the list of the archive preview has the same
# calculation). That limit is the reason for `Del erase`. The row already names
# the conversation, so the atom uses its cells for the verb. Also, `erase` is
# the word that the app uses in other places when it removes data.
footer_hint="↑↓ move · type to filter · Enter open · Esc back",
delete_hint="Del erase",
)
opened = 0
shown = 0
async with ctx.ui.session.stay(picker) as visit:
while True:
choice = await visit.result()
if isinstance(choice, DeleteRequest):
await self._delete_history(ctx, choice.value)
elif choice is CANCEL or choice is None: # Esc: back to the menu
return ToolResult(summary={"conversations": opened, "messages": shown})
else:
shown = await open_chat(ctx, choice)
opened += 1
picker.replace_items(await self._picker_items(ctx))
async def _picker_items(self, ctx: AppContext) -> list:
"""Make the rows of the picker: the pinned lane names, then Channels, Rooms, and Direct.
The rows are read again each time that the list is made or replaced. Thus a thread
that got new messages is at the position that its recency gives it. It costs
little to do this after each chat: the two device reads go through the session
cache, and the rest is stored history.
Args:
ctx: The shared application context.
Returns:
The :class:`~meshterm.ui.tui.select.Choice` and ``Separator`` rows, in the
order on the screen.
"""
# Read through the session cache. This picker runs each time that Chat opens. Its
# two reads (the channel slot probe and the contacts table) are the two slowest
# round trips on a companion. When MeshTerm read them from the device each time,
# Chat stopped for seconds when it opened (the cached chat screen behind it had no
# chance to help). The cache keeps the channels until the channel editor writes a
# slot, and reads the contacts again in the background (refer to
# :class:`~meshterm.services.device_state.DeviceState`).
channels = _channels_from_slots(await ctx.devstate.channel_slots())
contacts = await ctx.devstate.contacts()
# Direct lists only companion nodes. We do not send direct messages to repeaters,
# rooms, or sensors. A contact that never advertised its type is accepted (the
# app-wide DM rule, refer to is_direct_messageable). Room servers have their
# own section: a room is a place that you join and post to, not a person to whom
# you send a message.
companions = [c for c in contacts if is_direct_messageable(c.node_type)]
# Only the rooms that we joined, in the same way as only the channels on a slot are
# listed. The Rooms page joins rooms (refer to meshterm.ui.rooms).
rooms = [
Conversation(label=c.name, is_channel=False, contact=c)
for c in contacts
if is_room(c.node_type) and ctx.rooms.joined(c)
]
room_peers = [conv.peer for conv in rooms if conv.peer]
# A stable snapshot sets the order of the rows (so that the list does not change its
# order under the highlight). A lookup that reads itself again gives the live preview
# of each row (refer to _LiveLasts). The latest message of a room is its latest
# *post*.
lasts = ctx.repo.last_chat_messages(rooms=room_peers)
live = _LiveLasts(ctx, seed=lasts, rooms=room_peers)
# The names in previews and mentions resolve back to keys for their hue (the
# app-wide colour rule). A name that no contact and no stored advert has stays
# muted. A room post names its author by key, which resolves in the other
# direction.
stored_names, named_at = ctx.repo.node_names_with_times()
key_of = make_name_key_resolver(contacts, stored_names, heard=named_at)
name_of = make_node_resolver(contacts, stored_names)
# Our name and key, for our own messages and the mentions of us in the previews.
from ..ui.chat import our_node
us = await our_node(ctx)
# List the contacts by recency: first the contacts with messages, with the newest
# exchange at the top. Then the contacts with no messages, in alphabetical order
# (refer to _recency_key). The same for rooms.
direct = [Conversation(label=c.name, is_channel=False, contact=c) for c in companions]
direct.sort(key=lambda conv: _recency_key(conv, lasts))
rooms.sort(key=lambda conv: _recency_key(conv, lasts))
# One measurement for the full list, the headings and the rows, done before they are
# made. The name lane is only as wide as the names in it, and each cell that it does
# not use goes to the message preview (refer to _lanes).
lanes = _lanes(channels + rooms + direct)
# The lane names pin for the full picker (they have the same meaning in the two
# groups). Thus, when the list scrolls into Direct, they stay at the top, with the
# heading of that group under them. Otherwise the header goes away after one row.
# Refer to Screen.sticky_rows.
items: list = [
Separator(lambda w: _picker_header(lanes, w), pinned=True),
section_heading("Channels"),
]
for conversation in channels:
items.append(
Choice(
title=_row_title(ctx, conversation, live, key_of, lanes, us=us),
value=conversation,
# ←→ move only the message. The lanes before it stay (_Lanes.head).
hscroll_from=lanes.head,
)
)
items.append(section_heading("Rooms"))
for conversation in rooms:
items.append(
Choice(
title=_row_title(
ctx, conversation, live, key_of, lanes, name_of=name_of, us=us
),
value=conversation,
deletable=lasts.get(conversation.key) is not None,
hscroll_from=lanes.head,
)
)
if not rooms:
# Show this, do not omit it. Users come here with the question "how do I get on
# a room?", and an empty section tells them where to go.
items.append(Separator(" no rooms joined — join one on the Rooms page"))
items.append(section_heading("Direct"))
if companions:
for conversation in direct:
items.append(
Choice(
title=_row_title(ctx, conversation, live, key_of, lanes, us=us),
value=conversation,
# Del offers to delete the stored history of this thread, only
# where there is history to delete.
deletable=lasts.get(conversation.key) is not None,
hscroll_from=lanes.head,
)
)
else:
items.append(Separator(" no contacts yet — receive an advert first"))
return items
@staticmethod
async def _delete_history(ctx: AppContext, conversation: Conversation) -> None:
"""Confirm, then delete the stored history of one direct conversation or one room.
A delete of history is a data loss that cannot be undone. Thus the confirm has the
reserved red (``destructive``): Cancel on the left, and the committing Delete on
the right. When the user confirms, MeshTerm deletes the messages of the peer from
the database and clears the unread count of the thread. The contact itself (a
record on the device) does not change. The posts of a room go only from this
machine. The room keeps its own posts, and does not send them again, because it
got the acknowledgements for them.
"""
prompt = (
f"Delete the posts stored from {conversation.label}? They are removed from "
"this machine, and the room won't send them again."
if conversation.is_room
else f"Delete the chat history with {conversation.label}? Every stored "
"message in this conversation is removed."
)
if not await ctx.ui.dialog(
prompt,
[("Cancel", False), ("Delete", True)],
title="Delete history",
default=1,
destructive=True,
):
return
ctx.repo.delete_chat_history(conversation.peer)
ctx.chat.clear_unread(conversation.key)
# -- CLI --------------------------------------------------------------------
async def _run_cli(self, ctx: AppContext, action: str, params: dict[str, Any]) -> ToolResult:
"""Dispatch a scripted CLI action.
Args:
ctx: The shared application context.
action: One of ``send``, ``history``, ``listen``, ``list``.
params: The arguments of the action.
Returns:
A :class:`ToolResult` for the action.
"""
if action == "send":
return await self._cli_send(ctx, params)
if action == "history":
return await self._cli_history(ctx, params)
if action == "listen":
return await self._cli_listen(ctx, params)
return await self._cli_list(ctx)
async def _cli_send(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
"""Send a channel message or a direct message, and report whether it was acknowledged.
A channel broadcast has nothing to report, because a channel has no
acknowledgement. Thus it prints nothing, and the exit status says that the message
went out. A direct message prints its ``acked`` state. That is the one thing that
the send does not already tell the caller: the radio accepted the message in the
two cases, and the answer of the peer is a separate fact.
A channel message goes out under the send scope of the channel, or under ``scope``
for this one message (a region name, or ``*`` for unscoped). The document says
which scope it went out under. If the radio cannot send under the scope, it
refuses before it transmits anything. That is a failure (exit 1), never a silent
unscoped send.
``--to`` a room server posts to the room. The room acknowledges a post after it
stores it, so ``acked`` has the same meaning. A room that no longer knows us (it
restarted, or we never joined) discards the post with no acknowledgement. Use
``rooms join`` first.
"""
device = await ctx.device()
text = str(params["text"])
channel = params.get("channel")
if channel is not None:
message = await ctx.chat.send_channel(
int(channel), text, label=f"#{channel}", scope=params.get("scope") or None
)
return ToolResult(
summary={"channel": channel, "sent": True, "scope": message.scope},
report=(_sent(channel=int(channel), scope=message.scope),),
)
contact = _resolve_contact(await device.get_contacts(), str(params["to"]))
if contact.is_room:
message = await ctx.chat.send_post(contact, text)
else:
message = await ctx.chat.send_direct(contact, text)
return ToolResult(
summary={"to": contact.name, "acked": bool(message.acked)},
report=(_sent(contact=contact, acked=bool(message.acked)),),
)
async def _cli_history(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
"""Print the stored transcript of a conversation. For a room, print its board by author.
The transcript of a room is its posts. Its command replies are not in it, the same
as on its board in the menu. Where a direct transcript names the peer, the
transcript of a room names the author of each post. The reason: the room is the
conversation that the caller already named, and the posts in it have different
writers. Our own posts name our node, as each of our hops does on this face.
"""
limit = int(params.get("limit") or _HISTORY_LIMIT)
channel = params.get("channel")
to = params.get("to")
room = False
name_of: NodeResolver | None = None
me: NodeRef | None = None
if channel is not None:
channel_id = await ctx.chat.channel_id_for(int(channel))
messages = ctx.repo.recent_chat_messages(
is_channel=True, channel_id=channel_id, limit=limit
)
label = f"#{channel}"
else:
device = await ctx.device()
contacts = await device.get_contacts()
contact = _resolve_contact(contacts, str(to))
room = contact.is_room
messages = ctx.repo.recent_chat_messages(
is_channel=False,
peer=contact.key_prefix or contact.public_key[:12],
limit=limit,
posts_only=room,
)
label = contact.name
if room:
name_of = make_node_resolver(contacts, ctx.repo.node_names())
me = _self_ref(await device.get_self_info())
return ToolResult(
summary={"conversation": label, "messages": len(messages)},
report=(_transcript(messages, room=room, name_of=name_of, me=me),),
exit_code=exitcodes.OK if messages else exitcodes.NO_RESULT,
)
async def _cli_listen(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
"""Show the received messages live in the console (and store them in the history).
A receiver on the plain console, outside the full-screen menu. It starts the
listener that always runs, and prints each message when it arrives. It is also a
diagnostic: with the debug logging of the pump on (``--debug`` on the CLI), it
shows whether MeshTerm gets messages from the companion.
Args:
ctx: The shared application context.
params: ``seconds``: how long to listen (``0`` or ``None`` means until an
interrupt).
Returns:
A :class:`ToolResult` with the number of heard messages.
"""
from ..ui import script
seconds = params.get("seconds") or 0
await ctx.device()
await ctx.chat.start() # also starts to store the received messages in the history
# This line is about the run, and is not part of its answer. Thus it goes to stderr,
# so that a redirected stdout holds only messages.
script.stderr_console().print(
"listening for messages"
+ (f" for {seconds}s" if seconds else " — press Ctrl-C to stop"),
style="muted",
highlight=False,
)
seen = 0
def on_message(event: MeshEvent) -> None:
nonlocal seen
message = event.message
if message is None:
return
seen += 1
channel = (
_channel_ref(message.channel, f"#{message.channel}", None)
if message.is_channel
else None
)
emit(
{
"received_at": message.received_at,
"conversation": channel.name if channel else (message.sender or None),
"direction": "in",
"node": None if message.is_channel else NodeRef(hash=message.sender),
"channel": channel,
"author": NodeRef(hash=message.author) if message.author else None,
"snr_db": message.snr,
"text": message.text,
"acked": None,
}
)
with renderers.stream(ctx, _live_messages()) as emit:
unsubscribe = ctx.events.subscribe(on_message, EventKind.MESSAGE)
try:
if seconds:
await asyncio.sleep(seconds)
else:
await asyncio.Event().wait() # until Ctrl-C or a cancellation
except (KeyboardInterrupt, asyncio.CancelledError): # pragma: no cover - interactive
pass
finally:
unsubscribe()
return ToolResult(
summary={"messages": seen},
message=f"[muted]stopped — heard {seen} message{'' if seen == 1 else 's'}[/muted]",
exit_code=exitcodes.OK if seen else exitcodes.NO_RESULT,
)
async def _cli_list(self, ctx: AppContext) -> ToolResult:
"""List the channels, the contacts, and the most recent message of each."""
device = await ctx.device()
channels = await _read_channels(device)
contacts = await device.get_contacts()
rooms = [(c.key_prefix or c.public_key[:12]).lower() for c in contacts if c.is_room]
lasts = ctx.repo.last_chat_messages(rooms=rooms)
rows = [*channels] + [
Conversation(label=c.name, is_channel=False, contact=c) for c in contacts
]
return ToolResult(
summary={"conversations": len(rows)},
report=(_conversations(ctx, rows, lasts),),
exit_code=exitcodes.OK if rows else exitcodes.NO_RESULT,
)
def register_cli(self, app: typer.Typer) -> None:
"""Register the ``chat`` subcommand group.
Args:
app: The Typer application.
"""
from ..cli import run_tool_command
chat_app = typer.Typer(help=self.help, no_args_is_help=True, rich_markup_mode=None)
@chat_app.command("send", help="Send a message to a contact or channel, or post to a room")
def _send_cmd(
text: str = typer.Argument(..., help="The message body"),
to: str | None = typer.Option(None, "--to", help="Contact or room name, or key prefix"),
channel: int | None = typer.Option(None, "--channel", help="Channel slot index"),
scope: str | None = typer.Option(
None,
"--scope",
help="Send under this region instead of the channel's scope (* = unscoped)",
),
) -> None:
if (to is None) == (channel is None):
raise typer.BadParameter("Pass exactly one of --to / --channel.")
if scope is not None:
scope = _cli_scope(scope, channel)
run_tool_command(
self,
{"cli_action": "send", "to": to, "channel": channel, "text": text, "scope": scope},
)
@chat_app.command("history", help="Show a conversation's stored history")
def _history_cmd(
to: str | None = typer.Option(None, "--to", help="Contact or room name, or key prefix"),
channel: int | None = typer.Option(None, "--channel", help="Channel slot index"),
limit: int = typer.Option(_HISTORY_LIMIT, "--limit", help="Max messages to show"),
) -> None:
if (to is None) == (channel is None):
raise typer.BadParameter("Pass exactly one of --to / --channel.")
run_tool_command(
self,
{"cli_action": "history", "to": to, "channel": channel, "limit": limit},
)
@chat_app.command("list", help="List channels, rooms, contacts, and recent messages")
def _list_cmd() -> None:
run_tool_command(self, {"cli_action": "list"})
app.add_typer(chat_app, name=self.name)
# -- helpers ------------------------------------------------------------------
def _cli_scope(scope: str, channel: int | None) -> str:
"""Check ``--scope`` before anything is sent: a region that the firmware can hold, or ``*``.
A bad value is a usage error (exit 2), not a device failure, because nothing was
transmitted yet. Here, a direct message has no scope to get. The firmware floods a
DM under the session scope, the same as all other packets, but only a channel send
sets a scope for itself.
Raises:
typer.BadParameter: For a direct send, or for a name that the firmware refuses.
"""
from ..core.regions import WILDCARD, RegionNameError, normalize, validate
if channel is None:
raise typer.BadParameter("--scope applies to a channel send (--channel).")
if normalize(scope) == WILDCARD:
return WILDCARD
try:
return validate(scope)
except RegionNameError as exc:
raise typer.BadParameter(str(exc)) from exc
def _enable_receive_debug() -> None:
"""Send the debug logs of the message pull and of the library to the console.
This function sets the ``meshcore`` logger and the logger of the connection layer to
DEBUG, and attaches a stderr handler. Thus ``chat listen --debug`` shows whether
MeshTerm pulls received messages from the companion (the receive path) at all.
"""
import logging
import sys
handler = logging.StreamHandler(sys.stderr)
handler.setFormatter(logging.Formatter("%(name)s %(levelname)s %(message)s"))
for name in ("meshcore", "meshterm.core.connection"):
log = logging.getLogger(name)
log.setLevel(logging.DEBUG)
log.addHandler(handler)
def _channels_from_slots(slots: list[ChannelSlot]) -> list[Conversation]:
"""Change cached channel slots into channel conversations, and always offer Public (slot 0).
The picker reads its channels through the session cache
(:meth:`~meshterm.services.device_state.DeviceState.channel_slots`). Thus it uses the one
slow slot probe again, and does not read each slot again each time that Chat opens.
This function maps that cached :class:`~meshterm.core.channel_probe.ChannelSlot` list
onto the :class:`~meshterm.core.models.Conversation` rows of the picker. When the
firmware reports no slot for channel 0 (the default public channel), this function
makes one. Thus there is always a place to chat. :func:`_read_channels` (the CLI path)
gives the same guarantee.
Args:
slots: The configured channel slots, from the session cache.
Returns:
One :class:`~meshterm.core.models.Conversation` for each slot, with Public at the
start when slot 0 is absent.
"""
conversations = [
Conversation(
label=slot.name,
is_channel=True,
channel_idx=slot.idx,
channel_id=channel_identity(slot.name, slot.secret),
secret=slot.secret,
)
for slot in slots
]
if not any(c.channel_idx == 0 for c in conversations):
conversations.insert(
0,
Conversation(
label="Public",
is_channel=True,
channel_idx=0,
channel_id="slot:0",
secret=DEFAULT_PUBLIC_SECRET,
),
)
return conversations
async def _read_channels(device: Device) -> list[Conversation]:
"""Probe the channel slots, and return them as channel conversations.
This function always offers channel 0 (the default public channel), also when the
firmware reports no configured slots. Thus there is always a place to chat.
Args:
device: The connected device to probe.
Returns:
One :class:`~meshterm.core.models.Conversation` for each configured channel (at
least channel 0).
"""
conversations: list[Conversation] = []
for idx in range(CHANNEL_SLOT_PROBE_CAP):
try:
channel = await device.get_channel(idx)
except Exception: # noqa: BLE001 - the firmware may not support channel reads
break
if channel:
name = str(channel.get("channel_name") or idx)
secret = bytes(channel.get("channel_secret") or b"\x00" * 16)
conversations.append(
Conversation(
label=name,
is_channel=True,
channel_idx=idx,
channel_id=channel_identity(name, secret),
secret=secret,
)
)
if not any(c.channel_idx == 0 for c in conversations):
conversations.insert(
0,
Conversation(
label="Public",
is_channel=True,
channel_idx=0,
channel_id="slot:0",
secret=DEFAULT_PUBLIC_SECRET,
),
)
return conversations
def _resolve_contact(contacts: list[Contact], needle: str) -> Contact:
"""Find a contact by its exact name (case-insensitive), or by a key prefix that matches.
Args:
contacts: The known contacts.
needle: A contact name, or a prefix of a public key.
Returns:
The matching contact.
Raises:
typer.BadParameter: If no contact matches ``needle``.
"""
folded = needle.casefold()
for contact in contacts:
pub = (contact.public_key or "").lower().removeprefix("0x")
if contact.name.casefold() == folded or pub.startswith(folded):
return contact
raise typer.BadParameter(f"no contact matches {needle!r}")
def _recency_key(conversation: Conversation, lasts: dict) -> tuple:
"""A sort key that puts the conversations in order of recency, then of name.
The conversations that have messages come first, with the most recent exchange at the
top. The conversations with no messages come after them, in alphabetical order of
label. The ``0`` or ``1`` at the start keeps the two groups apart. Thus their
tie-breakers, which have different types, are never compared.
Args:
conversation: The conversation to rank.
lasts: A map from the conversation key to its most recent message.
Returns:
A tuple that ``sorted`` can use as a key.
"""
last: ChatMessage | None = lasts.get(conversation.key)
if last is not None:
return (0, -last.created_at.timestamp())
return (1, conversation.label.casefold())
class _LiveLasts:
"""A lookup of the most recent message of each conversation, which reads itself again.
The picker takes one snapshot of
:meth:`~meshterm.persistence.repository.Repository.last_chat_messages` to set the *order*
of the rows (so that the list never changes its order under the highlight). But the row
previews read through this lookup. Thus, when a message arrives while the list is open,
the next paint shows the new sender and text in the preview. The lookup queries the
repository again a few times each second at most (``ttl`` sets the limit), not one time
for each row on each paint. Thus a wide list stays fast.
"""
def __init__(
self, ctx: AppContext, *, seed: dict, ttl: float = 0.5, rooms: list[str] | None = None
) -> None:
"""Bind to a context, and start the cache with the snapshot that was read at open.
``rooms`` are the peers of the room servers in the conversations. Thus a new read
gets the latest *post* of a room, the same as the first snapshot did (refer to
``Repository.last_chat_messages``).
"""
self._ctx = ctx
self._ttl = ttl
self._cache = seed
self._rooms = list(rooms or ())
self._at = time.monotonic()
def get(self, key: str) -> ChatMessage | None:
"""Return the latest message for ``key``. Read the cache again when its TTL ends."""
now = time.monotonic()
if now - self._at >= self._ttl:
try:
self._cache = self._ctx.repo.last_chat_messages(rooms=self._rooms)
except Exception: # noqa: BLE001 - if a read error occurs, keep the last snapshot
pass
self._at = now
return self._cache.get(key)
#: The width of the unread badge lane, between the label and the age (``● 999`` fits).
_BADGE_WIDTH = 5
#: The width of the relative age lane, between the badge and the preview. It is right-aligned.
#: ``now`` and two-digit spans such as ``59m`` and ``23h`` fit. Thus the message text of each
#: row starts in the same column.
_AGE_WIDTH = 3
#: The minimum width of the conversation lane. The header word is ``CONVERSATION`` (12 cells)
#: and the lane has a gap of two cells after it. If the lane is narrower, the heading touches
#: ``NEW`` on a mesh where each name is ``Bob``.
_LABEL_MIN = 12
#: The maximum width of the lane. The lane width is the length of the longest name in it
#: (refer to :func:`_lanes`), and at this width that rule is no longer useful. Without this
#: limit, one very long name puts padding before each *short* name. This width holds the
#: long but usual shape of a repeater name (``YUL-Cartierville``). A longer name ends with
#: an ellipsis. The row only opens the conversation, and the title of its screen has the
#: full name. Each cell that this limit saves goes to the preview.
_LABEL_MAX = 16
@dataclass(frozen=True)
class _Lanes:
"""The fixed lane widths of the picker, measured one time each time the list is made.
The column header and each row get their layout from one of these objects. Thus the
headings cannot move away from the columns that they name. Only the first two widths
change. The marker has the glyph width of the platform (a channel glyph is two cells
on regular, and one cell on the PicoCalc console). The label lane has the width of the
longest name in the list, between :data:`_LABEL_MIN` and :data:`_LABEL_MAX`.
Attributes:
marker: The first glyph lane: a channel glyph or a contact dot, and its space.
label: The number of cells to which the conversation name is padded or cut with an
ellipsis.
"""
marker: int
label: int
@property
def head(self) -> int:
"""The cells before the preview: all that ←→ do not move (:attr:`Choice.hscroll_from`).
The marker, the name, the unread badge, and the age, each with the gap of two cells
after it. These columns say *which conversation this row is*. The preview is the
only part that moves, and that is the purpose. The lanes show the user where the
user is in the list, and they already fit.
"""
return self.marker + self.label + 2 + _BADGE_WIDTH + 2 + _AGE_WIDTH + 2
def _lanes(conversations: list[Conversation]) -> _Lanes:
"""Measure the lanes of the picker for the conversations that it will draw.
Before, the name lane was always 22 cells. That is wide on a terminal of 72 cells, and
very wide on the 53 cells of the PicoCalc. Each cell after the longest name in the list
is padding that the preview of the last message loses. When the width comes from the
content, a mesh of ``Alice`` and ``Bob`` keeps its names in the lane of the header word,
and gives all the other cells to the messages.
Args:
conversations: All the rows that the list will hold, channels and direct.
Returns:
The lane widths for the header and for the rows.
"""
longest = max((cell_len(c.label) for c in conversations), default=0)
return _Lanes(
# The marker is "glyph + space", and the glyph comes from the platform: two cells on
# regular, one cell on the console font. The width is read from the widget, not
# assumed. Thus the lanes of the two groups align on the two platforms: the dot of
# a contact is padded to the width of a channel glyph here (refer to
# _append_marker).
marker=cell_len(channel_glyph("Public", None)) + 1,
label=max(_LABEL_MIN, min(_LABEL_MAX, longest)),
)
def _picker_header(lanes: _Lanes, width: int) -> str:
"""Column headers above the fixed lanes of the picker (the layout is in :func:`_title`).
The indent covers the pointer column of the select screen (2 cells, drawn on choice
rows but not on separators) and the marker lane. Thus each header is exactly above its
column. ``NEW`` counts the unread messages, ``LAST`` is the age of the last message,
and ``MESSAGE`` is that message. The words are short, because the lanes under the
first two are a badge and an age, and a heading that is longer than its lane touches
the next heading. ``LAST`` also uses the gap after its lane (the age lane is three
cells), and that leaves a space before the preview.
The header is resolved for the render width (the header row is pinned, so it must
stay one row). On a terminal that is too narrow for the full line, ``MESSAGE`` becomes
``MSG`` and gives back its cells. Thus the line does not wrap and does not lose the
label (refer to :func:`~meshterm.ui.menus.column_header`).
"""
return column_header(
[
Lane("CONVERSATION", lanes.label + 2),
Lane("NEW", _BADGE_WIDTH + 2),
Lane("LAST", _AGE_WIDTH + 2),
Lane(("MESSAGE", "MSG")),
],
width,
indent=2 + lanes.marker,
)
def _row_title(
ctx: AppContext,
conversation: Conversation,
lasts: _LiveLasts,
key_of: NameKeyResolver,
lanes: _Lanes,
*,
name_of: NodeResolver | None = None,
us: tuple[str | None, str | None] = (None, None),
) -> Callable[[], str | Text]:
"""Return the row title as a *callable* that the select screen renders on each paint.
The unread badge and the preview of the last message are both read live. Thus, when a
message arrives while the picker is open, the next paint changes the ``●`` count of
that row *and* its preview of the sender and text (the session already paints
approximately one time each second, for the header).
Args:
ctx: The shared application context (for the live unread count).
conversation: The conversation of the row.
lasts: The lookup of the latest messages, which reads itself again and gives the
preview.
key_of: Maps a sender name back to the key of its node, for the hues of the preview.
lanes: The measured lane widths of the list.
name_of: Maps the author key of a room post to a name (only for rooms).
us: The name and the key of our node (refer to
:func:`~meshterm.ui.chat.our_node`), for our own chip in the preview.
Returns:
A callable with no arguments, which makes the current row title.
"""
return lambda: _title(ctx, conversation, lasts, key_of, lanes, name_of=name_of, us=us)
def _title(
ctx: AppContext,
conversation: Conversation,
lasts: _LiveLasts,
key_of: NameKeyResolver,
lanes: _Lanes,
*,
name_of: NodeResolver | None = None,
us: tuple[str | None, str | None] = (None, None),
) -> str | Text:
"""Make a picker row from lanes of fixed width, with colour codes.
The alignment makes the row easy to read. The marker, the label, the unread badge, the
relative age, and the preview each have their own lane. Thus the message text of each
row starts in the same column. Each colour has a purpose:
- The name of a direct contact gets the palette hue that comes from its key (a channel
label keeps the base style).
- The first dot has the colour of the companion mark, and its shape shows the history.
- The unread ``●`` badge is red, and the age is muted.
- The preview mutes its body, and lights the sender names and the ``@mentions`` in the
hues that come from their keys. These are the same colours as in the live transcript.
The row is always a Rich :class:`~rich.text.Text`, so that those spans stay under the
row highlight of the select screen.
Args:
ctx: The shared application context (for the live unread count).
conversation: The conversation of the row.
lasts: The lookup of the latest messages, which reads itself again.
key_of: Maps a sender name or a mention name in the preview back to the key of its
node.
lanes: The measured lane widths of the list.
name_of: Maps the author key of a room post to a name (only for rooms).
us: The name and the key of our node, for our own chip in the preview.
Returns:
The row title as a styled :class:`~rich.text.Text`.
"""
unread = ctx.chat.unread(conversation.key)
last = lasts.get(conversation.key)
text = Text(no_wrap=True, overflow="ellipsis")
_append_marker(text, conversation, last, lanes)
label_style = ""
if not conversation.is_channel and conversation.contact is not None:
contact = conversation.contact
label_style = name_style(conversation.label, contact.public_key or contact.key_prefix)
text.append(fit_cells(conversation.label, lanes.label), style=label_style or None)
text.append(" ")
# The unread badge lane (_BADGE_WIDTH cells): a red ● with the count in warn, or blank
# spaces. Thus the lanes after it still align on rows with nothing unread.
if unread:
text.append("●", style="err")
text.append(f" {unread}".ljust(_BADGE_WIDTH - 1), style="warn")
else:
text.append(" " * _BADGE_WIDTH)
# The relative age lane (right-aligned) is between the badge and the message text. Thus
# the ages are in one neat column, and all the previews start at the same position. A
# last message that names us has our white ``★`` at the left of its age, the same as
# the transcript has it at the left of the time (refer to ChatScreen._body_lines).
age = _ago(last.created_at) if last is not None else ""
lane = f" {age:>{_AGE_WIDTH}}"
if age and last is not None and _names_us(last, us):
mark = len(lane) - len(age) - 2 # the mark, a space, then the age
text.append(lane[:mark])
text.append_text(mention_mark(us[1]))
text.append(lane[mark + 1 :], style="muted")
else:
text.append(lane, style="muted")
text.append(" ")
if last is not None:
text.append_text(_preview_text(last, key_of, name_of, us=us))
return text
#: The colour of the companion mark (the shared colour of the plain node ``●``) is the
#: colour of the contact dot. The shape (filled or hollow) shows the history, and the name
#: next to it has the hue that comes from the key of the person.
_COMPANION_DOT_STYLE = NODE_GLYPHS[NODE_TYPE_CHAT][1]
#: The marker of a room: the ``■`` of the room server, in the colour of its type. The map
#: and the contact list give a room the same mark. The console font has no hollow square,
#: so the marker shows no history state. The preview lane says whether there is something
#: to read, or a room to join.
_ROOM_MARK = NODE_GLYPHS[NODE_TYPE_ROOM]
def _append_marker(
text: Text, conversation: Conversation, last: ChatMessage | None, lanes: _Lanes
) -> None:
"""Add the first marker of the row (a channel glyph or a contact dot) in its own lane.
A channel keeps its marker for open or private (# / 🌐 / 🔒). A contact gets a small
circle in the colour of the companion mark. It is filled (``●``) after we exchanged
messages, and a hollow ring (``○``) before that. Thus the shape (hollow or filled)
shows whether there is history, and the name itself has the hue that comes from the
key of the person.
The dot is padded to :attr:`_Lanes.marker`, which is measured from the channel glyph
itself, not assumed. That glyph is two cells on regular and one cell on the PicoCalc
console font. A fixed pad aligned the two sections on one platform. On the other
platform, it put each channel row one cell to the left of each direct row.
"""
if conversation.is_channel:
glyph = channel_glyph(conversation.label, conversation.secret)
text.append(glyph + " " * max(1, lanes.marker - cell_len(glyph)))
elif conversation.is_room:
mark, style = _ROOM_MARK
text.append(mark, style=style)
text.append(" " * max(1, lanes.marker - cell_len(mark)))
else:
dot = "●" if last is not None else "○"
text.append(dot, style=_COMPANION_DOT_STYLE)
text.append(" " * max(1, lanes.marker - cell_len(dot)))
def _preview_text(
last: ChatMessage,
key_of: NameKeyResolver,
name_of: NodeResolver | None = None,
*,
us: tuple[str | None, str | None] = (None, None),
) -> Text:
"""A muted preview of the last message, with sender names and ``@mentions`` lit in their hue.
It is the same as the live transcript:
- Our own messages start with our chip, ``★ Name`` in white (refer to
:func:`~meshterm.ui.widgets.self_chip`). A mention of our name has the chip that is
white label, with no star. A message that names us has our white ``★`` at the
left of the age of the row (refer to :func:`_title`).
- In a received channel message, the inline ``Name:`` sender is the author, in the hue
that comes from its key (the node grey when no known node has the name).
- A room post starts with its author, named by key in the same way as the room screen
names authors (:func:`~meshterm.ui.room.author_label`).
- Each ``@[Name]`` mention shows as the chip of the node that it names, in the same
way (refer to :func:`_append_body`).
Thus the list and the chat use the same colour language, and where the terminal draws
chips, the same chips: the author closes on the point and a mention opens on the notch.
The preview is made *in full*, however long the message is. Before, it was cut to a
fixed 40 cells, and nothing could undo that cut. Now ←→ scroll the row. A preview that
was cut before to approximately the visible lane has nothing more to show. The list
cuts it at the right edge, the same as any other row, and the scroll goes past that.
"""
body_raw = _reaction_line(last) or last.text.replace("\n", " ")
text = Text()
if last.outbound:
name, key = us
chip = self_chip(name, key, role=ORIGIN)
# Where no chips are drawn, the chip is a padded block, white on our fill. Our node
# with no name keeps the old byline.
byline = chip + Text(" ") if name and key else Text("you: ", style="accent")
_append_author(text, chip, byline)
_append_body(text, body_raw, key_of, us)
elif last.is_channel:
name, body = split_channel_sender(body_raw)
if name is not None:
key = key_of(name)
_append_author(text, _origin(name, key), _byline(name, key))
_append_body(text, body, key_of, us)
else:
_append_body(text, body_raw, key_of, us)
elif last.author and name_of is not None:
from ..ui.room import author_label
name, key = author_label(last.author, name_of)
_append_author(text, _origin(name, key), _byline(name, key))
_append_body(text, body_raw, key_of, us)
else:
_append_body(text, body_raw, key_of, us)
return text
def _reaction_line(last: ChatMessage) -> str | None:
"""The preview body of a reaction, ``reacted 👍``, or ``None`` for a usual message.
A reaction is a message on the wire, and it can be the latest message of a
conversation. Its raw body (a mention, a newline, and a hash) means nothing to a person.
The name of the node keeps its place in front of the body, as for each message.
"""
if last.author is not None:
return None # a room post: rooms have no reactions
body = (
split_channel_sender(last.text)[1] if last.is_channel and not last.outbound else last.text
)
reaction = reactions.parse(body.strip(" \t"), channel=last.is_channel)
if reaction is None:
return None
line = f"reacted {emoticon(reaction.emoji)}"
if last.is_channel and not last.outbound:
name = split_channel_sender(last.text)[0]
return f"{name}: {line}" if name else line
return line
def _origin(name: str, key: str | None) -> Text:
"""The chip of the author of a preview: it closes on the point, as in the transcript."""
return author_chip(name, key)
def _append_author(text: Text, chip: Text, byline: Text) -> None:
"""Append the author of a preview: its origin ``chip`` where chips are drawn, else ``byline``.
The chip closes on the point, the same as the sender chip of the transcript, and the
body follows it after one space. The point says what the colon of ``Name:`` says, so
the chip does not carry a colon. Where the terminal draws no chips, the author is the
``byline`` (``Name: ``, or ``you: `` for us), as it always was (JP, 2026-10-10).
"""
if powerline_enabled():
text.append_text(chip)
text.append(" ")
else:
text.append_text(byline)
def _byline(name: str, key: str | None) -> Text:
"""The author of a preview as text: the name in its hue, then a muted colon."""
text = Text(name, style=name_style(name, key))
text.append(": ", style="muted")
return text
def _append_body(
text: Text,
body: str,
key_of: NameKeyResolver,
us: tuple[str | None, str | None] = (None, None),
) -> None:
"""Append ``body`` to ``text``, muted, and light the names that it mentions.
Each ``@[Name]`` is drawn in the hue that comes from the key of that node, as a chip
that opens on the notch where the terminal draws chips (the same as in the transcript,
refer to :func:`~meshterm.ui.widgets.mention_chip`). It takes the node grey when the
name resolves to no node that we know. A mention of our own name (``us``) has the chip
of our key with a white label, as in the transcript.
"""
pos = 0
for match in MENTION.finditer(body):
if match.start() > pos:
text.append(body[pos : match.start()], style="muted")
name = match.group(1)
if us[0] and name == us[0]:
text.append_text(mention_chip(name, us[1], you=True))
else:
text.append_text(mention_chip(name, key_of(name)))
pos = match.end()
if pos < len(body):
text.append(body[pos:], style="muted")
def _names_us(last: ChatMessage, us: tuple[str | None, str | None]) -> bool:
"""Whether the message ``last`` has a mention of our name (``us``)."""
name = us[0]
return bool(name) and any(match.group(1) == name for match in MENTION.finditer(last.text))
def _ago(when: Any) -> str:
"""The column age for a message time, through the only grammar (``format_age``).
There is one difference from the widget, on purpose. A missing or naive timestamp (a
bad value from the wire) shows as a *blank* lane, not as ``never``. The picker must
have an empty cell there, not a word.
"""
if getattr(when, "tzinfo", None) is None:
return ""
return format_age(age_seconds(when))
def _channel_ref(idx: int | None, label: str, secret: bytes | None) -> ChannelRef:
"""One channel in the shared shape, from what the surface knows about it.
A transcript row and a live message know a slot and a label, but not the key. Thus,
when the secret is not available, ``public`` comes from the name. A ``#`` at the start
of the name means a public channel, and the app writes a public channel that way in
all other places.
"""
return ChannelRef(
slot=int(idx or 0),
name=label,
public=is_public_channel(label, secret) if secret else label.startswith("#"),
hash=channel_hash(secret) if secret else None,
)
def _message_columns(*, author_lane: bool = False) -> tuple:
"""The columns of one transcript row, shared by ``history`` and the ``listen`` stream.
``DIR`` holds what the transcript of the menu writes into its sender lane as the word
"you". The direction of a message is a separate fact. When the name lane held it, our
own name became a value that the lane could not hold in other cases. Thus ``PEER`` is
always the *other* party.
``TEXT`` is last and escaped. It is the rest of the line, and "the rest of the line"
is not true when a body holds a newline. After the newline, the rest looks like a
second record with an empty time. The document holds the raw body, because a JSON
string can hold a newline.
``author`` is the writer of a room post. It is in each document (``null`` when the
conversation is not a room), so that one command answers in one shape. For the
transcript of a room, ``author_lane`` also draws it as ``AUTHOR``, because there it is
the column that the user wants to see.
"""
from dataclasses import replace
from ..ui import fields
peer = fields.plain_only(fields.name("conversation", "PEER"))
return (
fields.when("created_at", "TIME"),
fields.word("direction", "DIR"),
# One column for the other end of the conversation, whichever type it is. The
# document keeps the two types apart, because a parser cannot tell a channel from a
# contact by its label. The transcript of a room names the room on each row, so
# there AUTHOR is in its place.
replace(peer, lanes=()) if author_lane else peer,
fields.node("node", lanes=()),
fields.channel("channel", lanes=()),
_author_column(lane=author_lane),
fields.snr("snr_db", "SNR_DB"),
fields.free("text", "TEXT"),
fields.hidden("acked"),
)
def _author_column(*, lane: bool) -> Any:
"""The author of a room post: the shared node object, drawn as one hop (``Name (hash)``).
It uses the grammar of a hop (:func:`~meshterm.ui.script.route`), because that is the
one way in which the plain face names a node by name *and* key. When nothing names an
author, the author is only its hash, never an empty ``()``.
"""
from dataclasses import replace
from ..ui import fields, script
from ..ui.report import Lane
column = fields.node("author", lanes=())
if not lane:
return column
def hop(ref: NodeRef | None) -> str:
return script.route([(ref.name, ref.hash)]) if ref is not None else script.NONE
return replace(column, lanes=(Lane(header="AUTHOR", render=hop),))
def _self_ref(info: dict) -> NodeRef:
"""Our node, from the self info of the companion: how one of our posts names its author."""
key = str(info.get("public_key") or "").lower()
return NodeRef(
name=str(info.get("name") or "") or None,
key=key or None,
hash=key[:8] or None,
type=NODE_TYPE_LABELS.get(info.get("adv_type")),
is_self=True,
)
def _author_ref(author: str, name_of: NodeResolver | None) -> NodeRef:
"""The author of a post as a node: always its hash, and its name if something names it."""
from ..ui.room import author_label
label, key = author_label(author, name_of) if name_of is not None else (author, None)
return NodeRef(name=label if key else None, hash=author)
def _transcript(
messages: list[ChatMessage],
*,
room: bool = False,
name_of: NodeResolver | None = None,
me: NodeRef | None = None,
) -> Listing:
"""The stored messages of a conversation, oldest first, one record for each.
Args:
messages: The transcript.
room: Whether it is the board of a room. Then each row names its author, in an
``AUTHOR`` lane in the place of ``PEER`` (because the peer is the room on
each row).
name_of: Names the key of an author, for a room.
me: Our node, the author of our own posts in a room.
"""
from ..ui.report import Listing
rows = []
for message in messages:
channel = (
_channel_ref(message.channel_idx, message.peer_name or f"#{message.channel_idx}", None)
if message.is_channel
else None
)
rows.append(
{
"created_at": message.created_at,
"direction": "out" if message.outbound else "in",
"conversation": message.peer_name or message.peer or None,
"node": None
if message.is_channel
else NodeRef(name=message.peer_name, hash=message.peer),
"channel": channel,
"author": (
_author_ref(message.author, name_of)
if message.author
else (me if room and message.outbound else None)
),
"snr_db": message.snr,
"text": message.text,
"acked": message.acked,
}
)
return Listing(
key="messages",
columns=_message_columns(author_lane=room),
rows=rows,
order=("TIME", "DIR", "AUTHOR" if room else "PEER", "SNR_DB", "TEXT"),
)
def _live_messages() -> Listing:
"""The shape that ``chat listen`` streams: what arrived, when it arrived.
Here ``TIME`` is absolute, but in the transcript it is an age. The reason is the base
of the full time rule: with ages, each row of a live tail shows ``now``, which gives no
information. There is no ``DIR`` lane, because all that a tail hears came in. The
body goes last, so that the one field with no width can never push a lane.
"""
from dataclasses import replace
from ..ui import fields
from ..ui.report import Listing
def pin(column, width: int): # noqa: ANN001, ANN202 - one column in, one column out
return replace(column, lanes=tuple(replace(lane, width=width) for lane in column.lanes))
return Listing(
key="messages",
columns=(
pin(fields.instant("received_at", "TIME"), 25),
pin(fields.plain_only(fields.name("conversation", "PEER")), 16),
fields.node("node", lanes=()),
fields.channel("channel", lanes=()),
# The writer of a room post is in the document. The tail itself names the room,
# which is the node that sent the post.
_author_column(lane=False),
pin(fields.snr("snr_db", "SNR_DB"), 6),
fields.free("text", "TEXT"),
fields.hidden("direction"),
fields.hidden("acked"),
),
order=("TIME", "PEER", "SNR_DB", "TEXT"),
)
def _conversations(ctx: AppContext, rows: list, lasts: dict) -> Listing:
"""Each channel and contact, with the last message in each.
``LAST`` is an age, not an instant. A user looks through a conversation list for the
conversations that are active. ``never`` is a real answer: the conversation exists,
and it has no messages.
"""
from ..ui import fields
from ..ui.report import Listing
records = []
for conversation in rows:
last = lasts.get(conversation.key)
channel = (
_channel_ref(conversation.channel_idx, conversation.label, conversation.secret)
if conversation.is_channel
else None
)
contact = getattr(conversation, "contact", None)
if conversation.is_channel:
kind = "channel"
elif conversation.is_room:
kind = "room"
else:
kind = "direct"
records.append(
{
"kind": kind,
"conversation": conversation.label,
"channel": channel,
"node": None
if conversation.is_channel
else NodeRef(
name=conversation.label,
key=(getattr(contact, "public_key", "") or "").lower() or None,
hash=(getattr(contact, "key_prefix", "") or "").lower() or None,
type=NODE_TYPE_LABELS.get(getattr(contact, "node_type", None)),
),
"unread": ctx.chat.unread(conversation.key),
"last_message_at": last.created_at if last is not None else None,
# Not cut to 40 cells, as the preview of the picker is. Nothing here wraps,
# so there is no reason to make a message shorter. But a body that holds a
# newline still ends the record, so it is folded flat.
"last_text": last.text if last is not None else None,
}
)
return Listing(
key="conversations",
columns=(
fields.word("kind", "KIND"),
fields.plain_only(fields.name("conversation", "CONVERSATION")),
fields.channel("channel", lanes=()),
fields.node("node", lanes=()),
fields.integer("unread", "UNREAD"),
fields.when("last_message_at", "LAST", absent="never"),
fields.free("last_text", "LAST_TEXT"),
),
rows=records,
order=("CONVERSATION", "KIND", "UNREAD", "LAST", "LAST_TEXT"),
)
def _sent(
*,
contact: object | None = None,
channel: int | None = None,
acked: bool | None = None,
scope: str | None = None,
) -> Facts:
"""What one ``chat send`` did.
A direct message prints its ``acked`` state. That is the one thing that the send does
not already tell the caller: the radio accepted the message in the two cases, and the
answer of the peer is a separate fact. A channel broadcast prints nothing, because a
channel *has* no acknowledgement. The document says that in the one way that the plain
face cannot: with ``null``, not ``false``.
The document also holds the ``scope`` under which a channel message went out, in the
same shape as the scope of a received packet (:func:`~meshterm.ui.fields.scope`): the
region, ``unscoped``, or ``null`` when it is not known (a direct message, or a default
scope that MeshTerm could not read).
"""
from dataclasses import replace
from ..core.regions import UNSCOPED, WILDCARD, Scope
from ..ui import fields
from ..ui.report import PAIRS, SILENT, Facts
sent_scope = None
if scope == WILDCARD:
sent_scope = UNSCOPED
elif scope:
sent_scope = Scope("scoped", scope)
node = (
NodeRef(
name=contact.name,
key=(getattr(contact, "public_key", "") or "").lower() or None,
hash=(getattr(contact, "key_prefix", "") or "").lower() or None,
type=NODE_TYPE_LABELS.get(getattr(contact, "node_type", None)),
)
if contact is not None
else None
)
return Facts(
key="sent",
fields=(
# `acked` is the only lane. The caller named the recipient, and a zero exit
# already says that the radio accepted the message. Thus the one fact that is
# left to report is whether the peer answered.
fields.hidden("kind"),
fields.node("node", lanes=()),
fields.channel("channel", lanes=()),
fields.hidden("sent"),
fields.flag("acked", "acked"),
# Only in the document. A direct message has no scope of its own to print, and a
# channel send prints nothing.
replace(fields.scope("scope", "scope"), lanes=()),
),
values={
"kind": (
"channel"
if channel is not None
else ("room" if getattr(contact, "is_room", False) else "direct")
),
"node": node,
"channel": (
_channel_ref(channel, f"#{channel}", None) if channel is not None else None
),
"sent": True,
"acked": acked,
"scope": sent_scope,
},
shape=SILENT if channel is not None else PAIRS,
)