fixes
This commit is contained in:
67
CONTEXT.md
67
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 форматирование в сообщениях
|
||||
- [ ] Медиа-сообщения (фото, видео, голосовые)
|
||||
|
||||
## Известные проблемы
|
||||
|
||||
|
||||
75
ROADMAP.md
75
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 сообщений
|
||||
|
||||
@@ -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) {
|
||||
|
||||
98
src/main.rs
98
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<B: ratatui::backend::Backend>(
|
||||
terminal: &mut Terminal<B>,
|
||||
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::<Update>();
|
||||
|
||||
// Запускаем 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<B: ratatui::backend::Backend>(
|
||||
|
||||
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<B: ratatui::backend::Backend>(
|
||||
|
||||
// Синхронизируем сообщения из 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<B: ratatui::backend::Backend>(
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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<i32>,
|
||||
/// Чат замьючен (уведомления отключены)
|
||||
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<i64, UserOnlineStatus>,
|
||||
/// Состояние сетевого соединения
|
||||
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<i64> =
|
||||
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;
|
||||
|
||||
@@ -2,3 +2,4 @@ pub mod client;
|
||||
|
||||
pub use client::TdClient;
|
||||
pub use client::UserOnlineStatus;
|
||||
pub use client::NetworkState;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<String> {
|
||||
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("Выберите чат")
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user