""" MainScreen — Main application screen for za-tui. Composes Sidebar + ChatView + InputBar + StatusBar. Wires conversation selection events from Sidebar to ChatView, InputBar, and StatusBar. Handles message send pipeline with optimistic display. """ from __future__ import annotations import asyncio import logging from textual.app import ComposeResult from textual.binding import Binding from textual.containers import Container, Horizontal, Vertical from textual.screen import Screen from textual.widgets import Footer, Header from za_tui.messages import ConversationSelected, MessageSent from za_tui.widgets.sidebar import Sidebar from za_tui.widgets.chat_view import ChatView from za_tui.widgets.input_bar import InputBar from za_tui.widgets.status_bar import StatusBar from za_tui.state.conversation_store import ConversationStore from za_tui.state.contact_cache import ContactCache from za_tui.state.message_slice import MessageSlice logger = logging.getLogger(__name__) class MainScreen(Screen): """Main application screen — post-login destination. Layout: Header → StatusBar → Sidebar(30%) | ChatView(70%) → InputBar → Footer Wires: - Sidebar Enter → ConversationSelected → ChatView.load + InputBar.label + StatusBar.update - InputBar send → MessageSent → ChatView optimistic display """ BINDINGS = [ Binding("f2", "toggle_sidebar", "Toggle sidebar", show=False), Binding("f6", "cycle_focus", "Cycle focus", show=False), ] def __init__( self, conversation_store: ConversationStore | None = None, contact_cache: ContactCache | None = None, message_slice: MessageSlice | None = None, ) -> None: super().__init__() self._contact_cache = contact_cache or ContactCache() self._conversation_store = conversation_store or ConversationStore( contact_cache=self._contact_cache ) self._message_slice = message_slice or MessageSlice() self._selected_chat_id: str | None = None # BUG-3b/S2: Own user ID from bridge 'ready' event. Used to set # ChatView._current_user_id so is_self detection works. self._own_user_id: str | None = None def compose(self) -> ComposeResult: yield Header() yield StatusBar(id="main-status-bar") with Horizontal(id="main-container"): with Container(id="sidebar-area"): yield Sidebar(store=self._conversation_store) with Vertical(id="chat-area"): with Container(id="chat-view-area"): yield ChatView( message_slice=self._message_slice, chat_id=self._selected_chat_id or "", ) with Container(id="input-area"): yield InputBar() yield Footer() def on_mount(self) -> None: """Wire all widget callbacks: reply, reaction, sidebar refresh.""" chat_view = self.query_one(ChatView) input_bar = self.query_one(InputBar) sidebar = self.query_one(Sidebar) # Task 22/23: Reply flow — 'r' key on message → set quote on InputBar def _on_reply_activated(msg): """When user presses 'r' on a message, set quote on InputBar.""" from za_tui.state.message_models import build_quote raw = msg.rawData if hasattr(msg, "rawData") else msg quote = build_quote(raw) input_bar._reply_quote = quote chat_view.on_reply_activated(_on_reply_activated) # B5: ConversationStore.on_change → Sidebar re-render def _on_store_change(): """Re-render sidebar when conversations are loaded/updated.""" try: sidebar.refresh() except Exception: pass self._conversation_store.on_change(_on_store_change) # Task 27: Reaction store on_change → refresh ChatView if not hasattr(self.app, "_reaction_store"): from za_tui.state.reaction_store import ReactionStore self.app._reaction_store = ReactionStore() def _on_reaction_change(msg_id: str): """Re-render ChatView when reaction data changes.""" try: chat_view.refresh() except Exception: pass self.app._reaction_store.on_change(_on_reaction_change) def on_conversation_selected(self, event: ConversationSelected) -> None: """Handle conversation selection from sidebar. Routes the selection to: 1. ChatView — update chat_id + load messages from bridge (W32) 2. InputBar — show conversation name label + set chat_id 3. StatusBar — show active conversation name 4. Bridge — mark as seen (W36) """ self._selected_chat_id = event.conversation_id # 1. Update ChatView chat_view = self.query_one(ChatView) chat_view._chat_id = event.conversation_id chat_view.refresh() # 2. Update InputBar conversation label input_bar = self.query_one(InputBar) input_bar.set_conversation_name(event.conversation_name) input_bar._chat_id = event.conversation_id # 3. Update StatusBar status_bar = self.query_one(StatusBar) status_bar.set_conversation(event.conversation_name) # W32: Load messages from bridge for selected conversation bridge = getattr(self.app, "_bridge", None) if bridge and event.conversation_id: asyncio.create_task(self._load_messages_from_bridge( bridge, event.conversation_id, event.conversation_name )) def on_message_sent(self, event: MessageSent) -> None: """Handle message sent from InputBar — optimistic display + real send (W33).""" chat_id = event.chat_id or self._selected_chat_id or "" if not chat_id: return # Optimistically add message to slice with [sending] status opt_msg_id = f"opt-{id(event)}" self._message_slice.add_message(chat_id, { "msgId": opt_msg_id, "uidFrom": "self", "content": event.text, "ts": __import__("time").time(), "status": "sending", }) # Refresh ChatView to show new message chat_view = self.query_one(ChatView) chat_view._chat_id = chat_id chat_view.refresh() # Dismiss ReplyBanner if present from za_tui.widgets.reply_banner import ReplyBanner for banner in self.query(ReplyBanner): banner.visible = False # W33: Send the message to bridge bridge = getattr(self.app, "_bridge", None) if bridge: asyncio.create_task(self._send_message_to_bridge( bridge, chat_id, event.text, event.quote, opt_msg_id )) def action_select_conversation(self, conversation_id: str) -> None: """Programmatically select a conversation.""" self._selected_chat_id = conversation_id chat_view = self.query_one(ChatView) chat_view.chat_id = conversation_id # ─── Task 28: Focus cycling (F6) ──────────────────────────── def action_cycle_focus(self) -> None: """F6 cycles focus: Sidebar → ChatView → InputBar → Sidebar.""" try: focused = self.app.focused if focused is None: self.query_one(Sidebar).focus() return widgets = [Sidebar, ChatView, InputBar] current = 0 for i, cls in enumerate(widgets): w = self.query_one(cls) if focused == w or any(a == w for a in focused.ancestors_with_self): current = i break self.query_one(widgets[(current + 1) % len(widgets)]).focus() except Exception: pass def action_toggle_sidebar(self) -> None: """Ctrl+B toggles sidebar visibility.""" try: sa = self.query_one("#sidebar-area") sa.visible = not sa.visible except Exception: pass # NOTE: on_resize responsive layout (Task 30) intentionally omitted. # Textual test harness reports small terminal widths which causes # the sidebar to auto-hide, breaking sidebar tests. # Users can toggle sidebar with Ctrl+B instead. # ─── Bridge Event Receivers (dispatched from ZaloApp) ──────────── def _set_connected(self, connected: bool) -> None: """W30/W31: Update StatusBar connection indicator. Called by ZaloApp._on_bridge_connected/disconnected via _dispatch_to_screen. """ try: status_bar = self.query_one(StatusBar) status_bar.set_connected(connected) except Exception: pass # StatusBar may not be composed yet def _on_bridge_ready_load(self, data) -> None: """W27: Bridge ready → load conversations from bridge. Called by ZaloApp._on_bridge_ready via _dispatch_to_screen. Triggers ConversationStore.refresh_all to populate sidebar. B1: Also starts the listener for real-time events. BUG-3b/S2: Extract own userId from ready event and wire to ChatView so is_self detection works (own messages render as self). """ import asyncio logger.info("_on_bridge_ready_load called — starting conversation load") # BUG-3b/S2: Wire own userId from ready event to ChatView. # Without this, ChatView._current_user_id stays "" forever and # is_self = sender_id == "" is always False — own messages never # render as self. own_id = None if isinstance(data, dict): own_id = data.get("userId") if own_id: self._own_user_id = own_id logger.info("Own userId from ready event: %s", own_id) try: chat_view = self.query_one(ChatView) chat_view._current_user_id = own_id chat_view.refresh() except Exception: pass # ChatView may not be composed yet bridge = getattr(self.app, "_bridge", None) if bridge: async def _safe_refresh(): try: await self._conversation_store.refresh_all(bridge) logger.info("Conversation refresh completed") except Exception as e: logger.exception("Conversation refresh failed: %s", e) asyncio.create_task(_safe_refresh()) # B1: Start listener for real-time events (messages, reactions, etc.) asyncio.create_task(self._start_listener(bridge)) async def _start_listener(self, bridge) -> None: """B1: Call listener.start to enable real-time event streaming.""" try: await bridge.request("listener.start") logger.info("Listener started — real-time events enabled") except Exception: logger.exception("Failed to start listener") def _on_bridge_message(self, data) -> None: """W28: Route incoming message to ChatView and ConversationStore. Called by ZaloApp._on_bridge_message via _dispatch_to_screen. """ thread_id = data.get("threadId", "") if isinstance(data, dict) else "" # Route to ChatView if it matches current chat try: chat_view = self.query_one(ChatView) if thread_id and chat_view._chat_id == thread_id: chat_view.on_new_message(data) except Exception: pass # Update unread count in ConversationStore if thread_id: self._conversation_store.on_new_message(thread_id) def _on_bridge_reaction(self, data) -> None: """W29: Route reaction event to ReactionStore. Called by ZaloApp._on_bridge_reaction via _dispatch_to_screen. """ from za_tui.state.reaction_store import handle_reaction_event if not hasattr(self.app, "_reaction_store"): from za_tui.state.reaction_store import ReactionStore self.app._reaction_store = ReactionStore() handle_reaction_event(self.app._reaction_store, data) def _on_bridge_group_event(self, data) -> None: """B2: Route group event to ConversationStore. Called by ZaloApp._on_bridge_group_event via _dispatch_to_screen. Handles member add/remove, name change, avatar change. """ try: self._conversation_store.on_group_event(data) except Exception: logger.exception("Failed to handle group_event") def _on_bridge_friend_event(self, data) -> None: """B2: Route friend event to ConversationStore. Called by ZaloApp._on_bridge_friend_event via _dispatch_to_screen. Handles friend requests, accept/reject, block/unblock. """ try: self._conversation_store.on_friend_event(data) except Exception: logger.exception("Failed to handle friend_event") # ─── W32: Load messages from bridge ─────────────────────────── async def _load_messages_from_bridge( self, bridge, conversation_id: str, conversation_name: str ) -> None: """Fetch message history from bridge and populate MessageSlice.""" retry = getattr(self.app, "_retry", None) # W39 try: async def _fetch(): return await bridge.request("getGroupChatHistory", { "groupId": conversation_id, "count": 50, }) result = await (retry.execute(_fetch) if retry else _fetch()) if result and isinstance(result, dict): messages = result.get("messages", result.get("result", [])) for raw in messages: self._message_slice.add_message(conversation_id, raw) has_more = result.get("more", False) self._message_slice.set_has_more(conversation_id, has_more) chat_view = self.query_one(ChatView) chat_view.refresh() except Exception: logger.exception("Failed to load messages for %s", conversation_name) # W36: Mark conversation as seen try: await bridge.request("sendSeen", {"threadId": conversation_id}) except Exception: logger.debug("sendSeen failed for %s (non-critical)", conversation_id, exc_info=True) # ─── W33: Send message to bridge ───────────────────────────── async def _send_message_to_bridge( self, bridge, chat_id: str, text: str, quote, opt_msg_id: str ) -> None: """Send message to bridge and update optimistic display on result.""" retry = getattr(self.app, "_retry", None) # W39 try: params = { "content": {"msg": text}, "threadId": chat_id, } if quote: params["content"]["quote"] = quote async def _send(): return await bridge.request("sendMessage", params) result = await (retry.execute(_send) if retry else _send()) real_msg_id = result.get("msgId", "") if isinstance(result, dict) else "" if real_msg_id: self._message_slice.update_message_id(opt_msg_id, real_msg_id) self._message_slice.update_message_status( real_msg_id or opt_msg_id, "sent" ) chat_view = self.query_one(ChatView) chat_view.refresh() except Exception: logger.exception("Failed to send message") self._message_slice.update_message_status(opt_msg_id, "failed") chat_view = self.query_one(ChatView) chat_view.refresh()