diff --git a/tui/app.tcss b/tui/app.tcss index b058f23..adb18d6 100644 --- a/tui/app.tcss +++ b/tui/app.tcss @@ -104,9 +104,7 @@ ActivityScreen VerticalScroll { padding: 1 4; } -ComposeThreadScreen Vertical, -ComposeReplyScreen Vertical, -ComposeNewsScreen Vertical { +ComposeScreen Vertical { padding: 1 4; } diff --git a/tui/screens/compose/base.py b/tui/screens/compose/base.py new file mode 100644 index 0000000..b629d46 --- /dev/null +++ b/tui/screens/compose/base.py @@ -0,0 +1,98 @@ +"""Base class for all compose screens (thread, news, reply). + +Handles the shared post workflow: session check, body/title validation, +file upload, API call, error handling, and navigation on success. + +Subclasses define their own ``compose()`` layout using these widget IDs: + - ``#compose-title`` — optional Input (only if ``requires_title = True``) + - ``#compose-body`` — required TextArea + - ``#compose-file`` — optional Input for a file path + +Subclasses must override ``get_post_params()`` to return the keyword +arguments passed to ``create_post_record``. +""" + +from textual import work +from textual.screen import Screen +from textual.widgets import Input, TextArea + +from core import limits +from core.models import AuthError +from core.records import create_post_record +from tui.screens.compose.upload import upload_file +from tui.util import require_session + + +class ComposeScreen(Screen): + BINDINGS = [ + ("escape", "app.pop_screen", "back"), + ("ctrl+s", "post", "post"), + ] + + # Subclasses set these to control validation behavior. + requires_title: bool = False + post_type: str = "post" # used in error messages, e.g. "Failed to post thread" + + def get_post_params(self, title: str | None, body: str) -> dict: + """Return keyword arguments for ``create_post_record``. + + Called after validation passes. *title* is ``None`` when + ``requires_title`` is ``False``. + """ + raise NotImplementedError + + def action_post(self) -> None: + self._do_post() + + @work(exclusive=True) + async def _do_post(self) -> None: + session = require_session(self) + if not session: + return + + # -- Read form values ------------------------------------------------ + title = None + if self.requires_title: + title = self.query_one("#compose-title", Input).value.strip() + + body = self.query_one("#compose-body", TextArea).text.strip() + + # -- Validate -------------------------------------------------------- + if self.requires_title and not title: + self.notify("Title cannot be empty.", severity="error") + return + if not body: + self.notify("Body cannot be empty.", severity="error") + return + if len(body) > limits.POST_BODY: + self.notify( + f"Body too long ({len(body)}/{limits.POST_BODY}).", severity="error" + ) + return + + # -- Upload attachment (if any) -------------------------------------- + attachments = [] + file_path = self.query_one("#compose-file", Input).value.strip() + if file_path: + attachments = await upload_file(self, file_path, session) + if attachments is None: + return # upload_file already notified the user + + # -- Create the post record ------------------------------------------ + params = self.get_post_params(title, body) + try: + resp = await create_post_record( + self.app.http_client, + session, + attachments=attachments or None, + **params, + ) + resp.raise_for_status() + except AuthError: + self.notify("Session expired. Please log in again.", severity="error") + return + except Exception as error: + self.notify(f"Failed to post {self.post_type}: {error}", severity="error") + return + + self.app.pop_screen() diff --git a/tui/screens/compose/news.py b/tui/screens/compose/news.py index 159329f..cc7dd16 100644 --- a/tui/screens/compose/news.py +++ b/tui/screens/compose/news.py @@ -1,22 +1,16 @@ -from textual import work from textual.app import ComposeResult from textual.containers import Vertical -from textual.screen import Screen from textual.widgets import Footer, Input, Static, TextArea from core import lexicon, limits -from core.models import AtUri, AuthError, BBS -from core.records import create_post_record -from tui.util import require_session +from core.models import AtUri, BBS +from tui.screens.compose.base import ComposeScreen from tui.widgets.breadcrumb import Breadcrumb -from tui.screens.compose.upload import upload_file -class ComposeNewsScreen(Screen): - BINDINGS = [ - ("escape", "app.pop_screen", "back"), - ("ctrl+s", "post", "post"), - ] +class ComposeNewsScreen(ComposeScreen): + requires_title = True + post_type = "news" def __init__(self, bbs: BBS, handle: str) -> None: super().__init__() @@ -32,59 +26,15 @@ class ComposeNewsScreen(Screen): with Vertical(): yield Static("news", classes="title") yield Input( - placeholder="Title", id="news-title", max_length=limits.POST_TITLE + placeholder="Title", id="compose-title", max_length=limits.POST_TITLE ) - yield TextArea(id="news-body", language=None) - yield Input(placeholder="attach file (path, optional)", id="news-file") + yield TextArea(id="compose-body", language=None) + yield Input(placeholder="attach file (path, optional)", id="compose-file") yield Footer() def on_mount(self) -> None: - self.query_one("#news-title", Input).focus() - - def action_post(self) -> None: - self.post_news() - - @work(exclusive=True) - async def post_news(self) -> None: - session = require_session(self) - if not session: - return - - title = self.query_one("#news-title", Input).value.strip() - body = self.query_one("#news-body", TextArea).text.strip() - if not title or not body: - self.notify("Title and body cannot be empty.", severity="error") - return - if len(body) > limits.POST_BODY: - self.notify( - f"Body too long ({len(body)}/{limits.POST_BODY}).", severity="error" - ) - return + self.query_one("#compose-title", Input).focus() + def get_post_params(self, title: str | None, body: str) -> dict: site_uri = str(AtUri(self.bbs.identity.did, lexicon.SITE, "self")) - - attachments = [] - file_path = self.query_one("#news-file", Input).value.strip() - if file_path: - attachments = await upload_file(self, file_path, session) - if attachments is None: - return - - try: - resp = await create_post_record( - self.app.http_client, - session, - scope=site_uri, - body=body, - title=title, - attachments=attachments or None, - ) - resp.raise_for_status() - except AuthError: - self.notify("Session expired. Please log in again.", severity="error") - return - except Exception as error: - self.notify(f"Failed to post news: {error}", severity="error") - return - - self.app.pop_screen() + return {"scope": site_uri, "body": body, "title": title} diff --git a/tui/screens/compose/reply.py b/tui/screens/compose/reply.py index fd5ccac..e903a4f 100644 --- a/tui/screens/compose/reply.py +++ b/tui/screens/compose/reply.py @@ -1,21 +1,17 @@ -from textual import work from textual.app import ComposeResult from textual.containers import Vertical -from textual.screen import Screen from textual.widgets import Footer, Input, Static, TextArea -from core import limits -from core.models import AuthError, BBS, Post as PostModel -from core.records import create_post_record -from tui.util import require_session +from core.models import BBS, Post as PostModel +from tui.screens.compose.base import ComposeScreen from tui.widgets.breadcrumb import Breadcrumb -from tui.screens.compose.upload import upload_file -class ComposeReplyScreen(Screen): +class ComposeReplyScreen(ComposeScreen): + requires_title = False + post_type = "reply" + BINDINGS = [ - ("escape", "app.pop_screen", "back"), - ("ctrl+s", "post", "post"), ("ctrl+g", "toggle_reply_to", "toggle reply to"), ] @@ -47,12 +43,12 @@ class ComposeReplyScreen(Screen): classes="subtitle", id="reply-to-info", ) - yield TextArea(id="reply-body", language=None) - yield Input(placeholder="attach file (path, optional)", id="reply-file") + yield TextArea(id="compose-body", language=None) + yield Input(placeholder="attach file (path, optional)", id="compose-file") yield Footer() def on_mount(self) -> None: - self.query_one("#reply-body", TextArea).focus() + self.query_one("#compose-body", TextArea).focus() def action_toggle_reply_to(self) -> None: if not self.original_parent: @@ -73,51 +69,13 @@ class ComposeReplyScreen(Screen): classes="subtitle", id="reply-to-info", ), - before=self.query_one("#reply-body"), - ) - - def action_post(self) -> None: - self.post_reply() - - @work(exclusive=True) - async def post_reply(self) -> None: - session = require_session(self) - if not session: - return - - body = self.query_one("#reply-body", TextArea).text.strip() - if not body: - self.notify("Message body cannot be empty.", severity="error") - return - if len(body) > limits.POST_BODY: - self.notify( - f"Body too long ({len(body)}/{limits.POST_BODY}).", severity="error" + before=self.query_one("#compose-body"), ) - return - - attachments = [] - file_path = self.query_one("#reply-file", Input).value.strip() - if file_path: - attachments = await upload_file(self, file_path, session) - if attachments is None: - return - - try: - resp = await create_post_record( - self.app.http_client, - session, - scope=self.thread.scope, - body=body, - root=self.thread.uri, - parent=self.parent_post.uri if self.parent_post else None, - attachments=attachments or None, - ) - resp.raise_for_status() - except AuthError: - self.notify("Session expired. Please log in again.", severity="error") - return - except Exception as error: - self.notify(f"Failed to post reply: {error}", severity="error") - return - self.app.pop_screen() + def get_post_params(self, title: str | None, body: str) -> dict: + return { + "scope": self.thread.scope, + "body": body, + "root": self.thread.uri, + "parent": self.parent_post.uri if self.parent_post else None, + } diff --git a/tui/screens/compose/thread.py b/tui/screens/compose/thread.py index ac161a3..c42cdca 100644 --- a/tui/screens/compose/thread.py +++ b/tui/screens/compose/thread.py @@ -1,22 +1,16 @@ -from textual import work from textual.app import ComposeResult from textual.containers import Vertical -from textual.screen import Screen from textual.widgets import Footer, Input, Static, TextArea from core import lexicon, limits -from core.models import AtUri, AuthError, BBS, Board -from core.records import create_post_record -from tui.util import require_session +from core.models import AtUri, BBS, Board +from tui.screens.compose.base import ComposeScreen from tui.widgets.breadcrumb import Breadcrumb -from tui.screens.compose.upload import upload_file -class ComposeThreadScreen(Screen): - BINDINGS = [ - ("escape", "app.pop_screen", "back"), - ("ctrl+s", "post", "post"), - ] +class ComposeThreadScreen(ComposeScreen): + requires_title = True + post_type = "thread" def __init__(self, bbs: BBS, handle: str, board: Board) -> None: super().__init__() @@ -35,60 +29,16 @@ class ComposeThreadScreen(Screen): yield Static("new thread", classes="title") yield Input( placeholder="Thread title", - id="thread-title", + id="compose-title", max_length=limits.POST_TITLE, ) - yield TextArea(id="thread-body", language=None) - yield Input(placeholder="attach file (path, optional)", id="thread-file") + yield TextArea(id="compose-body", language=None) + yield Input(placeholder="attach file (path, optional)", id="compose-file") yield Footer() def on_mount(self) -> None: - self.query_one("#thread-title", Input).focus() - - def action_post(self) -> None: - self.post_thread() - - @work(exclusive=True) - async def post_thread(self) -> None: - session = require_session(self) - if not session: - return - - title = self.query_one("#thread-title", Input).value.strip() - body = self.query_one("#thread-body", TextArea).text.strip() - if not title or not body: - self.notify("Title and body cannot be empty.", severity="error") - return - if len(body) > limits.POST_BODY: - self.notify( - f"Body too long ({len(body)}/{limits.POST_BODY}).", severity="error" - ) - return + self.query_one("#compose-title", Input).focus() + def get_post_params(self, title: str | None, body: str) -> dict: board_uri = str(AtUri(self.bbs.identity.did, lexicon.BOARD, self.board.slug)) - - attachments = [] - file_path = self.query_one("#thread-file", Input).value.strip() - if file_path: - attachments = await upload_file(self, file_path, session) - if attachments is None: - return - - try: - resp = await create_post_record( - self.app.http_client, - session, - scope=board_uri, - body=body, - title=title, - attachments=attachments or None, - ) - resp.raise_for_status() - except AuthError: - self.notify("Session expired. Please log in again.", severity="error") - return - except Exception as error: - self.notify(f"Failed to post thread: {error}", severity="error") - return - - self.app.pop_screen() + return {"scope": board_uri, "body": body, "title": title}