aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-cli/src/meshbay_cli/tui
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-cli/src/meshbay_cli/tui')
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/tui/app.py15
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/tui/chat.py246
2 files changed, 255 insertions, 6 deletions
diff --git a/packages/meshbay-cli/src/meshbay_cli/tui/app.py b/packages/meshbay-cli/src/meshbay_cli/tui/app.py
index dbb773c..fe7fa2c 100644
--- a/packages/meshbay-cli/src/meshbay_cli/tui/app.py
+++ b/packages/meshbay-cli/src/meshbay_cli/tui/app.py
@@ -3,7 +3,8 @@
Textual, because it runs on the same asyncio loop as aiortc and httpx rather
than beside them, and on Linux terminals and Windows' consoles alike. Signed in
-already, it opens on the two panes; otherwise on the sign-in screen first.
+already, it opens on the two panes — or, for `meshbay-cli chat` with no group,
+on the groups to chat in (`tui/chat.py`); otherwise on the sign-in screen first.
Nothing is printed while it runs — a line on stderr would tear the screen — so
the log goes to a file in the CLI's data directory instead.
@@ -24,10 +25,12 @@ from .screens import DeviceCode, MainScreen, SignInScreen
class MeshBayApp(App):
TITLE = "MeshBay"
- def __init__(self, ctx: Context):
+ def __init__(self, ctx: Context, home=MainScreen):
super().__init__()
self.ctx = ctx
self.hub = ctx.hub
+ # The screen a signed-in account lands on, made from the account.
+ self.home = home
async def use_hub(self, url: str) -> None:
if url != self.hub.url:
@@ -51,9 +54,9 @@ class MeshBayApp(App):
def start(self, account: Account) -> None:
account.on_device_code = DeviceCode(self)
if isinstance(self.screen, SignInScreen):
- self.switch_screen(MainScreen(account))
+ self.switch_screen(self.home(account))
else:
- self.push_screen(MainScreen(account))
+ self.push_screen(self.home(account))
def _log_to_file() -> None:
@@ -70,7 +73,7 @@ def _log_to_file() -> None:
root.addHandler(handler)
-async def run_interactive(ctx: Context) -> int:
+async def run_interactive(ctx: Context, home=MainScreen) -> int:
_log_to_file()
- await MeshBayApp(ctx).run_async()
+ await MeshBayApp(ctx, home).run_async()
return 0
diff --git a/packages/meshbay-cli/src/meshbay_cli/tui/chat.py b/packages/meshbay-cli/src/meshbay_cli/tui/chat.py
new file mode 100644
index 0000000..83c1753
--- /dev/null
+++ b/packages/meshbay-cli/src/meshbay_cli/tui/chat.py
@@ -0,0 +1,246 @@
+"""
+`meshbay-cli chat` with no group: the groups, then one group's chat, IRC-style.
+
+The list shows every group of the account; Enter opens one whose node is up and
+whose chat is on (`enabled_apps` in the sealed ack, empty meaning all). The room
+shows the last messages, then the ones that arrive (`chat_msg` pushed by the
+node), and a line at the bottom to write in. Esc, or `/part`, goes back to the
+list; Ctrl+Q, or `/quit`, quits. Messages are read and posted exactly as the
+`chat` action reads and posts them — signature, epoch key, content, then the
+roster's verdict on the signing device — and drawn as text, never markup.
+"""
+
+import asyncio
+import contextlib
+import time
+
+from rich.text import Text
+from textual.app import ComposeResult
+from textual.binding import Binding
+from textual.screen import Screen
+from textual.widgets import DataTable, Footer, Input, RichLog, Static
+
+from ..client.account import Account
+from ..client.chat import Message, chat_keys, history, open_row, post
+from ..client.groups import my_groups
+from ..client.paths import handle
+from ..client.pool import Pool
+from ..client.roster import RosterCheck
+from ..errors import CliError
+from ..output import safe
+
+# How much of the conversation a room opens on.
+BACKLOG = 50
+HELP = "/part or Esc: back to the groups /quit or Ctrl+Q: quit /help: this"
+KEY_CHANGED = ("! This account is using a key you have not seen before: a new device "
+ "added elsewhere, or the node naming the wrong person.")
+
+
+def chat_enabled(ack: dict) -> bool:
+ apps = ack.get("enabled_apps") or []
+ return not apps or "chat" in apps
+
+
+class ChatGroupsScreen(Screen):
+ BINDINGS = [
+ Binding("r", "refresh", "Refresh"),
+ Binding("q", "app.quit", "Quit"),
+ ]
+
+ DEFAULT_CSS = """
+ ChatGroupsScreen > .title { height: 1; padding: 0 1; background: $panel; }
+ ChatGroupsScreen > DataTable { height: 1fr; }
+ """
+
+ def __init__(self, account: Account):
+ super().__init__()
+ self.account = account
+ self.pool = Pool(account)
+ self.groups: list[dict] = []
+
+ def compose(self) -> ComposeResult:
+ yield Static(Text("Chat — choose a group, Enter to join"), classes="title")
+ table = DataTable(cursor_type="row")
+ table.add_column("Group", key="group")
+ table.add_column("Node", key="node")
+ yield table
+ yield Footer()
+
+ def on_mount(self) -> None:
+ self.query_one(DataTable).focus()
+ self.action_refresh()
+
+ def action_refresh(self) -> None:
+ self.run_worker(self._load(), exclusive=True, group="groups")
+
+ async def _load(self) -> None:
+ try:
+ self.groups = sorted(await my_groups(self.account),
+ key=lambda g: (g["name"].lower(), g.get("owner_username")))
+ except CliError as e:
+ self.app.notify(e.message, severity="error", markup=False)
+ return
+ table = self.query_one(DataTable)
+ table.clear()
+ for g in self.groups:
+ up = g.get("node_online")
+ table.add_row(Text(safe(handle(g)), style="" if up else "dim", no_wrap=True,
+ overflow="ellipsis"),
+ Text("up" if up else "down", style="" if up else "dim"),
+ key=g["id"])
+
+ def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
+ group = next((g for g in self.groups if g["id"] == event.row_key.value), None)
+ if group is not None:
+ self.run_worker(self._join(group), exclusive=True, group="join")
+
+ async def _join(self, group: dict) -> None:
+ self.app.notify(f"Joining {safe(handle(group))}…", timeout=3, markup=False)
+ try:
+ session = await self.pool.session(group)
+ except CliError as e:
+ self.app.notify(e.message, severity="error", markup=False, timeout=8)
+ return
+ if not chat_enabled(session.ack):
+ self.app.notify(f"{safe(handle(group))} has its chat turned off.",
+ severity="warning", markup=False)
+ return
+ await self.app.push_screen(ChatRoomScreen(self.account, group, session))
+
+ async def on_unmount(self) -> None:
+ with contextlib.suppress(Exception):
+ await self.pool.close()
+
+
+class ChatRoomScreen(Screen):
+ BINDINGS = [Binding("escape", "part", "Back to the groups")]
+
+ DEFAULT_CSS = """
+ ChatRoomScreen > .title { height: 1; padding: 0 1; background: $panel; }
+ ChatRoomScreen > RichLog { height: 1fr; padding: 0 1; }
+ ChatRoomScreen > Input { dock: bottom; }
+ """
+
+ def __init__(self, account: Account, group: dict, session):
+ super().__init__()
+ self.account = account
+ self.group = group
+ self.session = session
+ self.keys = None
+ self.check = RosterCheck(account, session)
+ self._day = ""
+ self._ready = asyncio.Event()
+
+ def compose(self) -> ComposeResult:
+ yield Static(Text(f"#{safe(handle(self.group))} — {HELP}", no_wrap=True,
+ overflow="ellipsis"), classes="title")
+ yield RichLog(wrap=True, markup=False, highlight=False, auto_scroll=True)
+ yield Input(placeholder=f"Message to {safe(handle(self.group))}")
+ yield Footer()
+
+ @property
+ def log_view(self) -> RichLog:
+ return self.query_one(RichLog)
+
+ def on_mount(self) -> None:
+ self.query_one(Input).focus()
+ self.session.ch.on_push("chat_msg", self._pushed)
+ self.session.ch.on_push("chat_epoch_ack", self._epoch_moved)
+ self.run_worker(self._open(), group="room")
+
+ def on_unmount(self) -> None:
+ self.session.ch.off_push("chat_msg", self._pushed)
+ self.session.ch.off_push("chat_epoch_ack", self._epoch_moved)
+
+ async def _open(self) -> None:
+ try:
+ self.keys = await chat_keys(self.session)
+ rows = await history(self.session, BACKLOG)
+ for row in rows:
+ self.show(await open_row(row, self.keys, self.group["id"], self.check))
+ except CliError as e:
+ self.note(f"! {e.message}")
+ return
+ finally:
+ self._ready.set()
+ if not rows:
+ self.note("No message yet. Say something.")
+
+ # ── Drawing ──────────────────────────────────────────────────────────────
+
+ def note(self, text: str, style: str = "italic dim") -> None:
+ self.log_view.write(Text(text, style=style))
+
+ def show(self, m: Message) -> None:
+ day = time.strftime("%Y-%m-%d", time.localtime(m.timestamp))
+ if day != self._day:
+ self._day = day
+ self.note(f"— {day} —")
+ line = Text(time.strftime("%H:%M ", time.localtime(m.timestamp)), style="dim")
+ line.append(f"<{safe(m.sender)}> ", style="bold")
+ if m.unreadable:
+ line.append(f"[this message cannot be read: {m.unreadable}]", style="italic")
+ else:
+ line.append("\n ".join(safe(part) for part in m.text.split("\n")))
+ self.log_view.write(line)
+ if m.trust == "changed":
+ self.note(" " + KEY_CHANGED, style="bold yellow")
+
+ # ── What the node pushes ─────────────────────────────────────────────────
+
+ def _pushed(self, msg: dict) -> None:
+ self.run_worker(self._arrived(msg), group="arrivals")
+
+ async def _arrived(self, msg: dict) -> None:
+ await self._ready.wait()
+ if self.keys is None:
+ return
+ # A message sealed under an epoch opened since the keys were fetched.
+ if int(msg.get("epoch") or 0) not in self.keys.by_epoch:
+ with contextlib.suppress(CliError):
+ self.keys = await chat_keys(self.session)
+ msg = {**msg, "timestamp": msg.get("timestamp") or time.time()}
+ self.show(await open_row(msg, self.keys, self.group["id"], self.check))
+
+ def _epoch_moved(self, msg: dict) -> None:
+ """Somebody was removed: a new epoch, and the key to post under with it."""
+ self.session.ack["chat_epoch"] = int(msg.get("epoch") or 0)
+ self.run_worker(self._refetch_keys(), group="keys")
+
+ async def _refetch_keys(self) -> None:
+ with contextlib.suppress(CliError):
+ self.keys = await chat_keys(self.session)
+
+ # ── Writing ──────────────────────────────────────────────────────────────
+
+ async def on_input_submitted(self, event: Input.Submitted) -> None:
+ text = event.value.rstrip()
+ event.input.value = ""
+ if not text.strip():
+ return
+ command = text.strip().lower()
+ if command in ("/part", "/leave"):
+ self.action_part()
+ return
+ if command in ("/quit", "/exit"):
+ self.app.exit()
+ return
+ if command == "/help":
+ self.note(HELP)
+ return
+ if command.startswith("/") and not command.startswith("//"):
+ self.note(f"Unknown command {safe(text.split()[0])}. {HELP}")
+ return
+ if command.startswith("//"):
+ text = text.lstrip()[1:] # "//x" posts "/x"
+ await self._ready.wait()
+ try:
+ await post(self.session, self.keys, text, self.account.username)
+ except CliError as e:
+ self.note(f"! Not sent: {e.message}", style="bold red")
+ return
+ # The node relays to everybody else; this side shows its own at once.
+ self.show(Message(0, time.time(), self.account.username, text, trust="pinned"))
+
+ def action_part(self) -> None:
+ self.app.pop_screen()