In a channel, a DM, or a room, ^K toggles the lock of the contact that wrote the selected message, so the archive sweep never takes it. The hint says ^K lock or ^K unlock, but only where it fits without dropping another atom, and the status line confirms what the key did. On the handhelds, Lock (or Unlock) rides Shift+F2 in a channel and a DM. A room keeps Log in there, so the Author page that a conversation opens now carries the lock row, and no other management verb. Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
481 lines
21 KiB
Python
481 lines
21 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
"""The room screen: the board of a room server, to read and to post to like a chat.
|
|
|
|
A room server keeps the most recent posts of its members. It sends each member the posts
|
|
that the member missed. To the user, this is a conversation with many voices, which is the
|
|
shape that a channel already has. Thus the screen *is* the chat screen
|
|
(:class:`~meshterm.ui.chat.ChatScreen`). It also shows what a room knows and a channel does
|
|
not: who wrote each post, by key and not by a name that somebody typed before it, and
|
|
whether the room will take what you post.
|
|
|
|
Three things make it different, and each one belongs to the room:
|
|
|
|
* **Authors are keys.** A room relays a post as a direct message from *itself*. The room
|
|
signs the post with the first four bytes of the key of the author
|
|
(:attr:`~meshterm.core.models.ChatMessage.author`). MeshTerm files the post under the
|
|
name of the author when a contact or an overheard advert names that key. When nothing
|
|
names the key, MeshTerm files the post under the bare hash, in the grey of an
|
|
unidentified node. In both cases the colour comes from the key, never from a guess about
|
|
the name.
|
|
* **To get in, you log in.** The title shows what the room let us do (``member``,
|
|
``admin``, ``read-only``) or the state of the login. The Rooms page joins a room
|
|
(:mod:`meshterm.ui.rooms`). Here, when the user opens a joined room, MeshTerm logs in to
|
|
it again only if the room has been quiet
|
|
(:meth:`~meshterm.services.rooms.RoomService.login_due`). It does this quietly, and the
|
|
board is open to read. ^G runs the explained login when the user wants it.
|
|
* **Posting waits for the room.** A room drops, with no message, a post from anyone that it
|
|
does not know, or that it knows at the read-only access. Thus the compose line opens only
|
|
when we are logged in and can post. Until then, it says why it is not open and which key
|
|
to press.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Awaitable, Callable
|
|
from typing import TYPE_CHECKING
|
|
|
|
from rich.console import RenderableType
|
|
from rich.text import Text
|
|
|
|
from ..core.events import EventKind, MeshEvent
|
|
from ..core.models import (
|
|
ChatMessage,
|
|
Contact,
|
|
Conversation,
|
|
LoginResult,
|
|
NodeResolver,
|
|
RoomAccess,
|
|
RoomLogin,
|
|
)
|
|
from .chat import (
|
|
ChatScreen,
|
|
_belongs,
|
|
_contact_names,
|
|
_make_paths_presenter,
|
|
_with_restore,
|
|
our_node,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from ..context import AppContext
|
|
from .map_screen import Locator
|
|
from .node_detail_screen import ContactPages
|
|
|
|
#: The title's login atom while a login is in flight.
|
|
LOGGING_IN = "logging in…"
|
|
#: The title's login atom after a login met silence.
|
|
NO_REPLY = "no reply"
|
|
#: The title's login atom after the companion refused the login outright.
|
|
REFUSED = "refused"
|
|
#: The title's atom for a board opened on a room not joined.
|
|
NOT_JOINED = "not joined"
|
|
#: The title's login atom after a login failed outright (the link, the radio).
|
|
FAILED = "login failed"
|
|
|
|
|
|
def author_label(author: str, resolve: NodeResolver) -> tuple[str, str | None]:
|
|
"""The name and the colour of the author of a post: the name and key, or the bare hash.
|
|
|
|
This is the only rule for an author. The room screen and the preview of the Chat picker
|
|
share it, so that they cannot give two different names to one person. A contact or an
|
|
overheard advert can carry a key prefix. MeshTerm resolves such a key prefix to the
|
|
name of that node, and the key itself gives the colour. A key prefix that nothing names
|
|
stands as the hash, with no key to give a colour. It has the grey of an unidentified
|
|
node, because that grey is for a bare hash that stands in as a name.
|
|
|
|
Args:
|
|
author: The key prefix of the author, as the post carried it.
|
|
resolve: Gives the name of a node for a key prefix, or gives the key prefix back
|
|
when it is not known (refer to
|
|
:func:`~meshterm.services.trace_runner.make_node_resolver`).
|
|
|
|
Returns:
|
|
``(label, key)``: the name and the key that gives its colour, or the hash and
|
|
``None``.
|
|
"""
|
|
name = resolve(author)
|
|
if not name or name.lower() == author.lower():
|
|
return author, None
|
|
return name, author
|
|
|
|
|
|
class RoomScreen(ChatScreen):
|
|
"""The board of a room: posts under their authors, a compose line, and the login state.
|
|
|
|
It does everything that a chat does. When the user selects a post, Enter replies with an
|
|
``@mention``. ^P shows the paths that a post took. ^L opens the map on the author of a
|
|
post, and ^O opens the node page of that author. ^R tries again a post that the room did
|
|
not acknowledge. It also has ^G, which logs in to the room. It uses the remembered
|
|
password, or it asks for a password when none is remembered or when the last login got
|
|
no answer.
|
|
"""
|
|
|
|
_empty_text = "No posts yet"
|
|
|
|
@property
|
|
def picocalc_lyra_lane(self):
|
|
"""The lane of the chat, with *Log in* on the Shift half of F2 (F7), where ^G has no key.
|
|
|
|
Each chord that the desktop has needs a chip on the handheld. The plain bank of F1
|
|
to F3 has the verbs that a room shares with a channel and a DM, and the Shift bank
|
|
has the verbs of the screen (refer to :attr:`ChatScreen.picocalc_lyra_lane`). Thus
|
|
``Retry`` (^R) stays on the Shift half of F1, as in a DM, and ``Log in`` is next to
|
|
it. The chip is dim while a login is in progress.
|
|
"""
|
|
from dataclasses import replace
|
|
|
|
lane = list(super().picocalc_lyra_lane)
|
|
lane[1] = replace(
|
|
lane[1], opp_label="Log in", opp_action="login", opp_enabled=not self._login_open
|
|
)
|
|
return lane
|
|
|
|
def __init__(
|
|
self,
|
|
conversation: Conversation,
|
|
messages: list[ChatMessage],
|
|
*,
|
|
send: Callable[[str], Awaitable[ChatMessage | None]],
|
|
names: dict[str, str],
|
|
session, # noqa: ANN001 - TuiSession, imported lazily to avoid a cycle
|
|
resolve: NodeResolver,
|
|
auto_login: Callable[[], Awaitable[RoomLogin | None]],
|
|
join: Callable[[bool], Awaitable[RoomLogin | None]],
|
|
joined: bool = True,
|
|
access: RoomAccess | None = None,
|
|
resend: Callable[[ChatMessage], Awaitable[ChatMessage]] | None = None,
|
|
paths: Callable[[ChatMessage], Awaitable[None]] | None = None,
|
|
key_of: Callable[[str], str | None] | None = None,
|
|
locator: Locator | None = None,
|
|
pages: ContactPages | None = None,
|
|
self_name: str | None = None,
|
|
self_key: str | None = None,
|
|
) -> None:
|
|
"""Build the room screen.
|
|
|
|
Args:
|
|
conversation: The conversation of the room (its label is the title of the
|
|
screen).
|
|
messages: The board as stored, oldest first, with posts only (refer to
|
|
:meth:`~meshterm.persistence.repository.Repository.recent_chat_messages`).
|
|
send: Posts a line to the room and returns the stored post.
|
|
names: Contact key prefix to name (the chat screen labels a peer with it).
|
|
session: The running :class:`~meshterm.ui.tui.session.TuiSession`.
|
|
resolve: Gives the name for the key prefix of an author (refer to
|
|
:func:`author_label`).
|
|
auto_login: Logs in quietly with the remembered password. This is what happens
|
|
when the user opens a quiet room. It is ``None`` when there is no password
|
|
to log in with.
|
|
join: The explained login flow (:func:`~meshterm.ui.rooms.join_room`), with its
|
|
dialogs floating over the board. MeshTerm calls it with a flag that says
|
|
if it must ask for the password also when one is remembered. It returns
|
|
``None`` when the user backed out.
|
|
joined: Whether the user joined the room. A board that is opened on a room that
|
|
is not joined (its stored posts, read from the Rooms page) takes no posts.
|
|
access: What the room let us do when we last logged in, if we ever logged in.
|
|
resend: Posts again a post that the room did not acknowledge (^R).
|
|
paths: Shows the paths that a selected post took (^P).
|
|
key_of: Gives the key of its node for a name, for the hues of ``@mention``.
|
|
locator: The located nodes, for ^L: the map on the author of a post.
|
|
pages: The contacts, for ^O: the node page of the author of a post.
|
|
self_name: The name of our node, on our posts and on the mentions of us.
|
|
self_key: Our public key, which gives the hue of that chip.
|
|
"""
|
|
super().__init__(
|
|
conversation,
|
|
messages,
|
|
send=send,
|
|
names=names,
|
|
session=session,
|
|
resend=resend,
|
|
paths=paths,
|
|
key_of=key_of,
|
|
locator=locator,
|
|
pages=pages,
|
|
self_name=self_name,
|
|
self_key=self_key,
|
|
)
|
|
self._label = conversation.label
|
|
self._resolve = resolve
|
|
self._auto_login = auto_login
|
|
self._join = join
|
|
self._access = access
|
|
#: The state of the login, when the title must say this and not the access (one of
|
|
#: the atoms of the module). If it is ``None``, the title shows the access.
|
|
self._login_state: str | None = None if joined else NOT_JOINED
|
|
self._login_open = False
|
|
self._apply_access()
|
|
|
|
# --- the login ----------------------------------------------------------------------
|
|
|
|
def begin_login(self, *, ask: bool = False) -> None:
|
|
"""^G: the explained login flow, with its prompt and its result floating over the board.
|
|
|
|
Args:
|
|
ask: Ask for the password also when one is remembered. A member at the read-only
|
|
access needs this, because the remembered password gave no more than that.
|
|
"""
|
|
if self._login_open:
|
|
return
|
|
self._login_open = True
|
|
self._session.run_detached(self._settle(self._join(ask)))
|
|
|
|
def begin_auto_login(self) -> None:
|
|
"""The user opens a quiet room: log in with what is remembered, with the board open.
|
|
|
|
There are no dialogs, because the user asked for the board and not for a login. The
|
|
title says ``logging in…`` while the login runs, and the compose line waits for the
|
|
answer. A room that has forgotten us drops each post that we send. Thus the compose
|
|
line opens only when the room has let us in. If the room does not answer, this shows
|
|
in the same two places, and ^G is the explained retry.
|
|
"""
|
|
if self._login_open:
|
|
return
|
|
self._login_open = True
|
|
self._login_state = LOGGING_IN
|
|
self._apply_access()
|
|
self._session.run_detached(self._settle(self._auto_login()))
|
|
|
|
async def _settle(self, attempt: Awaitable[RoomLogin | None]) -> None:
|
|
"""Wait for one login attempt, and show how it ended."""
|
|
try:
|
|
login = await attempt
|
|
except Exception as exc: # noqa: BLE001 - shown on the board, which stays open
|
|
self._login_state = FAILED
|
|
self._status = f"login failed: {exc}"
|
|
else:
|
|
if login is not None:
|
|
self._apply_login(login)
|
|
elif self._login_state == LOGGING_IN:
|
|
self._login_state = None # there was nothing to log in with
|
|
finally:
|
|
self._login_open = False
|
|
self._apply_access()
|
|
self._session.invalidate()
|
|
|
|
def _apply_login(self, login: RoomLogin) -> None:
|
|
"""Take the result of a login: the access that it gave, or the reason for no access."""
|
|
self._status = ""
|
|
if login.access is not None and login.result is LoginResult.ACCEPTED:
|
|
self._access = login.access
|
|
self._login_state = None
|
|
elif login.result is LoginResult.REFUSED:
|
|
self._login_state = REFUSED
|
|
else:
|
|
self._login_state = NO_REPLY
|
|
|
|
def _apply_access(self) -> None:
|
|
"""Open the compose line only while we are logged in and the room keeps what we post.
|
|
|
|
The line stays closed while a login is in progress or got no answer. A room that has
|
|
forgotten us (it restarted, or it stopped waiting for us) drops a post with no
|
|
message. A compose line that took a post in that state was the one thing on the
|
|
board that was not true.
|
|
"""
|
|
self._composing = (
|
|
self._login_state is None and self._access is not None and self._access.can_post
|
|
)
|
|
self._retitle()
|
|
|
|
def _retitle(self) -> None:
|
|
"""Give the board a title: the room and its one status atom (``Room · member``)."""
|
|
atom = self._login_state or (self._access.value if self._access else None)
|
|
self.title = f"{self._label} · {atom}" if atom else self._label
|
|
|
|
# --- the board ----------------------------------------------------------------------
|
|
|
|
def append(self, message: ChatMessage) -> None:
|
|
"""Add a post that just arrived, one time, also if the room sends it more than once.
|
|
|
|
A room sends a post again when it did not hear an acknowledgement. Thus the same
|
|
post can arrive twice. A room also sends other traffic under its key (the replies
|
|
to the commands of an admin). That traffic is not on the board.
|
|
"""
|
|
if not message.is_post:
|
|
return
|
|
for held in reversed(self._messages):
|
|
if (
|
|
held.author == message.author
|
|
and held.created_at == message.created_at
|
|
and held.text == message.text
|
|
):
|
|
return
|
|
super().append(message)
|
|
|
|
def _sender_and_body(self, message: ChatMessage) -> tuple[str, str]:
|
|
"""The author of a post (by name, or by hash) and its text. Our post has ``you``."""
|
|
if message.outbound:
|
|
return "you", message.text
|
|
if message.author:
|
|
return author_label(message.author, self._resolve)[0], message.text
|
|
return self._label, message.text
|
|
|
|
def _header_key(self, sender: str, message: ChatMessage) -> str | None:
|
|
"""The key of the author gives the colour of the chip. A hash that nothing names is grey."""
|
|
if message.author:
|
|
return author_label(message.author, self._resolve)[1]
|
|
return super()._header_key(sender, message)
|
|
|
|
def _compose_line(self, width: int) -> RenderableType:
|
|
"""The compose line, or, when there can be none, the reason and the key to press."""
|
|
if self._composing:
|
|
return super()._compose_line(width)
|
|
# Each reason is inside the 53 columns of the handhelds, so it is one line everywhere.
|
|
why = {
|
|
LOGGING_IN: "logging in… posting opens once the room lets you in",
|
|
NO_REPLY: "not logged in: no answer from the room · ^G to retry",
|
|
REFUSED: "not logged in: the login was refused · ^G to retry",
|
|
FAILED: "not logged in · ^G to try again",
|
|
NOT_JOINED: "not joined · ^G to join",
|
|
}.get(self._login_state or "")
|
|
if why is None:
|
|
why = (
|
|
"read-only — the room drops your posts · ^G to log in"
|
|
if self._access is RoomAccess.READ_ONLY
|
|
else "not logged in · ^G to log in"
|
|
)
|
|
return Text(why, style="muted")
|
|
|
|
def _submit(self) -> None:
|
|
"""Post the compose line, unless we know that the room drops our posts."""
|
|
if self._composing:
|
|
super()._submit()
|
|
|
|
def handle(self, action: str, data: str = "") -> None:
|
|
"""^G logs in. The chat handles all the other actions."""
|
|
if action == "login":
|
|
# The remembered password of a member at the read-only access gave all that it
|
|
# can, so ^G asks for another password. For each other member, the flow tries
|
|
# the remembered password first and asks only if it fails.
|
|
self.begin_login(ask=self._access is RoomAccess.READ_ONLY)
|
|
self._session.invalidate()
|
|
return
|
|
super().handle(action, data)
|
|
|
|
def _retry_target(self) -> ChatMessage | None:
|
|
"""A post that the room did not acknowledge, to send again.
|
|
|
|
There is none for a user whom the room does not hear.
|
|
"""
|
|
return super()._retry_target() if self._composing else None
|
|
|
|
@property
|
|
def footer_hint(self) -> str:
|
|
"""The hint of the chat, in the words of a board, with ^G when a login can start.
|
|
|
|
A selected post has the hint of a selected message in the chat. With no place for a
|
|
reply, Enter shows the paths of the post there too.
|
|
"""
|
|
if self._selected is not None:
|
|
return super().footer_hint
|
|
atoms = ["Enter send"] if self._composing else []
|
|
atoms.append("↑ pick a message")
|
|
if self._retry_target() is not None:
|
|
atoms.append("^R retry failed")
|
|
if not self._login_open:
|
|
atoms.append("^G log in")
|
|
atoms.append("Esc back")
|
|
return " · ".join(atoms)
|
|
|
|
|
|
async def open_room(ctx: AppContext, conversation: Conversation) -> int:
|
|
"""Open the room screen for ``conversation`` and run it until the user leaves.
|
|
|
|
The board opens immediately on what is stored. A joined room that has been quiet logs in
|
|
with the password that is remembered for it. This happens in the background, and the
|
|
board is readable. The login starts the catch-up of the room again. A room that MeshTerm
|
|
heard from recently sends nothing. The Rooms page joins a room
|
|
(:mod:`meshterm.ui.rooms`). Thus a room that is not joined opens only to read its
|
|
stored posts, and ^G joins it. Posts that arrive while the board is open are added at
|
|
once, and the unread count of the room stays clear while the user is on the board.
|
|
|
|
Args:
|
|
ctx: The shared application context (which runs the interactive TUI surface).
|
|
conversation: The conversation of the room (:attr:`~Conversation.is_room`).
|
|
|
|
Returns:
|
|
The number of posts on the board when the screen closed.
|
|
"""
|
|
from ..services import trace_runner
|
|
from .map_screen import Locator
|
|
from .node_detail_screen import ContactPages
|
|
from .rooms import join_room
|
|
|
|
session = ctx.ui.session
|
|
room = conversation.contact
|
|
assert room is not None
|
|
device = await ctx.device()
|
|
try:
|
|
await ctx.chat.start() # store the posts that arrive, if nothing has started it yet
|
|
except Exception: # noqa: BLE001 - the hub can already be running. This is best effort.
|
|
pass
|
|
|
|
board = ctx.repo.recent_chat_messages(
|
|
is_channel=False,
|
|
peer=conversation.peer,
|
|
limit=ctx.preferences.chat_history_limit,
|
|
posts_only=True,
|
|
)
|
|
contacts = await ctx.devstate.contacts()
|
|
stored_names, named_at = ctx.repo.node_names_with_times()
|
|
|
|
async def send(text: str) -> ChatMessage | None:
|
|
return await _with_restore(ctx, lambda: ctx.chat.send_post(room, text))
|
|
|
|
async def resend(message: ChatMessage) -> ChatMessage:
|
|
return await _with_restore(ctx, lambda: ctx.chat.resend_post(room, message))
|
|
|
|
async def fresh() -> Contact:
|
|
"""The room as the contact cache holds it now. Its route and heard time change."""
|
|
held = await ctx.devstate.contacts()
|
|
key = room.public_key or room.key_prefix
|
|
return next((c for c in held if (c.public_key or c.key_prefix) == key), room)
|
|
|
|
async def auto_login() -> RoomLogin | None:
|
|
password = ctx.rooms.password(room)
|
|
return None if password is None else await ctx.rooms.login(room, password)
|
|
|
|
async def join(ask: bool) -> RoomLogin | None:
|
|
return await join_room(ctx, await fresh(), ask=ask)
|
|
|
|
self_name, self_key = await our_node(ctx)
|
|
screen = RoomScreen(
|
|
conversation,
|
|
board,
|
|
send=send,
|
|
names=_contact_names(contacts),
|
|
session=session,
|
|
resolve=trace_runner.make_node_resolver(contacts, stored_names),
|
|
auto_login=auto_login,
|
|
join=join,
|
|
joined=ctx.rooms.joined(room),
|
|
access=ctx.rooms.access(room),
|
|
resend=resend,
|
|
paths=await _make_paths_presenter(ctx, conversation, device),
|
|
key_of=trace_runner.make_name_key_resolver(contacts, stored_names, heard=named_at),
|
|
locator=await Locator.gather(ctx),
|
|
pages=ContactPages(ctx, list(contacts), self_key=self_key or ""),
|
|
self_name=self_name,
|
|
self_key=self_key,
|
|
)
|
|
ctx.chat.set_active(conversation.key)
|
|
|
|
def on_event(event: MeshEvent) -> None:
|
|
message = event.message
|
|
if message is not None and message.is_post and _belongs(message, conversation):
|
|
screen.append(ChatMessage.from_message(message, peer_name=conversation.label))
|
|
|
|
unsubscribe = ctx.events.subscribe(on_event, EventKind.MESSAGE)
|
|
try:
|
|
# The screen stays pushed for the visit (one round of it). Thus the dialogs of a
|
|
# login float over the board when it is up, and not over the screen from which the
|
|
# room was opened.
|
|
async with session.stay(screen) as visit:
|
|
if ctx.rooms.login_due(room):
|
|
screen.begin_auto_login()
|
|
await visit.result() # the board resolves only when the user leaves it
|
|
finally:
|
|
unsubscribe()
|
|
ctx.chat.set_active(None)
|
|
return len(screen._messages)
|