diff options
Diffstat (limited to 'packages/meshbay-cli/src/meshbay_cli/tui')
| -rw-r--r-- | packages/meshbay-cli/src/meshbay_cli/tui/chat.py | 178 |
1 files changed, 173 insertions, 5 deletions
diff --git a/packages/meshbay-cli/src/meshbay_cli/tui/chat.py b/packages/meshbay-cli/src/meshbay_cli/tui/chat.py index 0717141..e4ea1fe 100644 --- a/packages/meshbay-cli/src/meshbay_cli/tui/chat.py +++ b/packages/meshbay-cli/src/meshbay_cli/tui/chat.py @@ -7,14 +7,19 @@ 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. A right-click copies — the text selected with the mouse, else the line under the pointer — and pastes into the line written -in. Messages are read and posted exactly as the +in. `/put FILE` sends one local file (relative to the current directory, or +absolute) into the chat's folder and names it in the conversation, as the +application's paperclip does; `/get NAME` saves a file of that folder, named +from it, into the current directory. 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 os import time +from pathlib import Path from rich.segment import Segment from rich.text import Text @@ -27,19 +32,30 @@ from textual.strip import Strip 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.chat import ( + Message, + attachment_text, + chat_folder, + 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 ..client.transfers import PART_SUFFIX, download, upload +from ..client.tree import Tree +from ..client.upload_plan import refused_names from ..errors import CliError -from ..output import safe +from ..output import human_size, safe from . import clipboard # How much of the conversation a room opens on. BACKLOG = 50 -HELP = ("/part or Esc: back to the groups /quit or Ctrl+Q: quit " - "right-click: copy, paste /help: this") +HELP = ("/put FILE: send a file /get NAME: save one here /part or Esc: back " + "/quit or Ctrl+Q: quit right-click: copy, paste /help: this") # Under a message's first line, its next ones and what is said about it. INDENT = " " * 6 KEY_CHANGED = ("! This account is using a key you have not seen before: a new device " @@ -203,6 +219,7 @@ class ChatRoomScreen(Screen): DEFAULT_CSS = """ ChatRoomScreen > .title { height: 1; padding: 0 1; background: $panel; } ChatRoomScreen > ChatLog { height: 1fr; padding: 0 1; } + ChatRoomScreen > .transfer { height: 1; padding: 0 1; display: none; } ChatRoomScreen > ChatInput { dock: bottom; } """ @@ -215,11 +232,13 @@ class ChatRoomScreen(Screen): self.check = RosterCheck(account, session) self._day = "" self._ready = asyncio.Event() + self._transferring = False def compose(self) -> ComposeResult: yield Static(Text(f"#{safe(handle(self.group))} — {HELP}", no_wrap=True, overflow="ellipsis"), classes="title") yield ChatLog(wrap=True, markup=False, highlight=False, auto_scroll=True) + yield Static("", classes="transfer", markup=False) yield ChatInput(placeholder=f"Message to {safe(handle(self.group))}", select_on_focus=False) yield Footer() @@ -232,11 +251,13 @@ class ChatRoomScreen(Screen): 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.session.ch.on_push("chat_directory_ack", self._folder_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) + self.session.ch.off_push("chat_directory_ack", self._folder_moved) async def _open(self) -> None: try: @@ -267,6 +288,11 @@ class ChatRoomScreen(Screen): if m.unreadable: line.append(f"[this message cannot be read: {m.unreadable}]", style="italic") else: + if m.attachment: + line.append(f"[file: {safe(m.attachment['filename'])}, " + f"{human_size(m.attachment['size'])}]", style="cyan") + if m.text: + line.append("\n" + INDENT) line.append(("\n" + INDENT).join(safe(part) for part in m.text.split("\n"))) self.log_view.write(line) if m.trust == "changed": @@ -297,6 +323,10 @@ class ChatRoomScreen(Screen): with contextlib.suppress(CliError): self.keys = await chat_keys(self.session) + def _folder_moved(self, msg: dict) -> None: + """The operator chose another folder for the chat's files.""" + self.session.ack["chat_directory"] = str(msg.get("path") or "") + # ── Writing ────────────────────────────────────────────────────────────── async def on_input_submitted(self, event: Input.Submitted) -> None: @@ -314,6 +344,19 @@ class ChatRoomScreen(Screen): if command == "/help": self.note(HELP) return + verb, _, arg = text.strip().partition(" ") + if verb.lower() in ("/put", "/get"): + arg = _unquote(arg.strip()) + if not arg: + self.note(f"{verb.lower()} FILE: " + ("the local file to send." + if verb.lower() == "/put" else "the file to save, named from " + "the chat's folder.")) + elif self._transferring: + self.note("! A file is on its way already; wait for it.", style="bold red") + else: + self.run_worker(self._put(arg) if verb.lower() == "/put" else self._get(arg), + group="transfer") + return if command.startswith("/") and not command.startswith("//"): self.note(f"Unknown command {safe(text.split()[0])}. {HELP}") return @@ -330,3 +373,128 @@ class ChatRoomScreen(Screen): def action_part(self) -> None: self.app.pop_screen() + + # ── Files ──────────────────────────────────────────────────────────────── + + async def _folder(self) -> tuple[Tree, str] | None: + tree = Tree.from_index(await self.session.index()) + folder = chat_folder(self.session.ack, tree.roots) + if not folder: + self.note("! This group has no folder for the chat's files: every one is " + "read-only or unavailable.", style="bold red") + return None + return tree, folder + + def _progress(self, arrow: str, name: str, size: int): + bar = self.query_one(".transfer", Static) + done = [0] + started = time.monotonic() + + def advance(n: int) -> None: + done[0] += n + rate = done[0] / max(time.monotonic() - started, 0.001) + pct = 100 * done[0] // size if size else 100 + bar.update(Text(f"{arrow} {safe(name)} {pct}% {human_size(int(rate))}/s", + no_wrap=True, overflow="ellipsis")) + + def queued(n: int) -> None: + bar.update(Text(f"{arrow} {safe(name)} waiting for a free slot on the node" + + (f" ({n} ahead)" if n else ""), no_wrap=True)) + bar.update(Text(f"{arrow} {safe(name)}", no_wrap=True)) + bar.display = True + return advance, queued + + def _transfer_ended(self) -> None: + self._transferring = False + bar = self.query_one(".transfer", Static) + bar.display = False + bar.update("") + + async def _put(self, arg: str) -> None: + path = Path(os.path.abspath(os.path.expanduser(arg))) + if not path.is_file(): + self.note(f"! {safe(arg)}: " + ("a folder; /put sends one file." + if path.is_dir() else "no such file."), style="bold red") + return + name = path.name + if problems := refused_names([name], []): + self.note(f"! Not sent: {safe(problems[0])}", style="bold red") + return + self._transferring = True + try: + await self._ready.wait() + if self.keys is None: + self.note("! Not sent: the chat could not be opened.", style="bold red") + return + found = await self._folder() + if found is None: + return + _, folder = found + size = path.stat().st_size + advance, queued = self._progress("↑", name, size) + with open(path, "rb") as fh: + def read(offset: int, length: int) -> bytes: + fh.seek(offset) + return fh.read(length) + stored = await upload(self.session, read, size, name, folder, + progress=advance, on_queued=queued) + try: + await post(self.session, self.keys, attachment_text(stored, size), + self.account.username) + except CliError as e: + self.note(f"! {safe(stored)} is in {safe(folder)}, but the message naming " + f"it was not sent: {safe(e.message)}", style="bold red") + return + except OSError as e: + self.note(f"! Not sent: {safe(arg)}: {e.strerror}", style="bold red") + return + except CliError as e: + self.note(f"! Not sent: {safe(e.message)}", style="bold red") + return + finally: + self._transfer_ended() + self.show(Message(0, time.time(), self.account.username, "", trust="pinned", + attachment={"filename": stored, "size": size, "type": "file"})) + if stored != name: + self.note(f"{INDENT}stored as {safe(stored)}: {safe(name)} was taken.") + + async def _get(self, arg: str) -> None: + name = arg.replace("\\", "/").strip("/") + if not name or any(p in ("", ".", "..") for p in name.split("/")): + self.note(f"! {safe(arg)}: name a file of the chat's folder.", style="bold red") + return + self._transferring = True + try: + found = await self._folder() + if found is None: + return + tree, folder = found + entry = tree.file(f"{folder}/{name}") + if entry is None: + self.note(f"! No file {safe(name)} in the chat's folder ({safe(folder)}).", + style="bold red") + return + target = Path.cwd() / name.rsplit("/", 1)[-1] + if target.exists() and not target.with_name(target.name + PART_SUFFIX).exists(): + self.note(f"! {safe(str(target))} is here already; nothing was downloaded.", + style="bold red") + return + size = int(entry["size"]) + advance, queued = self._progress("↓", name, size) + await download(self.session, entry, target, progress=advance, on_queued=queued) + except OSError as e: + self.note(f"! Not saved: {e.strerror}", style="bold red") + return + except CliError as e: + self.note(f"! Not saved: {safe(e.message)}", style="bold red") + return + finally: + self._transfer_ended() + self.note(f"Saved {safe(str(target))} ({human_size(size)}).") + + +def _unquote(text: str) -> str: + """A path pasted with its quotes, as a file manager or a shell gives it.""" + if len(text) >= 2 and text[0] == text[-1] and text[0] in "\"'": + return text[1:-1] + return text |