Compare commits
3 Commits
e1bceada6d
...
b2aecf11da
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2aecf11da | ||
|
|
79861bbbac | ||
|
|
eea5eb9584 |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -1 +1,7 @@
|
||||
/target
|
||||
|
||||
# TDLib session data (contains auth tokens - NEVER commit!)
|
||||
/tdlib_data/
|
||||
|
||||
# Environment variables (contains API keys)
|
||||
.env
|
||||
|
||||
80
CONTEXT.md
Normal file
80
CONTEXT.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# Текущий контекст проекта
|
||||
|
||||
## Статус: Базовая интеграция с TDLib работает
|
||||
|
||||
### Что сделано
|
||||
|
||||
#### TDLib интеграция
|
||||
- Подключена библиотека `tdlib-rs` v1.1 с автоматической загрузкой TDLib
|
||||
- Реализована авторизация через телефон + код + 2FA пароль
|
||||
- Сессия сохраняется автоматически в папке `tdlib_data/`
|
||||
- Отключены логи TDLib через FFI вызов `td_execute` до создания клиента
|
||||
- Updates обрабатываются в отдельном потоке через `mpsc` канал (неблокирующе)
|
||||
|
||||
#### Функциональность
|
||||
- Загрузка списка чатов (до 50 штук)
|
||||
- Отображение названия чата и счётчика непрочитанных
|
||||
- Загрузка истории сообщений при открытии чата
|
||||
- Отображение сообщений с именем отправителя и временем
|
||||
|
||||
#### Управление
|
||||
- `j/k` или стрелки — навигация по списку чатов
|
||||
- `д/л` — русская раскладка для j/k
|
||||
- `Enter` — открыть выбранный чат
|
||||
- `Esc` — закрыть открытый чат
|
||||
- `Ctrl+k` — перейти к первому чату
|
||||
- `Ctrl+R` — обновить список чатов
|
||||
- `Ctrl+C` — выход
|
||||
|
||||
### Структура проекта
|
||||
|
||||
```
|
||||
src/
|
||||
├── main.rs # Точка входа, UI рендеринг, event loop
|
||||
├── tdlib/
|
||||
│ ├── mod.rs # Модуль экспорта
|
||||
│ └── client.rs # TdClient: авторизация, загрузка чатов, сообщений
|
||||
```
|
||||
|
||||
### Ключевые решения
|
||||
|
||||
1. **Неблокирующий receive**: TDLib updates приходят в отдельном потоке и передаются в main loop через `mpsc::channel`. Это позволяет UI оставаться отзывчивым.
|
||||
|
||||
2. **FFI для логов**: Используем прямой вызов `td_execute` для отключения логов синхронно, до создания клиента, чтобы избежать вывода в терминал.
|
||||
|
||||
3. **Синхронизация чатов**: Чаты загружаются асинхронно через updates. Main loop периодически синхронизирует `app.chats` с `td_client.chats`.
|
||||
|
||||
### Зависимости (Cargo.toml)
|
||||
|
||||
```toml
|
||||
ratatui = "0.29"
|
||||
crossterm = "0.28"
|
||||
tdlib-rs = { version = "1.1", features = ["download-tdlib"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
dotenvy = "0.15"
|
||||
```
|
||||
|
||||
### Переменные окружения (.env)
|
||||
|
||||
```
|
||||
API_ID=your_api_id
|
||||
API_HASH=your_api_hash
|
||||
```
|
||||
|
||||
## Что НЕ сделано / TODO
|
||||
|
||||
- [ ] Отправка сообщений
|
||||
- [ ] Поиск по чатам
|
||||
- [ ] Папки телеграма (сейчас только "All")
|
||||
- [ ] Отображение онлайн-статуса пользователя
|
||||
- [ ] Markdown форматирование в сообщениях
|
||||
- [ ] Скролл истории сообщений
|
||||
- [ ] Отметка сообщений как прочитанные
|
||||
- [ ] Обновление чатов в реальном времени (новые сообщения)
|
||||
|
||||
## Известные проблемы
|
||||
|
||||
1. При первом запуске нужно пройти авторизацию
|
||||
2. Имя отправителя показывается как "User_ID" (нужно загружать имена пользователей)
|
||||
@@ -65,13 +65,21 @@
|
||||
7) Esc - закрытие открытого чата
|
||||
8) command + стрелка вверх (или ctrl + k) - выделяем самый верхний чат (без открытия)
|
||||
9) поддержка русской раскладки: "р о л д" соответствует "h j k l"
|
||||
10) `**commands**` - сюда вставь описания команд, которые есть в приложении
|
||||
10) Ctrl+R - обновить список чатов
|
||||
|
||||
### Реализованные команды (footer)
|
||||
|
||||
```
|
||||
j/k: Navigate | Ctrl+k: First | Enter: Open | Esc: Close | Ctrl+R: Refresh | Ctrl+C: Quit
|
||||
```
|
||||
|
||||
## Технологии
|
||||
Пишем на rust-е
|
||||
|
||||
1) ratatui - для tui интерфейса
|
||||
2) rust-tdlib - для подключения апи телеграма
|
||||
2) tdlib-rs - для подключения апи телеграма (обёртка над TDLib)
|
||||
3) tokio - async runtime
|
||||
4) crossterm - кроссплатформенный терминал
|
||||
|
||||
## Нефункциональные требования
|
||||
|
||||
|
||||
48
ROADMAP.md
Normal file
48
ROADMAP.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# Roadmap
|
||||
|
||||
## Фаза 1: Базовая инфраструктура [DONE]
|
||||
|
||||
- [x] Настройка проекта (Cargo.toml)
|
||||
- [x] TUI фреймворк (ratatui + crossterm)
|
||||
- [x] Базовый layout (папки, список чатов, область сообщений)
|
||||
- [x] Vim-style навигация (hjkl, стрелки)
|
||||
- [x] Русская раскладка (ролд)
|
||||
|
||||
## Фаза 2: TDLib интеграция [DONE]
|
||||
|
||||
- [x] Подключение tdlib-rs
|
||||
- [x] Авторизация (телефон + код + 2FA)
|
||||
- [x] Сохранение сессии
|
||||
- [x] Загрузка списка чатов
|
||||
- [x] Загрузка истории сообщений
|
||||
- [x] Отключение логов TDLib
|
||||
|
||||
## Фаза 3: Улучшение UX [IN PROGRESS]
|
||||
|
||||
- [ ] Отправка сообщений
|
||||
- [ ] Поиск по чатам (Ctrl+S)
|
||||
- [ ] Скролл истории сообщений
|
||||
- [ ] Загрузка имён пользователей (вместо User_ID)
|
||||
- [ ] Отметка сообщений как прочитанные
|
||||
- [ ] Реальное время: новые сообщения
|
||||
|
||||
## Фаза 4: Папки и фильтрация
|
||||
|
||||
- [ ] Загрузка папок из Telegram
|
||||
- [ ] Переключение между папками (Cmd+1, Cmd+2, ...)
|
||||
- [ ] Фильтрация чатов по папке
|
||||
|
||||
## Фаза 5: Расширенный функционал
|
||||
|
||||
- [ ] Отображение онлайн-статуса
|
||||
- [ ] Статус доставки/прочтения (✓, ✓✓)
|
||||
- [ ] Поддержка медиа-заглушек (фото, видео, голосовые)
|
||||
- [ ] Mentions (@)
|
||||
- [ ] Muted чаты (серый цвет)
|
||||
|
||||
## Фаза 6: Полировка
|
||||
|
||||
- [ ] Оптимизация 60 FPS
|
||||
- [ ] Минимальное разрешение 600 символов
|
||||
- [ ] Обработка ошибок сети
|
||||
- [ ] Graceful shutdown
|
||||
70
src/main.rs
70
src/main.rs
@@ -65,6 +65,7 @@ struct App {
|
||||
chat_list_state: ListState,
|
||||
selected_chat: Option<usize>,
|
||||
current_messages: Vec<MessageInfo>,
|
||||
message_input: String, // Input for new message
|
||||
folders: Vec<String>,
|
||||
selected_folder: usize,
|
||||
is_loading: bool,
|
||||
@@ -87,6 +88,7 @@ impl App {
|
||||
chat_list_state: state,
|
||||
selected_chat: None,
|
||||
current_messages: Vec::new(),
|
||||
message_input: String::new(),
|
||||
folders: vec!["All".to_string()],
|
||||
selected_folder: 0,
|
||||
is_loading: true,
|
||||
@@ -138,6 +140,7 @@ impl App {
|
||||
fn close_chat(&mut self) {
|
||||
self.selected_chat = None;
|
||||
self.current_messages.clear();
|
||||
self.message_input.clear();
|
||||
}
|
||||
|
||||
fn select_first_chat(&mut self) {
|
||||
@@ -372,6 +375,38 @@ async fn handle_main_input(app: &mut App, key: event::KeyEvent) {
|
||||
let has_super = key.modifiers.contains(KeyModifiers::SUPER);
|
||||
let has_ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
||||
|
||||
// Если чат открыт - режим ввода сообщения
|
||||
if app.selected_chat.is_some() {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
app.close_chat();
|
||||
}
|
||||
KeyCode::Char('r') if has_ctrl => {
|
||||
// Обновить список чатов работает и в режиме чата
|
||||
app.status_message = Some("Обновление чатов...".to_string());
|
||||
if let Err(e) = app.td_client.load_chats(50).await {
|
||||
app.error_message = Some(e);
|
||||
}
|
||||
app.status_message = None;
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
app.message_input.pop();
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
// TODO: отправка сообщения
|
||||
// Пока просто очищаем инпут
|
||||
if !app.message_input.is_empty() {
|
||||
app.message_input.clear();
|
||||
}
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
// Вводим символы в инпут сообщения
|
||||
app.message_input.push(c);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
} else {
|
||||
// Режим навигации по списку чатов
|
||||
match key.code {
|
||||
// Navigate down: j, Down, д (Russian)
|
||||
KeyCode::Char('j') | KeyCode::Char('д') | KeyCode::Down if !has_super && !has_ctrl => {
|
||||
@@ -410,7 +445,7 @@ async fn handle_main_input(app: &mut App, key: event::KeyEvent) {
|
||||
}
|
||||
}
|
||||
KeyCode::Esc => {
|
||||
app.close_chat();
|
||||
// Ничего не делаем, чат и так не открыт
|
||||
}
|
||||
KeyCode::Char('r') if has_ctrl => {
|
||||
// Обновить список чатов
|
||||
@@ -428,6 +463,7 @@ async fn handle_main_input(app: &mut App, key: event::KeyEvent) {
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn ui(f: &mut Frame, app: &mut App) {
|
||||
@@ -773,14 +809,34 @@ fn render_messages(f: &mut Frame, area: Rect, app: &App) {
|
||||
)));
|
||||
}
|
||||
|
||||
let messages_widget =
|
||||
Paragraph::new(lines).block(Block::default().borders(Borders::ALL));
|
||||
// Вычисляем скролл, чтобы показать последние сообщения
|
||||
let visible_height = message_chunks[1].height.saturating_sub(2) as usize; // -2 для borders
|
||||
let total_lines = lines.len();
|
||||
let scroll_offset = if total_lines > visible_height {
|
||||
(total_lines - visible_height) as u16
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let messages_widget = Paragraph::new(lines)
|
||||
.block(Block::default().borders(Borders::ALL))
|
||||
.scroll((scroll_offset, 0));
|
||||
f.render_widget(messages_widget, message_chunks[1]);
|
||||
|
||||
// Input box
|
||||
let input = Paragraph::new("> ...")
|
||||
let input_text = if app.message_input.is_empty() {
|
||||
"> Введите сообщение...".to_string()
|
||||
} else {
|
||||
format!("> {}", app.message_input)
|
||||
};
|
||||
let input_style = if app.message_input.is_empty() {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
} else {
|
||||
Style::default().fg(Color::Yellow)
|
||||
};
|
||||
let input = Paragraph::new(input_text)
|
||||
.block(Block::default().borders(Borders::ALL))
|
||||
.style(Style::default().fg(Color::Yellow));
|
||||
.style(input_style);
|
||||
f.render_widget(input, message_chunks[2]);
|
||||
}
|
||||
} else {
|
||||
@@ -796,7 +852,11 @@ fn render_footer(f: &mut Frame, area: Rect, app: &App) {
|
||||
format!(" {} ", msg)
|
||||
} else if let Some(err) = &app.error_message {
|
||||
format!(" Error: {} ", err)
|
||||
} else if app.selected_chat.is_some() {
|
||||
// Режим ввода сообщения
|
||||
" Enter: Send | Esc: Close chat | Ctrl+R: Refresh | Ctrl+C: Quit ".to_string()
|
||||
} else {
|
||||
// Режим навигации
|
||||
" j/k: Navigate | Ctrl+k: First | Enter: Open | Esc: Close | Ctrl+R: Refresh | Ctrl+C: Quit ".to_string()
|
||||
};
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ pub struct ChatInfo {
|
||||
pub id: i64,
|
||||
pub title: String,
|
||||
pub last_message: String,
|
||||
pub last_message_date: i32,
|
||||
pub unread_count: i32,
|
||||
pub is_pinned: bool,
|
||||
pub order: i64,
|
||||
@@ -105,15 +106,19 @@ impl TdClient {
|
||||
}
|
||||
Update::ChatLastMessage(update) => {
|
||||
let chat_id = update.chat_id;
|
||||
let last_message_text = update
|
||||
let (last_message_text, last_message_date) = update
|
||||
.last_message
|
||||
.as_ref()
|
||||
.map(|msg| extract_message_text_static(msg))
|
||||
.map(|msg| (extract_message_text_static(msg), msg.date))
|
||||
.unwrap_or_default();
|
||||
|
||||
if let Some(chat) = self.chats.iter_mut().find(|c| c.id == chat_id) {
|
||||
chat.last_message = last_message_text;
|
||||
chat.last_message_date = last_message_date;
|
||||
}
|
||||
|
||||
// Пересортируем после обновления
|
||||
self.chats.sort_by(|a, b| b.last_message_date.cmp(&a.last_message_date));
|
||||
}
|
||||
Update::ChatReadInbox(update) => {
|
||||
if let Some(chat) = self.chats.iter_mut().find(|c| c.id == update.chat_id) {
|
||||
@@ -140,16 +145,17 @@ impl TdClient {
|
||||
}
|
||||
|
||||
fn add_or_update_chat(&mut self, td_chat: &TdChat) {
|
||||
let last_message = td_chat
|
||||
let (last_message, last_message_date) = td_chat
|
||||
.last_message
|
||||
.as_ref()
|
||||
.map(|m| extract_message_text_static(m))
|
||||
.map(|m| (extract_message_text_static(m), m.date))
|
||||
.unwrap_or_default();
|
||||
|
||||
let chat_info = ChatInfo {
|
||||
id: td_chat.id,
|
||||
title: td_chat.title.clone(),
|
||||
last_message,
|
||||
last_message_date,
|
||||
unread_count: td_chat.unread_count,
|
||||
is_pinned: false,
|
||||
order: 0,
|
||||
@@ -158,10 +164,14 @@ impl TdClient {
|
||||
if let Some(existing) = self.chats.iter_mut().find(|c| c.id == td_chat.id) {
|
||||
existing.title = chat_info.title;
|
||||
existing.last_message = chat_info.last_message;
|
||||
existing.last_message_date = chat_info.last_message_date;
|
||||
existing.unread_count = chat_info.unread_count;
|
||||
} else {
|
||||
self.chats.push(chat_info);
|
||||
}
|
||||
|
||||
// Сортируем чаты по дате последнего сообщения (новые сверху)
|
||||
self.chats.sort_by(|a, b| b.last_message_date.cmp(&a.last_message_date));
|
||||
}
|
||||
|
||||
fn convert_message(&self, message: &TdMessage) -> MessageInfo {
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user