From c18f43664e81cfee6dd8e6af2de0d837802e788e Mon Sep 17 00:00:00 2001 From: Mikhail Kilin Date: Thu, 22 Jan 2026 15:26:15 +0300 Subject: [PATCH] fixes --- CONTEXT.md | 67 +++++++++++++++++------ ROADMAP.md | 75 +++++++++++++++++++------- src/app/mod.rs | 10 ++++ src/main.rs | 98 +++++++++++++++++++++++++++------- src/tdlib/client.rs | 70 +++++++++++++++++++++++- src/tdlib/mod.rs | 1 + src/ui/chat_list.rs | 12 ++++- src/ui/footer.rs | 36 +++++++++---- src/ui/messages.rs | 127 ++++++++++++++++++++++++++++++++++++++------ src/ui/mod.rs | 27 ++++++++++ 10 files changed, 436 insertions(+), 87 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index b48a090..8dc03c8 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -1,6 +1,6 @@ # Текущий контекст проекта -## Статус: Фаза 3 — улучшение UX +## Статус: Фаза 6 завершена — Полировка ### Что сделано @@ -10,6 +10,7 @@ - Сессия сохраняется автоматически в папке `tdlib_data/` - Отключены логи TDLib через FFI вызов `td_execute` до создания клиента - Updates обрабатываются в отдельном потоке через `mpsc` канал (неблокирующе) +- **Graceful shutdown**: корректное закрытие TDLib при выходе (Ctrl+C) #### Функциональность - Загрузка списка чатов (до 50 штук) @@ -17,10 +18,14 @@ - **Фильтрация удалённых аккаунтов**: "Deleted Account" не отображаются в списке - Отображение названия чата, счётчика непрочитанных и **@username** - **Иконка 📌** для закреплённых чатов +- **Иконка 🔇** для замьюченных чатов +- **Индикатор @** для чатов с непрочитанными упоминаниями +- **Онлайн-статус**: зелёная точка ● для онлайн пользователей - Загрузка истории сообщений при открытии чата (множественные попытки) - **Группировка сообщений по дате** (разделители "Сегодня", "Вчера", дата) — по центру - **Группировка сообщений по отправителю** (заголовок с именем) - **Выравнивание сообщений**: исходящие справа (зелёные), входящие слева +- **Перенос длинных сообщений**: автоматический wrap на несколько строк - **Отображение времени и галочек**: `текст (HH:MM ✓✓)` для исходящих, `(HH:MM) текст` для входящих - **Галочки прочтения** (✓ отправлено, ✓✓ прочитано) — обновляются в реальном времени - **Отметка сообщений как прочитанных**: при открытии чата счётчик непрочитанных сбрасывается @@ -28,6 +33,27 @@ - **Новые сообщения в реальном времени** при открытом чате - **Поиск по чатам** (Ctrl+S): фильтрация по названию и @username - **Кеширование имён пользователей**: имена загружаются асинхронно и обновляются в UI +- **Папки Telegram**: загрузка и переключение между папками (1-9) +- **Медиа-заглушки**: [Фото], [Видео], [Голосовое], [Стикер], [GIF] и др. + +#### Состояние сети +- **Индикатор в футере**: показывает текущее состояние подключения + - `⚠ Нет сети` — красный, ожидание сети + - `⏳ Прокси...` — cyan, подключение к прокси + - `⏳ Подключение...` — cyan, подключение к серверам + - `⏳ Обновление...` — cyan, синхронизация данных + +#### Оптимизации +- **60 FPS ready**: poll таймаут 16ms, рендеринг только при изменениях (`needs_redraw` флаг) +- **Оптимизация памяти**: + - Очистка сообщений при закрытии чата + - Лимит кэша пользователей (500) + - Периодическая очистка неактивных записей +- **Минимальное разрешение**: предупреждение если терминал меньше 80x20 + +#### Динамический инпут +- **Автоматическое расширение**: поле ввода увеличивается при длинном тексте (до 10 строк) +- **Перенос текста**: длинные сообщения переносятся на новые строки #### Управление - `↑/↓` стрелки — навигация по списку чатов @@ -35,7 +61,7 @@ - `Esc` — закрыть открытый чат / отменить поиск - `Ctrl+S` — поиск по чатам (фильтрация по названию и username) - `Ctrl+R` — обновить список чатов -- `Ctrl+C` — выход +- `Ctrl+C` — выход (graceful shutdown) - `↑/↓` в открытом чате — скролл сообщений (с подгрузкой старых) - `1-9` — переключение папок (в списке чатов) - Ввод текста в поле сообщения @@ -44,26 +70,26 @@ ``` src/ -├── main.rs # Точка входа, event loop, TDLib инициализация +├── main.rs # Точка входа, event loop, TDLib инициализация, graceful shutdown ├── app/ -│ ├── mod.rs # App структура и состояние +│ ├── mod.rs # App структура и состояние (needs_redraw флаг) │ └── state.rs # AppScreen enum ├── ui/ -│ ├── mod.rs # Роутинг UI по экранам +│ ├── mod.rs # Роутинг UI по экранам, проверка минимального размера │ ├── loading.rs # Экран загрузки │ ├── auth.rs # Экран авторизации -│ ├── main_screen.rs # Главный экран -│ ├── chat_list.rs # Список чатов (с pin и username) -│ ├── messages.rs # Область сообщений (выравнивание, группировка) -│ └── footer.rs # Подвал с командами +│ ├── main_screen.rs # Главный экран с папками +│ ├── chat_list.rs # Список чатов (pin, mute, online, mentions) +│ ├── messages.rs # Область сообщений (wrap, группировка, динамический инпут) +│ └── footer.rs # Подвал с командами и статусом сети ├── input/ │ ├── mod.rs # Роутинг ввода │ ├── auth.rs # Обработка ввода на экране авторизации │ └── main_input.rs # Обработка ввода на главном экране ├── utils.rs # Утилиты (disable_tdlib_logs, format_timestamp, format_date, get_day) └── tdlib/ - ├── mod.rs # Модуль экспорта - └── client.rs # TdClient: авторизация, чаты, сообщения, кеш имён + ├── mod.rs # Модуль экспорта (TdClient, UserOnlineStatus, NetworkState) + └── client.rs # TdClient: авторизация, чаты, сообщения, кеш, NetworkState ``` ### Ключевые решения @@ -74,13 +100,17 @@ src/ 3. **Синхронизация чатов**: Чаты загружаются асинхронно через updates. Main loop периодически синхронизирует `app.chats` с `td_client.chats`. -4. **Кеширование имён**: При получении `Update::User` сохраняем имя (first_name + last_name) и username в HashMap. Имена подгружаются асинхронно через очередь `pending_user_ids`. +4. **Кеширование имён**: При получении `Update::User` сохраняем имя (first_name + last_name) и username в HashMap. Имена подгружаются асинхронно через очередь `pending_user_ids`. Кэш ограничен 500 записями. 5. **Группировка сообщений**: Сообщения группируются по дате (разделители по центру) и по отправителю (заголовки). Исходящие выравниваются вправо, входящие влево. 6. **Отметка прочтения**: При открытии чата вызывается `view_messages` для всех сообщений. Новые входящие сообщения автоматически отмечаются как прочитанные. `Update::ChatReadOutbox` обновляет статус галочек. -7. **Фильтрация удалённых аккаунтов**: Чаты с названием "Deleted Account" или пустым именем пользователя автоматически удаляются из списка. +7. **Graceful shutdown**: При Ctrl+C устанавливается флаг остановки, закрывается TDLib клиент, ожидается завершение polling задачи с таймаутом 2 сек. + +8. **Оптимизация рендеринга**: Флаг `needs_redraw` позволяет пропускать перерисовку когда ничего не изменилось. Триггеры: TDLib updates, пользовательский ввод, изменение размера терминала. + +9. **Перенос текста**: Длинные сообщения автоматически разбиваются на строки с учётом ширины терминала. Для исходящих — time_mark на последней строке, для входящих — время на первой строке с отступом для остальных. ### Зависимости (Cargo.toml) @@ -92,6 +122,7 @@ tokio = { version = "1", features = ["full"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" dotenvy = "0.15" +chrono = "0.4" ``` ### Переменные окружения (.env) @@ -101,12 +132,14 @@ API_ID=your_api_id API_HASH=your_api_hash ``` -## Что НЕ сделано / TODO +## Что НЕ сделано / TODO (Фаза 7) -- [ ] Папки телеграма (сейчас только "All") -- [ ] Отображение онлайн-статуса пользователя +- [ ] Удалить дублирование current_messages между App и TdClient +- [ ] Использовать единый источник данных для сообщений +- [ ] Реализовать LRU-кэш для user_names/user_statuses вместо простого лимита +- [ ] Lazy loading для имён пользователей (загружать только видимых) +- [ ] Профилирование памяти и устранение утечек - [ ] Markdown форматирование в сообщениях -- [ ] Медиа-сообщения (фото, видео, голосовые) ## Известные проблемы diff --git a/ROADMAP.md b/ROADMAP.md index fcf9c4e..eef626f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -17,33 +17,68 @@ - [x] Загрузка истории сообщений - [x] Отключение логов TDLib -## Фаза 3: Улучшение UX [IN PROGRESS] +## Фаза 3: Улучшение UX [DONE] - [x] Отправка сообщений - [x] Фильтрация чатов (только Main, без архива) - [x] Поиск по чатам (Ctrl+S) -- [ ] Скролл истории сообщений -- [ ] Загрузка имён пользователей (вместо User_ID) -- [ ] Отметка сообщений как прочитанные -- [ ] Реальное время: новые сообщения +- [x] Скролл истории сообщений +- [x] Загрузка имён пользователей (вместо User_ID) +- [x] Отметка сообщений как прочитанные +- [x] Реальное время: новые сообщения -## Фаза 4: Папки и фильтрация +## Фаза 4: Папки и фильтрация [DONE] -- [ ] Загрузка папок из Telegram -- [ ] Переключение между папками (Cmd+1, Cmd+2, ...) -- [ ] Фильтрация чатов по папке +- [x] Загрузка папок из Telegram +- [x] Переключение между папками (1-9) +- [x] Фильтрация чатов по папке -## Фаза 5: Расширенный функционал +## Фаза 5: Расширенный функционал [DONE] -- [ ] Отображение онлайн-статуса -- [ ] Статус доставки/прочтения (✓, ✓✓) -- [ ] Поддержка медиа-заглушек (фото, видео, голосовые) -- [ ] Mentions (@) -- [ ] Muted чаты (серый цвет) +- [x] Отображение онлайн-статуса (зелёная точка ●) +- [x] Статус доставки/прочтения (✓, ✓✓) +- [x] Поддержка медиа-заглушек (фото, видео, голосовые, стикеры и др.) +- [x] Mentions (@) — индикатор непрочитанных упоминаний +- [x] Muted чаты (иконка 🔇) -## Фаза 6: Полировка +## Фаза 6: Полировка [DONE] -- [ ] Оптимизация 60 FPS -- [ ] Минимальное разрешение 600 символов -- [ ] Обработка ошибок сети -- [ ] Graceful shutdown +- [x] Оптимизация использования памяти (базовая) + - Очистка сообщений при закрытии чата + - Лимит кэша пользователей (500) + - Периодическая очистка неактивных записей +- [x] Оптимизация 60 FPS + - Poll таймаут 16ms + - Флаг `needs_redraw` — рендеринг только при изменениях + - Обработка Event::Resize для перерисовки при изменении размера +- [x] Минимальное разрешение (80x20) + - Предупреждение если терминал слишком мал +- [x] Обработка ошибок сети + - NetworkState enum (WaitingForNetwork, Connecting, etc.) + - Индикатор в футере с цветовой индикацией +- [x] Graceful shutdown + - AtomicBool флаг для остановки polling + - Корректное закрытие TDLib клиента + - Таймаут ожидания завершения задач +- [x] Динамический инпут + - Автоматическое расширение до 10 строк + - Wrap для длинного текста +- [x] Перенос длинных сообщений + - Автоматический wrap на несколько строк + - Правильное выравнивание для исходящих/входящих + +## Фаза 7: Глубокий рефакторинг памяти [TODO] + +- [ ] Удалить дублирование current_messages между App и TdClient +- [ ] Использовать единый источник данных для сообщений +- [ ] Реализовать LRU-кэш для user_names/user_statuses вместо простого лимита +- [ ] Lazy loading для имён пользователей (загружать только видимых) +- [ ] Профилирование памяти и устранение утечек + +## Фаза 8: Дополнительные фичи [TODO] + +- [ ] Markdown форматирование в сообщениях +- [ ] Редактирование сообщений +- [ ] Удаление сообщений +- [ ] Reply на сообщения +- [ ] Forward сообщений diff --git a/src/app/mod.rs b/src/app/mod.rs index 1963b6a..bf3b3a0 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -28,6 +28,8 @@ pub struct App { // Search state pub is_searching: bool, pub search_query: String, + /// Флаг для оптимизации рендеринга - перерисовывать только при изменениях + pub needs_redraw: bool, } impl App { @@ -53,9 +55,15 @@ impl App { is_loading: true, is_searching: false, search_query: String::new(), + needs_redraw: true, } } + /// Помечает UI как требующий перерисовки + pub fn mark_dirty(&mut self) { + self.needs_redraw = true; + } + pub fn next_chat(&mut self) { let filtered = self.get_filtered_chats(); if filtered.is_empty() { @@ -106,7 +114,9 @@ impl App { self.current_messages.clear(); self.message_input.clear(); self.message_scroll_offset = 0; + // Очищаем данные в TdClient self.td_client.current_chat_id = None; + self.td_client.current_chat_messages.clear(); } pub fn select_first_chat(&mut self) { diff --git a/src/main.rs b/src/main.rs index dc77f03..012b78c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,6 +11,8 @@ use crossterm::{ }; use ratatui::{backend::CrosstermBackend, Terminal}; use std::io; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; use std::time::Duration; use tdlib_rs::enums::Update; @@ -58,16 +60,22 @@ async fn run_app( terminal: &mut Terminal, app: &mut App, ) -> io::Result<()> { + // Флаг для остановки polling задачи + let should_stop = Arc::new(AtomicBool::new(false)); + let should_stop_clone = should_stop.clone(); + // Канал для передачи updates из polling задачи в main loop let (update_tx, mut update_rx) = tokio::sync::mpsc::unbounded_channel::(); // Запускаем polling TDLib receive() в отдельной задаче - tokio::spawn(async move { - loop { - // receive() блокирующий, поэтому запускаем в blocking thread + let polling_handle = tokio::spawn(async move { + while !should_stop_clone.load(Ordering::Relaxed) { + // receive() с таймаутом 0.1 сек чтобы периодически проверять флаг let result = tokio::task::spawn_blocking(|| tdlib_rs::receive()).await; if let Ok(Some((update, _client_id))) = result { - let _ = update_tx.send(update); + if update_tx.send(update).is_err() { + break; // Канал закрыт, выходим + } } } }); @@ -100,8 +108,15 @@ async fn run_app( loop { // Обрабатываем updates от TDLib из канала (неблокирующе) + let mut had_updates = false; while let Ok(update) = update_rx.try_recv() { app.td_client.handle_update(update); + had_updates = true; + } + + // Помечаем UI как требующий перерисовки если были обновления + if had_updates { + app.needs_redraw = true; } // Обрабатываем очередь сообщений для отметки как прочитанных @@ -116,6 +131,7 @@ async fn run_app( // Синхронизируем сообщения из td_client в app (для новых сообщений в реальном времени) if app.selected_chat_id.is_some() && !app.td_client.current_chat_messages.is_empty() { + let prev_messages_len = app.current_messages.len(); // Синхронизируем все сообщения (включая обновлённые имена и is_read) for td_msg in &app.td_client.current_chat_messages { if let Some(app_msg) = app.current_messages.iter_mut().find(|m| m.id == td_msg.id) { @@ -127,37 +143,75 @@ async fn run_app( app.current_messages.push(td_msg.clone()); } } + // Если добавились новые сообщения - нужна перерисовка + if app.current_messages.len() != prev_messages_len { + app.needs_redraw = true; + } } // Обновляем состояние экрана на основе auth_state - update_screen_state(app).await; + let screen_changed = update_screen_state(app).await; + if screen_changed { + app.needs_redraw = true; + } - terminal.draw(|f| ui::render(f, app))?; + // Рендерим только если есть изменения + if app.needs_redraw { + terminal.draw(|f| ui::render(f, app))?; + app.needs_redraw = false; + } - // Используем poll для неблокирующего чтения событий - if event::poll(Duration::from_millis(100))? { - if let Event::Key(key) = event::read()? { - // Global quit command - if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) { - return Ok(()); - } + // Используем poll с коротким таймаутом для быстрой реакции на ввод + // 16ms ≈ 60 FPS потенциально, но рендерим только при изменениях + if event::poll(Duration::from_millis(16))? { + match event::read()? { + Event::Key(key) => { + // Global quit command + if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) { + // Graceful shutdown + should_stop.store(true, Ordering::Relaxed); - match app.screen { - AppScreen::Loading => { - // В состоянии загрузки игнорируем ввод + // Закрываем TDLib клиент + let _ = tdlib_rs::functions::close(app.td_client.client_id()).await; + + // Ждём завершения polling задачи (с таймаутом) + let _ = tokio::time::timeout( + Duration::from_secs(2), + polling_handle + ).await; + + return Ok(()); } - AppScreen::Auth => handle_auth_input(app, key.code).await, - AppScreen::Main => handle_main_input(app, key).await, + + match app.screen { + AppScreen::Loading => { + // В состоянии загрузки игнорируем ввод + } + AppScreen::Auth => handle_auth_input(app, key.code).await, + AppScreen::Main => handle_main_input(app, key).await, + } + + // Любой ввод требует перерисовки + app.needs_redraw = true; } + Event::Resize(_, _) => { + // При изменении размера терминала нужна перерисовка + app.needs_redraw = true; + } + _ => {} } } } } -async fn update_screen_state(app: &mut App) { +/// Возвращает true если состояние изменилось и требуется перерисовка +async fn update_screen_state(app: &mut App) -> bool { use tokio::time::timeout; let prev_screen = app.screen.clone(); + let prev_status = app.status_message.clone(); + let prev_error = app.error_message.clone(); + let prev_chats_len = app.chats.len(); match &app.td_client.auth_state { AuthState::WaitTdlibParameters => { @@ -198,4 +252,10 @@ async fn update_screen_state(app: &mut App) { app.error_message = Some(e.clone()); } } + + // Проверяем, изменилось ли что-то + app.screen != prev_screen + || app.status_message != prev_status + || app.error_message != prev_error + || app.chats.len() != prev_chats_len } diff --git a/src/tdlib/client.rs b/src/tdlib/client.rs index 6d12fb8..ec4184b 100644 --- a/src/tdlib/client.rs +++ b/src/tdlib/client.rs @@ -1,6 +1,9 @@ use std::env; use std::collections::HashMap; -use tdlib_rs::enums::{AuthorizationState, ChatList, ChatType, MessageContent, Update, User, UserStatus}; +use tdlib_rs::enums::{AuthorizationState, ChatList, ChatType, ConnectionState, MessageContent, Update, User, UserStatus}; + +/// Максимальный размер кэшей пользователей +const MAX_USER_CACHE_SIZE: usize = 500; use tdlib_rs::functions; use tdlib_rs::types::{Chat as TdChat, Message as TdMessage}; @@ -25,12 +28,16 @@ pub struct ChatInfo { pub last_message: String, pub last_message_date: i32, pub unread_count: i32, + /// Количество непрочитанных упоминаний (@) + pub unread_mention_count: i32, pub is_pinned: bool, pub order: i64, /// ID последнего прочитанного исходящего сообщения (для галочек) pub last_read_outbox_message_id: i64, /// ID папок, в которых находится чат pub folder_ids: Vec, + /// Чат замьючен (уведомления отключены) + pub is_muted: bool, } #[derive(Debug, Clone)] @@ -49,6 +56,21 @@ pub struct FolderInfo { pub name: String, } +/// Состояние сетевого соединения +#[derive(Debug, Clone, PartialEq)] +pub enum NetworkState { + /// Ожидание подключения к сети + WaitingForNetwork, + /// Подключение к прокси + ConnectingToProxy, + /// Подключение к серверам Telegram + Connecting, + /// Обновление данных + Updating, + /// Подключено + Ready, +} + /// Онлайн-статус пользователя #[derive(Debug, Clone, PartialEq)] pub enum UserOnlineStatus { @@ -91,6 +113,8 @@ pub struct TdClient { pub main_chat_list_position: i32, /// Онлайн-статусы пользователей: user_id -> status user_statuses: HashMap, + /// Состояние сетевого соединения + pub network_state: NetworkState, } #[allow(dead_code)] @@ -120,6 +144,7 @@ impl TdClient { folders: Vec::new(), main_chat_list_position: 0, user_statuses: HashMap::new(), + network_state: NetworkState::Connecting, } } @@ -131,6 +156,18 @@ impl TdClient { self.client_id } + /// Очистка кэшей если они превышают лимит + fn trim_caches(&mut self) { + if self.user_names.len() > MAX_USER_CACHE_SIZE { + // Оставляем только пользователей из текущих чатов + let active_user_ids: std::collections::HashSet = + self.chat_user_ids.values().copied().collect(); + self.user_names.retain(|id, _| active_user_ids.contains(id)); + self.user_usernames.retain(|id, _| active_user_ids.contains(id)); + self.user_statuses.retain(|id, _| active_user_ids.contains(id)); + } + } + /// Получение онлайн-статуса пользователя по chat_id (для приватных чатов) pub fn get_user_status_by_chat_id(&self, chat_id: i64) -> Option<&UserOnlineStatus> { self.chat_user_ids @@ -205,6 +242,17 @@ impl TdClient { chat.unread_count = update.unread_count; } } + Update::ChatUnreadMentionCount(update) => { + if let Some(chat) = self.chats.iter_mut().find(|c| c.id == update.chat_id) { + chat.unread_mention_count = update.unread_mention_count; + } + } + Update::ChatNotificationSettings(update) => { + if let Some(chat) = self.chats.iter_mut().find(|c| c.id == update.chat_id) { + // mute_for > 0 означает что чат замьючен + chat.is_muted = update.notification_settings.mute_for > 0; + } + } Update::ChatReadOutbox(update) => { // Обновляем last_read_outbox_message_id когда собеседник прочитал сообщения if let Some(chat) = self.chats.iter_mut().find(|c| c.id == update.chat_id) { @@ -306,6 +354,9 @@ impl TdClient { } } } + + // Периодически очищаем кэши + self.trim_caches(); } Update::ChatFolders(update) => { // Обновляем список папок @@ -331,6 +382,16 @@ impl TdClient { }; self.user_statuses.insert(update.user_id, status); } + Update::ConnectionState(update) => { + // Обновляем состояние сетевого соединения + self.network_state = match update.state { + ConnectionState::WaitingForNetwork => NetworkState::WaitingForNetwork, + ConnectionState::ConnectingToProxy => NetworkState::ConnectingToProxy, + ConnectionState::Connecting => NetworkState::Connecting, + ConnectionState::Updating => NetworkState::Updating, + ConnectionState::Ready => NetworkState::Ready, + }; + } _ => {} } } @@ -394,6 +455,9 @@ impl TdClient { }) .collect(); + // Проверяем mute статус + let is_muted = td_chat.notification_settings.mute_for > 0; + let chat_info = ChatInfo { id: td_chat.id, title: td_chat.title.clone(), @@ -401,10 +465,12 @@ impl TdClient { last_message, last_message_date, unread_count: td_chat.unread_count, + unread_mention_count: td_chat.unread_mention_count, is_pinned, order, last_read_outbox_message_id: td_chat.last_read_outbox_message_id, folder_ids, + is_muted, }; if let Some(existing) = self.chats.iter_mut().find(|c| c.id == td_chat.id) { @@ -412,8 +478,10 @@ impl TdClient { existing.last_message = chat_info.last_message; existing.last_message_date = chat_info.last_message_date; existing.unread_count = chat_info.unread_count; + existing.unread_mention_count = chat_info.unread_mention_count; existing.last_read_outbox_message_id = chat_info.last_read_outbox_message_id; existing.folder_ids = chat_info.folder_ids; + existing.is_muted = chat_info.is_muted; // Обновляем username если он появился if chat_info.username.is_some() { existing.username = chat_info.username; diff --git a/src/tdlib/mod.rs b/src/tdlib/mod.rs index c39a584..c4e4937 100644 --- a/src/tdlib/mod.rs +++ b/src/tdlib/mod.rs @@ -2,3 +2,4 @@ pub mod client; pub use client::TdClient; pub use client::UserOnlineStatus; +pub use client::NetworkState; diff --git a/src/ui/chat_list.rs b/src/ui/chat_list.rs index 647e422..955bc1a 100644 --- a/src/ui/chat_list.rs +++ b/src/ui/chat_list.rs @@ -44,6 +44,7 @@ pub fn render(f: &mut Frame, area: Rect, app: &mut App) { .map(|chat| { let is_selected = app.selected_chat_id == Some(chat.id); let pin_icon = if chat.is_pinned { "📌 " } else { "" }; + let mute_icon = if chat.is_muted { "🔇 " } else { "" }; // Онлайн-статус (зелёная точка для онлайн) let status_icon = match app.td_client.get_user_status_by_chat_id(chat.id) { @@ -57,15 +58,22 @@ pub fn render(f: &mut Frame, area: Rect, app: &mut App) { .map(|u| format!(" {}", u)) .unwrap_or_default(); + // Индикатор упоминаний @ + let mention_badge = if chat.unread_mention_count > 0 { + " @".to_string() + } else { + String::new() + }; + let unread_badge = if chat.unread_count > 0 { format!(" ({})", chat.unread_count) } else { String::new() }; - let content = format!("{}{}{}{}{}{}", prefix, status_icon, pin_icon, chat.title, username_text, unread_badge); + let content = format!("{}{}{}{}{}{}{}{}", prefix, status_icon, pin_icon, mute_icon, chat.title, username_text, mention_badge, unread_badge); - // Цвет зависит от онлайн-статуса + // Цвет: онлайн — зелёные, остальные — белые let style = match app.td_client.get_user_status_by_chat_id(chat.id) { Some(UserOnlineStatus::Online) => Style::default().fg(Color::Green), _ => Style::default().fg(Color::White), diff --git a/src/ui/footer.rs b/src/ui/footer.rs index 5f34255..7856119 100644 --- a/src/ui/footer.rs +++ b/src/ui/footer.rs @@ -5,21 +5,35 @@ use ratatui::{ Frame, }; use crate::app::App; +use crate::tdlib::NetworkState; pub fn render(f: &mut Frame, area: Rect, app: &App) { - let status = if let Some(msg) = &app.status_message { - format!(" {} ", msg) - } else if let Some(err) = &app.error_message { - format!(" Error: {} ", err) - } else if app.is_searching { - " j/k: Navigate | Enter: Select | Esc: Cancel ".to_string() - } else if app.selected_chat_id.is_some() { - " Cmd+j/k: Scroll | Enter: Send | Esc: Close | Ctrl+R: Refresh | Ctrl+C: Quit ".to_string() - } else { - " j/k: Navigate | Enter: Open | Ctrl+S: Search | Ctrl+R: Refresh | Ctrl+C: Quit ".to_string() + // Индикатор состояния сети + let network_indicator = match app.td_client.network_state { + NetworkState::Ready => "", + NetworkState::WaitingForNetwork => "⚠ Нет сети | ", + NetworkState::ConnectingToProxy => "⏳ Прокси... | ", + NetworkState::Connecting => "⏳ Подключение... | ", + NetworkState::Updating => "⏳ Обновление... | ", }; - let style = if app.error_message.is_some() { + let status = if let Some(msg) = &app.status_message { + format!(" {}{} ", network_indicator, msg) + } else if let Some(err) = &app.error_message { + format!(" {}Error: {} ", network_indicator, err) + } else if app.is_searching { + format!(" {}↑/↓: Navigate | Enter: Select | Esc: Cancel ", network_indicator) + } else if app.selected_chat_id.is_some() { + format!(" {}↑/↓: Scroll | Enter: Send | Esc: Close | Ctrl+R: Refresh | Ctrl+C: Quit ", network_indicator) + } else { + format!(" {}↑/↓: Navigate | Enter: Open | Ctrl+S: Search | Ctrl+R: Refresh | Ctrl+C: Quit ", network_indicator) + }; + + let style = if matches!(app.td_client.network_state, NetworkState::WaitingForNetwork) { + Style::default().fg(Color::Red) + } else if !matches!(app.td_client.network_state, NetworkState::Ready) { + Style::default().fg(Color::Cyan) + } else if app.error_message.is_some() { Style::default().fg(Color::Red) } else if app.status_message.is_some() { Style::default().fg(Color::Yellow) diff --git a/src/ui/messages.rs b/src/ui/messages.rs index b464859..207a72e 100644 --- a/src/ui/messages.rs +++ b/src/ui/messages.rs @@ -8,14 +8,66 @@ use ratatui::{ use crate::app::App; use crate::utils::{format_timestamp, format_date, get_day}; +/// Разбивает текст на строки с учётом максимальной ширины +fn wrap_text(text: &str, max_width: usize) -> Vec { + if max_width == 0 { + return vec![text.to_string()]; + } + + let mut result = Vec::new(); + let mut current_line = String::new(); + let mut current_width = 0; + + for word in text.split_whitespace() { + let word_width = word.chars().count(); + + if current_width == 0 { + // Первое слово в строке + current_line = word.to_string(); + current_width = word_width; + } else if current_width + 1 + word_width <= max_width { + // Слово помещается + current_line.push(' '); + current_line.push_str(word); + current_width += 1 + word_width; + } else { + // Слово не помещается, начинаем новую строку + result.push(current_line); + current_line = word.to_string(); + current_width = word_width; + } + } + + if !current_line.is_empty() { + result.push(current_line); + } + + if result.is_empty() { + result.push(String::new()); + } + + result +} + pub fn render(f: &mut Frame, area: Rect, app: &App) { if let Some(chat) = app.get_selected_chat() { + // Вычисляем динамическую высоту инпута на основе длины текста + let input_width = area.width.saturating_sub(4) as usize; // -2 для рамок, -2 для "> " + let input_text_len = app.message_input.chars().count() + 2; // +2 для "> " + let input_lines = if input_width > 0 { + ((input_text_len as f32 / input_width as f32).ceil() as u16).max(1) + } else { + 1 + }; + // Минимум 3 строки (1 контент + 2 рамки), максимум 10 + let input_height = (input_lines + 2).min(10).max(3); + let message_chunks = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Length(3), // Chat header - Constraint::Min(0), // Messages - Constraint::Length(3), // Input box + Constraint::Length(3), // Chat header + Constraint::Min(0), // Messages + Constraint::Length(input_height), // Input box (динамическая высота) ]) .split(area); @@ -112,22 +164,62 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { // Исходящие: справа, формат "текст (HH:MM ✓✓)" let read_mark = if msg.is_read { "✓✓" } else { "✓" }; let time_mark = format!("({} {})", time, read_mark); - let msg_text = format!("{} {}", msg.content, time_mark); - let msg_len = msg_text.chars().count(); - let padding = content_width.saturating_sub(msg_len + 1); + let time_mark_len = time_mark.chars().count() + 1; // +1 для пробела - lines.push(Line::from(vec![ - Span::raw(" ".repeat(padding)), - Span::styled(msg.content.clone(), Style::default().fg(Color::Green)), - Span::styled(format!(" {}", time_mark), Style::default().fg(Color::Gray)), - ])); + // Максимальная ширина для текста сообщения (оставляем место для time_mark) + let max_msg_width = content_width.saturating_sub(time_mark_len + 2); + + let wrapped_lines = wrap_text(&msg.content, max_msg_width); + let total_wrapped = wrapped_lines.len(); + + for (i, line_text) in wrapped_lines.into_iter().enumerate() { + let is_last_line = i == total_wrapped - 1; + let line_len = line_text.chars().count(); + + if is_last_line { + // Последняя строка — добавляем time_mark + let full_len = line_len + time_mark_len; + let padding = content_width.saturating_sub(full_len + 1); + lines.push(Line::from(vec![ + Span::raw(" ".repeat(padding)), + Span::styled(line_text, Style::default().fg(Color::Green)), + Span::styled(format!(" {}", time_mark), Style::default().fg(Color::Gray)), + ])); + } else { + // Промежуточные строки — просто текст справа + let padding = content_width.saturating_sub(line_len + 1); + lines.push(Line::from(vec![ + Span::raw(" ".repeat(padding)), + Span::styled(line_text, Style::default().fg(Color::Green)), + ])); + } + } } else { // Входящие: слева, формат "(HH:MM) текст" let time_str = format!("({})", time); - lines.push(Line::from(vec![ - Span::styled(format!(" {}", time_str), Style::default().fg(Color::Gray)), - Span::raw(format!(" {}", msg.content)), - ])); + let time_prefix_len = time_str.chars().count() + 2; // " (HH:MM) " + + // Максимальная ширина для текста + let max_msg_width = content_width.saturating_sub(time_prefix_len + 1); + + let wrapped_lines = wrap_text(&msg.content, max_msg_width); + + for (i, line_text) in wrapped_lines.into_iter().enumerate() { + if i == 0 { + // Первая строка — с временем + lines.push(Line::from(vec![ + Span::styled(format!(" {}", time_str), Style::default().fg(Color::Gray)), + Span::raw(format!(" {}", line_text)), + ])); + } else { + // Последующие строки — с отступом + let indent = " ".repeat(time_prefix_len); + lines.push(Line::from(vec![ + Span::raw(indent), + Span::raw(line_text), + ])); + } + } } } @@ -155,7 +247,7 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { .scroll((scroll_offset, 0)); f.render_widget(messages_widget, message_chunks[1]); - // Input box + // Input box с wrap для длинного текста let input_text = if app.message_input.is_empty() { "> Введите сообщение...".to_string() } else { @@ -168,7 +260,8 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { }; let input = Paragraph::new(input_text) .block(Block::default().borders(Borders::ALL)) - .style(input_style); + .style(input_style) + .wrap(ratatui::widgets::Wrap { trim: false }); f.render_widget(input, message_chunks[2]); } else { let empty = Paragraph::new("Выберите чат") diff --git a/src/ui/mod.rs b/src/ui/mod.rs index ccd55a1..9b01638 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -6,12 +6,39 @@ mod messages; mod footer; use ratatui::Frame; +use ratatui::layout::Alignment; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::widgets::Paragraph; use crate::app::{App, AppScreen}; +/// Минимальная ширина терминала +const MIN_WIDTH: u16 = 80; +/// Минимальная высота терминала +const MIN_HEIGHT: u16 = 20; + pub fn render(f: &mut Frame, app: &mut App) { + let area = f.area(); + + // Проверяем минимальный размер терминала + if area.width < MIN_WIDTH || area.height < MIN_HEIGHT { + render_size_warning(f, area.width, area.height); + return; + } + match app.screen { AppScreen::Loading => loading::render(f, app), AppScreen::Auth => auth::render(f, app), AppScreen::Main => main_screen::render(f, app), } } + +fn render_size_warning(f: &mut Frame, width: u16, height: u16) { + let message = format!( + "Терминал слишком мал: {}x{}\n\nМинимум: {}x{}", + width, height, MIN_WIDTH, MIN_HEIGHT + ); + let warning = Paragraph::new(message) + .style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)) + .alignment(Alignment::Center); + f.render_widget(warning, f.area()); +}