diff --git a/.env.example b/.env.example index 31843a3..47f4d9e 100644 --- a/.env.example +++ b/.env.example @@ -9,7 +9,9 @@ API_ID=2040 API_HASH=b18441a1ff607e10a989891a5462e627 # Конфиг -CURRENT_USER = "user" -DO_NOTIFY = 0 # Linux-only for now -UTC_OFFSET = 0 -LANGUAGE = "en" # en, ru +CURRENT_USER = "user" # Name for user session file +DO_NOTIFY = 0 # Linux-only for now +UTC_OFFSET = 0 # UTC timezone offset +LANGUAGE = "en" # en, ru +CHATS_LIMIT = 100 # Chats mount limit +MESSAGES_LIMIT = 50 # Messages mount limit diff --git a/src/app.py b/src/app.py index 452d201..beb2714 100644 --- a/src/app.py +++ b/src/app.py @@ -10,12 +10,12 @@ from src.screens import AuthScreen, ChatScreen import src.locales load_dotenv() -API_ID = getenv("API_ID") -API_HASH = getenv("API_HASH") -LANGUAGE = getenv("LANGUAGE") -UTC_OFFSET = getenv("UTC_OFFSET") +env_consts = ["API_ID", "API_HASH", "LANGUAGE", "UTC_OFFSET", "CHATS_LIMIT", + "MESSAGES_LIMIT"] +for name in env_consts: + exec(f"{name} = getenv(\"{name}\")") -if "" in [API_ID, API_HASH, LANGUAGE, UTC_OFFSET]: +if "" in list(map(lambda x: globals()[x], env_consts)): raise ValueError( "Недостаточно параметров в .env файле." "Скопируйте .env.example в .env и заполните свои API-ключи." @@ -23,9 +23,10 @@ if "" in [API_ID, API_HASH, LANGUAGE, UTC_OFFSET]: "Copy .env.example into .env and fill your API-keys." ) -API_ID = int(API_ID) +API_ID = int(API_ID) # type: ignore locale = dict(zip( - getattr(src.locales, "codes"), getattr(src.locales, LANGUAGE) + getattr(src.locales, "codes"), + getattr(src.locales, LANGUAGE) # type: ignore )) class Talc(App): @@ -50,13 +51,15 @@ class Talc(App): ): super().__init__(driver_class, css_path, watch_css, ansi_color) self.locale = locale - self.timezone = timezone(timedelta(hours=int(UTC_OFFSET))) + self.timezone = timezone(timedelta(hours=int(UTC_OFFSET))) # type: ignore + self.CHATS_LIMIT = CHATS_LIMIT # type: ignore + self.MESSAGES_LIMIT = MESSAGES_LIMIT # type: ignore async def on_mount(self) -> None: self.telegram_client = TelegramClient( getenv("CURRENT_USER"), API_ID, - API_HASH + API_HASH # type: ignore ) await self.telegram_client.connect() diff --git a/src/screens.py b/src/screens.py index 5cbe30f..2abfd77 100644 --- a/src/screens.py +++ b/src/screens.py @@ -30,8 +30,15 @@ class AuthScreen(Screen): def compose(self) -> ComposeResult: with Vertical(id="auth_container"): yield Label(self.locale["auth_greeting"]) - yield Input(placeholder=self.locale["phone_number"], id="phone") - yield Input(placeholder=self.locale["code"], id="code", disabled=True) + yield Input( + placeholder=self.locale["phone_number"], + id="phone" + ) + yield Input( + placeholder=self.locale["code"], + id="code", + disabled=True + ) yield Input( placeholder=self.locale["password"], id="password", @@ -79,15 +86,17 @@ class ChatScreen(Screen): self.locale = self.app.locale async def on_mount(self) -> None: - self.limit = 100 + self.limit = int(self.app.CHATS_LIMIT) - #Получение ID пользователя (себя) + # Получение ID пользователя (себя) self.me_id = await self.telegram_client.get_peer_id("me") # Получение объекта контейнера чатов self.chat_container = self\ .query_one("#main_container")\ .query_one("#chats")\ .query_one("#chat_container") + self.switcher = self.screen.query_one(Horizontal)\ + .query_one("#dialog_switcher", ContentSwitcher) print("Первоначальная загрузка виджетов чатов...") self.mount_chats( @@ -120,7 +129,7 @@ class ChatScreen(Screen): chats_amount = len(self.chat_container.query(Chat)) if limit > chats_amount: - # Маунт недостоющих, если чатов меньше, чем нужно + # Маунт недостающих, если чатов меньше, чем нужно for i in range(limit - chats_amount): chat = Chat(id=f"chat-{i + chats_amount + 1}") self.chat_container.mount(chat) @@ -178,11 +187,24 @@ class ChatScreen(Screen): else: chat.msg = str(dialogs[i].message.message) + if self.switcher.current is not None: + current_dialog = \ + self.switcher.query_one(f"#{self.switcher.current}") + if chat.peer_id == int(current_dialog.id[7:]): + chat.add_class("selected_chat") + else: + chat.remove_class("selected_chat") + self.is_chat_update_blocked = False print("Чаты обновлены") else: print("Обновление чатов невозможно: уже выполняется") + if self.switcher.current is not None: + current_dialog = \ + self.switcher.query_one(f"#{self.switcher.current}") + await current_dialog.update_dialog() + def notify_send(self, event) -> None: if not event: return None @@ -196,9 +218,9 @@ class ChatScreen(Screen): yield VerticalScroll(id="chat_container") #TODO: сделать кнопку, чтобы прогрузить больше чатов, # или ленивую прокрутку - with ContentSwitcher(id="dialog_switcher"): + yield ContentSwitcher(id="dialog_switcher") # ↑ Внутри него как раз крутятся диалоги - yield Label( - self.locale["start_converse"], - id="start_converse_label" - ) #TODO: не показывается надпись, надо будет исправить + #yield Label( + # self.locale["start_converse"], + # id="start_converse_label" + #) #TODO: не показывается надпись, надо будет исправить diff --git a/src/style.tcss b/src/style.tcss index 139d5cd..154caa1 100644 --- a/src/style.tcss +++ b/src/style.tcss @@ -74,10 +74,44 @@ ContentSwitcher { height: 100%; } -.start_converse_label { +#start_converse_label { width: 100%; height: 100%; text-align: center; content-align: center middle; color: $panel; } + +TopBar { + max-height: 3; + width: 100%; + background: $surface; +} + +TopBar Horizontal { + align: left middle; +} + +.avatar { + border: solid $foreground; + text-align: center; + width: 5; + height: 3; +} + +.peername_top_bar { + padding: 1; +} + +.odd { + background: $surface; +} + +.selected_chat { + background: $foreground; + color: $background +} + +.selected_chat .avatar{ + border: solid $background; +} diff --git a/src/widgets.py b/src/widgets.py index 6007b34..229be3a 100644 --- a/src/widgets.py +++ b/src/widgets.py @@ -2,24 +2,22 @@ from textual.containers import Horizontal, Vertical, Container, VerticalScroll from textual.widget import Widget -from textual.reactive import Reactive +from textual.reactive import reactive from textual.widgets import Input, Button, Label, Static, ContentSwitcher from textual.app import ComposeResult, RenderResult from textual.content import Content from textual.style import Style from telethon import TelegramClient, events, utils, types -import datetime -from os import getenv class Chat(Widget): """Класс виджета чата для панели чатов""" - username: Reactive[str] = Reactive(" ", recompose=True) - peername: Reactive[str] = Reactive(" ", recompose=True) - msg: Reactive[str] = Reactive(" ", recompose=True) - is_group: Reactive[bool] = Reactive(False, recompose=True) - is_channel: Reactive[bool] = Reactive(False, recompose=True) - peer_id: Reactive[int] = Reactive(0) + username: reactive[str] = reactive(" ", recompose=True) + peername: reactive[str] = reactive(" ", recompose=True) + msg: reactive[str] = reactive(" ", recompose=True) + is_group: reactive[bool] = reactive(False, recompose=True) + is_channel: reactive[bool] = reactive(False) + peer_id: reactive[int] = reactive(0) def __init__( self, @@ -38,6 +36,11 @@ class Chat(Widget): def on_mount(self) -> None: self.switcher = self.screen.query_one(Horizontal)\ .query_one("#dialog_switcher", ContentSwitcher) + + if int(self.id[5:]) % 2 != 0: + self.add_class("odd") + else: + self.add_class("even") def on_click(self) -> None: # Получение ID диалога и создание DOM-ID на его основе @@ -60,7 +63,7 @@ class Chat(Widget): def compose(self) -> ComposeResult: with Horizontal(): - yield Label(f"┌───┐\n│ {self.peername[:1]:1} │\n└───┘") + yield Label(self.peername[:1], classes="avatar") with Vertical(): yield Label(self.peername, id="peername", markup=False) if self.is_group: @@ -87,25 +90,19 @@ class Dialog(Widget): self.is_channel = is_channel async def on_mount(self) -> None: - self.limit = 10 + self.limit = int(self.app.MESSAGES_LIMIT) if not self.is_channel: self.msg_input = self.query_one("#msg_input") self.dialog = self.query_one(Vertical).query_one("#dialog") + self.top_bar = self.query_one(Vertical).query_one(TopBar) + self.switcher = self.screen.query_one(Horizontal)\ + .query_one("#dialog_switcher", ContentSwitcher) self.me = await self.telegram_client.get_me() await self.update_dialog() - for event in ( - events.NewMessage, - events.MessageDeleted, - events.MessageEdited - ): - self.telegram_client.on( - event(chats=(self.chat_id)) - )(self.update_dialog) - #self.dialog.scroll_down(animate=False, immediate=True) def mount_messages(self, limit: int) -> None: @@ -126,7 +123,7 @@ class Dialog(Widget): async def update_dialog(self, event = None) -> None: print("Запрос обновления сообщений") - if not self.is_msg_update_blocked: + if not self.is_msg_update_blocked and self.switcher.current == self.id: self.is_msg_update_blocked = True messages = await self.telegram_client.get_messages( @@ -186,10 +183,14 @@ class Dialog(Widget): msg.is_me = is_me msg.username = utils.get_display_name(messages[i].sender) - msg.send_time = messages[i]\ + msg.info = messages[i]\ .date\ .astimezone(self.timezone)\ .strftime("%H:%M") + + self.top_bar.peername = utils.get_display_name( + await self.telegram_client.get_entity(self.chat_id) + ) self.is_msg_update_blocked = False print("Сообщения обновлены") @@ -198,6 +199,7 @@ class Dialog(Widget): def compose(self) -> ComposeResult: with Vertical(): + yield TopBar() yield VerticalScroll(id="dialog") if not self.is_channel: with Horizontal(id="input_place"): @@ -227,27 +229,18 @@ class Dialog(Widget): class Message(Widget): """Класс виджета сообщений для окна диалога""" - message: Reactive[Content] = Reactive("", recompose=True) - is_me: Reactive[bool] = Reactive(False, recompose=True) - username: Reactive[str] = Reactive("", recompose=True) - send_time: Reactive[str] = Reactive("", recompose=True) + message: reactive[Content] = reactive("", recompose=True) + is_me: reactive[bool] = reactive(False, recompose=True) + username: reactive[str] = reactive("", recompose=True) + info: reactive[str] = reactive("", recompose=True) - def __init__( - self, - name=None, - id=None, - classes=None, - disabled=False - ) -> None: - super().__init__(name=name, id=id, classes=classes, disabled=disabled) - - def on_mount(self) -> None: - pass + def __init__(self, id=None) -> None: + super().__init__(id=id) def compose(self) -> ComposeResult: label = Label(self.message, markup=False) label.border_title = self.username * (not self.is_me) - label.border_subtitle = self.send_time + label.border_subtitle = self.info with Container(): yield label @@ -256,3 +249,13 @@ class Message(Widget): self.classes = "is_me_true" else: self.classes = "is_me_false" + +class TopBar(Widget): + """Класс виджета верхней панели для окна диалога""" + + peername: reactive[str] = reactive(" ", recompose=True) + + def compose(self) -> ComposeResult: + with Horizontal(): + yield Label(self.peername[:1], classes="avatar") + yield Label(self.peername, classes="peername_top_bar") diff --git a/style.tcss b/style.tcss deleted file mode 100644 index 818a84e..0000000 --- a/style.tcss +++ /dev/null @@ -1,119 +0,0 @@ -/* Основные стили */ -Screen { - background: $surface; - color: $text; -} - -/* Стили для подсказки */ -.help-text { - color: $text-muted; - text-align: center; - padding: 1; - background: $surface; - border: solid $accent; - margin: 1; -} - -/* Стили для чатов */ -.chat-item { - padding: 1 2; - height: auto; - min-height: 3; - border: solid $accent; - margin: 1 0; - transition: background 500ms; -} - -.chat-item:hover { - background: $accent 20%; -} - -.chat-item.selected { - background: $accent 30%; - border: solid $accent; -} - -.chat-item.focused { - background: $accent 40%; - border: solid $accent; - outline: solid $accent; -} - -.chat-item.selected.focused { - background: $accent 50%; -} - -#chat_container { - height: 100%; - border: solid $accent; - background: $surface; -} - -/* Стили для диалога */ -#dialog { - height: 100%; - border: solid $accent; - background: $surface; -} - -#input_place { - height: auto; - padding: 1; - background: $surface; - border: solid $accent; -} - -/* Стили для сообщений */ -.message { - margin: 1 0; - padding: 1; - border: solid $accent; -} - -.message.is_me_true { - background: $accent 20%; - margin-left: 20%; -} - -.message.is_me_false { - background: $surface; - margin-right: 20%; -} - -/* Стили для ASCII-арта */ -.ascii-art { - font-family: monospace; - white-space: pre; - margin: 1 0; - padding: 1; - background: $surface; - border: solid $accent; -} - -/* Стили для поиска */ -#search_input { - margin: 1; - border: solid $accent; -} - -/* Стили для кнопок */ -Button { - margin: 1; - min-width: 10; -} - -Button#load_more { - width: 100%; - margin: 0; - border: none; - background: $accent 20%; -} - -Button#load_more:hover { - background: $accent 30%; -} - -Button#load_more:disabled { - background: $accent 10%; - color: $text-muted; -} \ No newline at end of file