Files
MeshTerm/meshtools/ui/chat.py
T
Jean-Pierre MartineauandClaude Opus 4.8 a03dedb09c Add message delivery retry, channel capacity discovery, and a compose byte budget
Three interrelated improvements to the chat/channel experience:

Outgoing byte budget & unified transcript (ui/chat.py, ui/tui/prompt.py):
- Cap outgoing messages at MeshCore's UTF-8 limits (150B direct, 130B
  channel) and block over-limit sends, keeping the buffer intact to trim.
- Show a live `used/limit` gauge in the compose bar whose used count steps
  green→yellow→orange→red by bytes remaining, with truer yellow/orange hues.
- Color the overflow characters red in the compose line (multibyte-safe).
- Render direct chats with the same grouped, per-sender layout as channels
  (headers, day dividers) instead of one line per message.

Direct-message delivery retry (services/chat_service.py, persistence,
core/models.py, tools/chat.py, ui/tui/session.py):
- Track outbound acked state (None awaiting / True / False) with a persisted
  row_id, add resend_direct + update_chat_ack, and wire Ctrl-R to retry the
  latest unacknowledged message in place with ⏳/✅/❌ delivery glyphs.

Channel slot capacity discovery (core/channels.py, core/connection.py,
ui/channels.py, tools):
- Discover the firmware's real channel-slot count via a bounded read-only
  probe (Device.channel_capacity) instead of assuming 8; show used/total in
  the manager and refresh the chat slot→identity cache after any mutation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 23:12:25 -04:00

730 lines
31 KiB
Python
Raw 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.
"""The live chat screen and its launcher.
This is the interactive, full-screen chat experience: a scrolling transcript with an input
line pinned at the bottom, where messages you send and messages that arrive over the mesh
appear together in real time. Like the device picker and config editor, this module sits in
the UI layer but is allowed to depend on the context and services — it wires the
:class:`~meshtools.services.chat_service.ChatService` send path and the always-on event hub
to a :class:`~meshtools.ui.tui.screen.Screen`.
The screen subscribes to the hub for the duration it is open so inbound messages for the
current conversation append live; :class:`~meshtools.services.chat_service.ChatService`
independently persists every inbound message, so history is complete whether or not the
screen is open.
"""
from __future__ import annotations
import asyncio
import re
from typing import TYPE_CHECKING, Awaitable, Callable, Optional
from rich.console import Group, RenderableType
from rich.text import Text
from ..core.events import EventKind, MeshEvent
from ..core.models import ChatMessage, Contact, Conversation, Message, utcnow
from .theme import snr_style
from .tui.prompt import _LineEditor
from .tui.render import render_lines
from .tui.screen import CANCEL, Screen
if TYPE_CHECKING:
from ..context import AppContext
#: Fixed page step for PageUp/PageDown while scrolling the transcript (the screen isn't
#: told the viewport height, so a constant keeps paging predictable).
_PAGE = 10
#: How many past messages to load into the transcript when a conversation opens.
_HISTORY_LIMIT = 200
#: Palette of distinct, dark-theme-friendly colors cycled through to give each channel
#: sender its own hue (red is reserved for errors, so it's excluded). ``you`` and unknown
#: senders are styled separately.
_SENDER_COLORS = (
"bold #f472b6", # pink
"bold #60a5fa", # blue
"bold #34d399", # green
"bold #a78bfa", # violet
"bold #fb923c", # orange
"bold #22d3ee", # cyan
"bold #a3e635", # lime
"bold #e879f9", # fuchsia
)
#: Matches the ``Name: message`` convention channel senders use to identify themselves
#: (the protocol carries no sender field). The name is 1–20 non-colon characters and must
#: be followed by ``": "`` — conservative enough to leave ``http://…`` and ``note:x`` alone.
_SENDER_PREFIX = re.compile(r"^([^\s:][^:]{0,19}):[ \t]+(.*)$", re.DOTALL)
#: Delivery-state indicators for an outbound direct message, shown at the end of its line:
#: awaiting the ack, acknowledged, or transmitted-but-unacknowledged (retryable via Ctrl-R).
_SENDING, _DELIVERED, _FAILED = "⏳", "✅", "❌"
#: How many UTF-8 bytes a single outgoing message may carry, by conversation kind. MeshCore's
#: LoRa payload caps a direct message at 150 bytes and an (unscoped) channel broadcast at 130;
#: over the limit the companion would silently drop the packet, so we block the send instead
#: and show the running byte budget in the compose bar.
_DM_BYTE_LIMIT = 150
_CHANNEL_BYTE_LIMIT = 130
#: Byte-counter thresholds (bytes *remaining*) at which its color escalates, plus the two
#: mid-band hues. The theme's ``warn``/``err`` sit too close together (an amber that reads
#: orange, then red), so the counter names a truer yellow and orange directly to keep the
#: green→yellow→orange→red fuel gauge visibly stepped.
_BYTES_TIGHT, _BYTES_LOW = 20, 10
_BYTES_YELLOW = "bold #fde047"
_BYTES_ORANGE = "bold #ff9500"
def _delivery_glyph(acked: Optional[bool]) -> str:
"""Map an outbound direct message's ``acked`` state to its delivery emoji."""
if acked is None:
return _SENDING
return _DELIVERED if acked else _FAILED
def _split_channel_sender(text: str) -> tuple[Optional[str], str]:
"""Split a channel message into ``(sender_name, body)`` when it carries a name prefix.
Channel messages have no sender field on the wire, so senders identify themselves by
prefixing the text with ``Name: ``. Lifting that name out lets the transcript show it
as a colored header and keep the body clean.
Args:
text: The raw channel message text.
Returns:
``(name, body)`` when a plausible ``Name: `` prefix is present, else ``(None, text)``.
"""
match = _SENDER_PREFIX.match(text)
if match is None:
return None, text
name, body = match.group(1).strip(), match.group(2)
if not name or name.isdigit() or body.startswith("//"): # reject URLs / timestamps
return None, text
return name, body
class ChatScreen(Screen):
"""A live conversation: a scrolling transcript above a pinned input line.
The transcript auto-sticks to the newest message (and snaps back to the bottom whenever
you type or send). Scrolling up with the arrows / PageUp detaches from the bottom to
read history; End re-attaches. Enter sends the current line; Esc leaves the chat.
"""
floating = False
def __init__(
self,
conversation: Conversation,
messages: list[ChatMessage],
*,
send: Callable[[str], Awaitable[Optional[ChatMessage]]],
names: dict[str, str],
session, # noqa: ANN001 - TuiSession, imported lazily to avoid a cycle
resend: Optional[Callable[[ChatMessage], Awaitable[ChatMessage]]] = None,
) -> None:
"""Build the chat screen.
Args:
conversation: The thread being shown (its label titles the screen).
messages: The initial transcript (history), oldest-first.
send: Async callable that sends a line and returns the recorded outbound
message (or ``None`` if nothing was sent).
names: Map of contact key prefix to friendly name, for labeling inbound
direct messages.
session: The running :class:`~meshtools.ui.tui.session.TuiSession`, used to
request repaints when messages arrive or a send completes.
resend: Async callable that re-attempts delivery of an unacknowledged direct
message, updating it in place (direct chats only; ``None`` for channels).
"""
super().__init__()
self.title = conversation.label
self._is_channel = conversation.is_channel
self._messages = list(messages)
self._send = send
self._resend = resend
self._names = names
self._session = session
self._editor = _LineEditor()
self._sending = False
self._status = ""
self._stick = True # keep the newest message in view until the user scrolls up
# Channel-only reply selection: index of the highlighted message (or None when the
# compose line is focused), plus the body line it rendered on so the frame keeps it
# in view. Direct chats keep the flat, free-scrolling view and never select.
self._selected: Optional[int] = None
self._selected_line: Optional[int] = None
@property
def footer_hint(self) -> str:
"""Key hint, reflecting whether a message is picked for reply (channels only)."""
if not self._is_channel:
if any(m.outbound and m.acked is False for m in self._messages):
return "Enter send · Ctrl-R retry failed · ↑↓ scroll · Esc back"
return "Enter send · ↑↓/PgUp scroll · End latest · Esc back"
if self._selected is not None:
return "Enter reply (@mention) · ↑↓ pick · End/Esc cancel"
return "Enter send · ↑ pick a message to reply · Esc back"
# --- live updates --------------------------------------------------------
def append(self, message: ChatMessage) -> None:
"""Append an inbound message to the transcript and repaint."""
self._messages.append(message)
# Don't yank the view to the tail while the user is picking 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 any status."""
self._selected_line = None
if not self._messages:
lines = render_lines(Text("No messages yet — say hello!", style="muted"), width)
else:
# Both direct and channel threads use the same grouped, Discord/Slack-style
# transcript: consecutive messages from one sender share a colored header, with
# day dividers between them. Rendering per-message also lets us record where the
# picked reply target lands (channels only; see _selected_line / cursor_line).
if self._selected is not None:
self._selected = max(0, min(self._selected, len(self._messages) - 1))
lines = self._render_grouped(width)
limit = self._byte_limit()
used = self._used_bytes()
footer_parts: list[RenderableType] = [
Text("─" * width, style="muted"),
self._editor.render(overflow_at=self._overflow_at(limit)),
self._byte_counter(width, used, limit),
]
if self._is_channel and self._selected is not None:
footer_parts.append(self._reply_banner())
if self._status:
footer_parts.append(Text(self._status, style="muted"))
lines += render_lines(Group(*footer_parts), width)
# Sticking to the bottom: hand the frame an over-large offset so it clamps the view
# to the final lines (input + latest messages). Scrolling up clears the stick.
if self._stick:
self.scroll = len(lines)
return lines
def cursor_line(self) -> Optional[int]:
"""Keep the picked reply target in view; otherwise free scroll (managed by stick)."""
return self._selected_line
# --- outgoing byte budget ------------------------------------------------
def _byte_limit(self) -> int:
"""The UTF-8 byte ceiling for a message in this conversation (channel vs direct)."""
return _CHANNEL_BYTE_LIMIT if self._is_channel else _DM_BYTE_LIMIT
def _used_bytes(self) -> int:
"""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) -> Optional[int]:
"""Index of the first compose character whose bytes spill past ``limit``, else ``None``.
Walking by character (not byte) keeps multibyte input intact: an emoji or accented
letter is entirely under or entirely over the line, never split mid-sequence.
"""
total = 0
for i, ch in enumerate(self._editor.text):
total += len(ch.encode("utf-8"))
if total > limit:
return i
return None
def _byte_counter(self, width: int, used: int, limit: int) -> Text:
"""Right-aligned ``used/limit`` budget; only ``used`` is colored by how much is left.
Green with room to spare, yellow within :data:`_BYTES_TIGHT` bytes, orange within
:data:`_BYTES_LOW`, and red once the limit is met or exceeded — so the number reads as
a fuel gauge while the ``/limit`` suffix stays muted (it never changes).
"""
tail = f"/{limit}"
pad = max(0, width - len(str(used)) - len(tail))
counter = Text(" " * pad)
counter.append(str(used), style=self._byte_style(limit - used))
counter.append(tail, style="muted")
return counter
@staticmethod
def _byte_style(remaining: int) -> str:
"""Map bytes remaining to the counter's escalating color (green→yellow→orange→red)."""
if remaining <= 0:
return "err" # at or over the limit — the send is blocked
if remaining <= _BYTES_LOW:
return _BYTES_ORANGE
if remaining <= _BYTES_TIGHT:
return _BYTES_YELLOW
return "ok"
def _name(self, peer: Optional[str]) -> Optional[str]:
"""Resolve a sender key prefix to a contact name (exact, 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, tracking the picked message's row.
Serves both direct and channel threads. Consecutive messages from the same sender on
the same day share one colored header, with each message body indented below. A muted
divider marks each new day, and a blank line separates distinct sender groups.
Rendering message-by-message (rather than as one Group) lets us record the body line
of the selected reply target in :attr:`_selected_line` so the frame can scroll it into
view (channels only; direct threads never select).
"""
lines: list[str] = []
prev_group: Optional[tuple[bool, str]] = None
prev_day = None
for idx, message in enumerate(self._messages):
stamp = message.created_at.astimezone()
day = stamp.date()
sender, body = self._sender_and_body(message)
# Group by (are-we-the-sender, display name), not the name alone, so our own
# messages never merge with a remote sender who happens to be named the same.
group = (message.outbound, sender)
new_day = day != prev_day
if new_day:
if lines:
lines += render_lines(Text(""), width)
lines += render_lines(
Text(f"── {stamp:%a} {stamp:%b} {stamp.day} ──", style="muted"), width
)
if new_day or group != prev_group:
if not new_day and lines:
lines += render_lines(Text(""), width) # gap between sender groups
header = self._group_header(sender, is_self=message.outbound)
lines += render_lines(header, width)
selected = idx == self._selected
if selected:
self._selected_line = len(lines)
lines += render_lines(self._body_line(body, message, selected=selected), width)
prev_group, prev_day = group, day
return lines
def _sender_and_body(self, message: ChatMessage) -> tuple[str, str]:
"""Return the display sender and cleaned body for a message.
Our own messages are ``you``. Inbound channel messages carry a ``Name: `` prefix we
lift into the sender (falling back to ``·`` when absent); inbound direct messages take
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 _group_header(self, sender: str, *, is_self: bool = False) -> Text:
"""Build the sender header that starts a group, colored per sender."""
return Text(sender, style=self._sender_style(sender, is_self=is_self))
def _body_line(self, body: str, message: ChatMessage, *, selected: bool = False) -> Text:
"""Build one indented message line, each stamped with its own time.
The per-message timestamp lives here (not on the group header) so every message
shows the time it was actually sent, even when several are grouped under one
sender — otherwise a run of same-sender messages would appear to share one time.
When ``selected``, the line is marked as the reply target (matching the select
screen's ``❯`` pointer and brand highlight).
"""
stamp = message.created_at.astimezone()
line = Text()
line.append("❯ " if selected else " ", style="brand" if selected else None)
line.append(f"{stamp:%H:%M} ", style="brand" if selected else "muted")
line.append(body, style="brand" if selected else None)
if message.outbound:
# Direct messages track per-message delivery (⏳ → ✅/❌, retryable); channel
# broadcasts have no ack, so only flag one that failed to leave the companion.
if message.is_channel:
if message.acked is False:
line.append(" ⚠ no ack", style="warn")
else:
line.append(f" {_delivery_glyph(message.acked)}")
if message.snr is not None:
line.append(f" {message.snr:+.0f} dB", style=snr_style(message.snr))
return line
def _reply_banner(self) -> Text:
"""One-line cue shown above the input when a message is picked to reply to."""
message = self._messages[self._selected]
sender, _ = self._sender_and_body(message)
who = "this message" if message.outbound or sender == "·" else sender
return Text(
f"↩ Enter to reply to {who} with an @mention · End to cancel", style="accent"
)
def _sender_style(self, sender: str, *, is_self: bool = False) -> str:
"""Pick a stable color for a channel sender.
Our own messages are white — keyed on ``is_self`` (the message being outbound), not
on the ``"you"`` label, so a remote sender who happens to be named ``you`` still gets
a hue from the palette rather than masquerading as us. ``·`` (unknown) is muted; every
other sender gets a stable hue derived from its name.
"""
if is_self:
return "you" # white, out of the per-sender hue range — always easy to spot
if sender == "·":
return "muted"
return _SENDER_COLORS[sum(map(ord, sender)) % len(_SENDER_COLORS)]
# --- input ---------------------------------------------------------------
def handle(self, action: str, data: str = "") -> None:
"""Dispatch a key: channels navigate a reply selection; direct chats free-scroll."""
if self._is_channel:
self._handle_channel(action, data)
else:
self._handle_flat(action, data)
def _handle_flat(self, action: str, data: str = "") -> None:
"""Direct-chat input: send on Enter, scroll the transcript, edit, or leave on Esc."""
if action == "enter":
self._submit()
elif action == "retry":
self._retry()
elif action == "escape":
self.resolve(CANCEL)
elif action == "up":
self._stick = False
self.scroll = max(0, self.scroll - 1)
elif action == "pageup":
self._stick = False
self.scroll = max(0, self.scroll - _PAGE)
elif action == "down":
self.scroll += 1
elif action == "pagedown":
self.scroll += _PAGE
elif action == "home":
self._stick = False
self.scroll = 0
elif action == "end":
self._stick = True
else:
if self._editor.edit(action, data):
self._status = "" # trimming clears the "too long" notice
self._stick = True # typing snaps back to the live tail
def _handle_channel(self, action: str, data: str = "") -> None:
"""Channel input: arrows pick a message to reply to; Enter sends or starts a reply.
With no message picked the view behaves like the compose line (Enter sends). Moving
up enters the selection from the newest message; moving down past the newest (or
End) returns focus to the compose line and clears the selection. Enter on a picked
message primes the input with an ``@mention`` instead of sending.
"""
if action == "enter":
if self._selected is not None:
self._begin_reply()
else:
self._submit()
elif action == "escape":
if self._selected is not None:
self._clear_selection() # first Esc deselects; next leaves the chat
self._session.invalidate()
else:
self.resolve(CANCEL)
elif action == "up":
self._move_selection(-1)
elif action == "pageup":
self._move_selection(-_PAGE)
elif action == "down":
self._move_selection(1)
elif action == "pagedown":
self._move_selection(_PAGE)
elif action == "home":
if self._messages:
self._selected = 0
self._stick = False
elif action == "end":
self._clear_selection()
self._stick = True # jump back to the live tail / compose line
else:
if self._editor.edit(action, data):
# Touching the compose line returns focus there — nothing stays selected.
self._status = "" # trimming clears the "too long" notice
self._clear_selection()
self._stick = True
def _move_selection(self, delta: int) -> None:
"""Move the reply selection by ``delta`` messages (negative = toward older).
Entering from the compose line only happens moving up (``delta < 0``); moving past
the newest message drops the selection and re-sticks to the tail.
"""
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 _clear_selection(self) -> None:
"""Drop any reply selection (focus returns to the compose line)."""
self._selected = None
self._selected_line = None
def _begin_reply(self) -> None:
"""Prime the compose line with an ``@mention`` of the picked message's sender."""
message = self._messages[self._selected]
sender, _ = self._sender_and_body(message)
named = not message.outbound and sender != "·"
mention = f"@[{sender}] " if named else "@"
self._editor = _LineEditor(mention + self._editor.text)
self._clear_selection()
self._stick = True
self._session.invalidate()
def _submit(self) -> None:
"""Send the current input line as a message (scheduled off the key handler).
Refuses an over-budget line: the buffer is kept intact (so the user can trim it) and
the overage is reported, matching the red overflow the compose bar already shows.
"""
text = self._editor.text.strip()
if not text or self._sending:
return
limit = self._byte_limit()
over = self._used_bytes() - limit
if over > 0:
self._status = f"Too long by {over} byte{'s' if over != 1 else ''} — trim to send."
self._session.invalidate()
return
self._editor = _LineEditor()
self._sending = True
self._stick = True
if self._is_channel:
self._status = "sending…"
self._session.invalidate()
asyncio.ensure_future(self._send_channel(text))
else:
# Direct chats show an optimistic bubble whose trailing glyph tracks delivery:
# ⏳ now, then ✅/❌ once the ack resolves (or times out). acked=None ⇒ ⏳.
pending = ChatMessage(text=text, outbound=True, created_at=utcnow())
self._messages.append(pending)
self._status = ""
self._session.invalidate()
asyncio.ensure_future(self._send_direct(pending))
async def _send_channel(self, text: str) -> None:
"""Broadcast a channel message and append it once the companion accepts it."""
try:
message = await self._send(text)
if message is not None:
self._messages.append(message)
self._status = ""
except Exception as exc: # noqa: BLE001 - report inline, keep the chat alive
self._status = f"send failed: {exc}"
finally:
self._sending = False
self._stick = True
self._session.invalidate()
async def _send_direct(self, pending: ChatMessage) -> None:
"""Await delivery of the optimistic ``pending`` bubble, swapping in the stored row.
On success the pending bubble is replaced by the recorded message (carrying its
resolved ``acked`` state and row id, so a ❌ can later be retried in place). A hard
failure — the companion rejecting the send outright — drops the bubble and reports
the error inline, matching how a failed send has always surfaced.
"""
try:
message = await self._send(pending.text)
except Exception as exc: # noqa: BLE001 - report inline, keep the chat alive
self._discard(pending)
self._status = f"send failed: {exc}"
else:
if message is not None:
self._swap(pending, message)
else:
self._discard(pending)
self._status = ""
finally:
self._sending = False
self._stick = True
self._session.invalidate()
def _retry(self) -> None:
"""Re-attempt delivery of the most recent unacknowledged direct message (Ctrl-R)."""
if self._sending or self._resend is None:
return
target = next(
(m for m in reversed(self._messages) if m.outbound and m.acked is False),
None,
)
if target is None:
return
self._sending = True
target.acked = None # back to ⏳ while the retry is in flight
self._status = "retrying…"
self._stick = True
self._session.invalidate()
asyncio.ensure_future(self._resend_message(target))
async def _resend_message(self, message: ChatMessage) -> None:
"""Drive a retry to completion, refreshing the message's delivery state in place."""
assert self._resend is not None
try:
await self._resend(message)
except Exception as exc: # noqa: BLE001 - report inline, keep the chat alive
message.acked = False
self._status = f"retry failed: {exc}"
else:
self._status = "" if message.acked else "still no ack — Ctrl-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 recorded message, in place."""
try:
self._messages[self._messages.index(old)] = new
except ValueError: # pragma: no cover - the bubble is always still present
self._messages.append(new)
def _discard(self, message: ChatMessage) -> None:
"""Drop an optimistic bubble that never became a real message."""
try:
self._messages.remove(message)
except ValueError: # pragma: no cover - defensive
pass
async def open_chat(ctx: "AppContext", conversation: Conversation) -> int:
"""Open the live chat screen for ``conversation`` and run it until dismissed.
Ensures listening and message recording are active, loads the stored transcript,
subscribes to the hub so inbound messages for this thread append live, and pushes the
screen. The subscription and the active-conversation marker are always cleaned up on
exit.
Args:
ctx: The shared application context (must be running the interactive TUI surface).
conversation: The channel or contact conversation to open.
Returns:
The number of messages shown in the transcript when the screen closed.
Raises:
RuntimeError: If called outside the interactive menu (no full-screen session).
"""
from .surface import TuiUi
if not isinstance(ctx.ui, TuiUi): # pragma: no cover - guarded by the menu-only caller
raise RuntimeError("live chat is only available in the interactive menu")
session = ctx.ui.session
device = await ctx.device()
try:
await ctx.chat.start() # begin recording inbound if it wasn't already
except Exception: # noqa: BLE001 - the hub may already be running; recording is best-effort
pass
history = ctx.repo.recent_chat_messages(
is_channel=conversation.is_channel,
channel_id=conversation.channel_id,
peer=conversation.peer,
limit=_HISTORY_LIMIT,
)
names = _contact_names(await device.get_contacts())
async def send(text: str) -> Optional[ChatMessage]:
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 ctx.chat.send_direct(conversation.contact, text)
resend: Optional[Callable[[ChatMessage], Awaitable[ChatMessage]]] = None
if not conversation.is_channel:
async def resend(message: ChatMessage) -> ChatMessage:
assert conversation.contact is not None
return await ctx.chat.resend_direct(conversation.contact, message)
screen = ChatScreen(
conversation, history, send=send, names=names, session=session, resend=resend
)
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)
def _contact_names(contacts: list[Contact]) -> dict[str, str]:
"""Build a key-prefix → name map for labeling 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 currently shown.
Returns:
``True`` if the message should append 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)