from __future__ import annotations import asyncio from datetime import datetime from typing import TYPE_CHECKING from textual.app import ComposeResult from textual.binding import Binding from textual.containers import Horizontal, Vertical from textual.screen import Screen from textual.widgets import Button, DataTable, Footer, Header, Input, RichLog, Static from db.database import SessionLocal from schemas import SendMessageRequest from services import conversation_service, message_service if TYPE_CHECKING: from db.models import Message # Kanal-Symbole und Farben CHANNEL_ICON = {"telegram": "✈", "whatsapp": "πŸ“±", "sms": "βœ‰"} CHANNEL_COLOR = {"telegram": "cyan", "whatsapp": "green", "sms": "yellow"} class MainScreen(Screen): BINDINGS = [ Binding("n", "new_message", "Neu"), Binding("r", "refresh", "Aktualisieren"), Binding("q", "quit_app", "Beenden"), ] def __init__(self) -> None: super().__init__() self._current_conv_id: str | None = None self._conv_id_map: dict[int, str] = {} # Zeilen-Index β†’ conv_id # ── Layout ───────────────────────────────────────────────────────────────── def compose(self) -> ComposeResult: yield Header(show_clock=True) with Horizontal(id="main-container"): # Seitenleiste with Vertical(id="sidebar"): yield Static("Konversationen", id="sidebar-title") yield DataTable(id="conv-table", cursor_type="row", show_header=True) # Chat with Vertical(id="chat-area"): yield Static("Kein Chat geΓΆffnet", id="chat-header") yield RichLog(id="message-log", highlight=True, markup=True, wrap=True) with Horizontal(id="input-bar"): yield Input( placeholder="Nachricht eingeben… (Enter = Senden)", id="msg-input", ) yield Button("Senden", variant="primary", id="send-btn") yield Footer() def on_mount(self) -> None: self._setup_table() self._load_conversations() # Neuer-Nachrichten-Callback registrieren message_service.add_new_message_callback(self._on_new_message) # ── Konversationsliste ───────────────────────────────────────────────────── def _setup_table(self) -> None: table = self.query_one("#conv-table", DataTable) table.add_column("K", width=2) table.add_column("Name", width=16) table.add_column("Letzte Nachricht", width=22) table.add_column("πŸ””", width=3) def _load_conversations(self) -> None: db = SessionLocal() try: convs = conversation_service.get_all(db) table = self.query_one("#conv-table", DataTable) table.clear() self._conv_id_map.clear() for idx, conv in enumerate(convs): last = conv.messages[-1] if conv.messages else None last_text = (last.text[:20] + "…") if last and len(last.text) > 20 else (last.text if last else "") icon = CHANNEL_ICON.get(conv.channel, "?") unread = conversation_service.unread_count(db, conv.id) badge = str(unread) if unread else "" table.add_row(icon, conv.title or conv.id[:8], last_text, badge) self._conv_id_map[idx] = conv.id finally: db.close() # ── Nachrichten ──────────────────────────────────────────────────────────── def _load_messages(self, conv_id: str) -> None: db = SessionLocal() try: conv = conversation_service.get_by_id(db, conv_id) if not conv: return icon = CHANNEL_ICON.get(conv.channel, "?") color = CHANNEL_COLOR.get(conv.channel, "white") header = self.query_one("#chat-header", Static) header.update(f"{icon} [{color}]{conv.title or conv.id[:8]}[/{color}] [{conv.channel}]") log = self.query_one("#message-log", RichLog) log.clear() msgs = conversation_service.get_messages(db, conv_id, limit=100) conversation_service.mark_all_read(db, conv_id) for msg in msgs: self._render_message(log, msg, color) finally: db.close() def _render_message(self, log: RichLog, msg: "Message", channel_color: str) -> None: ts = msg.created_at.strftime("%H:%M") if msg.created_at else "??" direction_prefix = "β–Ά " if msg.direction == "outbound" else "β—€ " status_suffix = " βœ—" if msg.status == "failed" else "" style = "dim" if msg.direction == "outbound" else "" log.write( f"[dim]{ts}[/dim] [{style}]{direction_prefix}{msg.text}{status_suffix}[/{style}]" ) # ── Events ───────────────────────────────────────────────────────────────── def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: row_idx = event.cursor_row conv_id = self._conv_id_map.get(row_idx) if conv_id: self._current_conv_id = conv_id self._load_messages(conv_id) # Ungelesen-Badge in Tabelle zurΓΌcksetzen table = self.query_one("#conv-table", DataTable) table.update_cell_at((row_idx, 3), "") self.query_one("#msg-input", Input).focus() def on_input_submitted(self, event: Input.Submitted) -> None: if event.input.id == "msg-input": self._send_current() def on_button_pressed(self, event: Button.Pressed) -> None: if event.button.id == "send-btn": self._send_current() def _send_current(self) -> None: inp = self.query_one("#msg-input", Input) text = inp.value.strip() if not text or not self._current_conv_id: return inp.clear() asyncio.create_task(self._send_async(text)) async def _send_async(self, text: str) -> None: db = SessionLocal() try: conv = conversation_service.get_by_id(db, self._current_conv_id) if not conv: return req = SendMessageRequest( channel=conv.channel, recipient_phone=conv.channel_conversation_id if conv.channel != "telegram" else None, recipient_telegram_id=conv.channel_conversation_id if conv.channel == "telegram" else None, text=text, ) await message_service.send(db, req) self._load_messages(self._current_conv_id) self._load_conversations() finally: db.close() async def _on_new_message(self, msg: "Message") -> None: """Callback: neue eingehende Nachricht β†’ TUI aktualisieren.""" self.call_from_thread(self._refresh_on_new_message, msg.conversation_id) def _refresh_on_new_message(self, conv_id: str) -> None: self._load_conversations() if self._current_conv_id == conv_id: self._load_messages(conv_id) # ── Actions ──────────────────────────────────────────────────────────────── def action_new_message(self) -> None: from tui.screens.compose_screen import ComposeScreen self.app.push_screen(ComposeScreen(), self._compose_callback) def _compose_callback(self, result: dict | None) -> None: if not result: return asyncio.create_task(self._send_compose(result)) async def _send_compose(self, result: dict) -> None: db = SessionLocal() try: channel = result["channel"] recipient = result["recipient"] req = SendMessageRequest( channel=channel, recipient_phone=recipient if channel != "telegram" else None, recipient_telegram_id=recipient if channel == "telegram" else None, text=result["text"], ) await message_service.send(db, req) self._load_conversations() finally: db.close() def action_refresh(self) -> None: self._load_conversations() if self._current_conv_id: self._load_messages(self._current_conv_id) def action_quit_app(self) -> None: self.app.exit()