Compare commits

..

15 Commits

Author SHA1 Message Date
Mikhail Kilin
051c4a0265 fixes
Some checks are pending
CI / Check (pull_request) Waiting to run
CI / Format (pull_request) Waiting to run
CI / Clippy (pull_request) Waiting to run
CI / Build (macos-latest) (pull_request) Waiting to run
CI / Build (ubuntu-latest) (pull_request) Waiting to run
CI / Build (windows-latest) (pull_request) Waiting to run
2026-01-28 01:29:03 +03:00
Mikhail Kilin
f291191577 fixes 2026-01-27 23:29:00 +03:00
Mikhail Kilin
356d2d3064 add account profile 2026-01-27 13:41:29 +03:00
Mikhail Kilin
ac684da820 add draft messages 2026-01-27 12:31:31 +03:00
Mikhail Kilin
dc76e01f3c add find messages 2026-01-27 12:09:05 +03:00
Mikhail Kilin
81dc5b9007 add pinned messages 2026-01-27 04:38:29 +03:00
Mikhail Kilin
4d5625f950 add typings in/out
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 03:59:49 +03:00
Mikhail Kilin
46720b3584 fixes 2026-01-25 21:21:07 +03:00
Mikhail Kilin
e4dabbe3ac fixes 2026-01-25 01:47:38 +03:00
Mikhail Kilin
fa749d24c5 fixes 2026-01-24 18:53:35 +03:00
Mikhail Kilin
22c4e17377 fixes 2026-01-24 02:22:47 +03:00
Mikhail Kilin
c18f43664e fixes 2026-01-22 15:26:15 +03:00
Mikhail Kilin
1ef341d907 commit 2026-01-21 21:20:18 +03:00
Mikhail Kilin
0a9ae8b448 fixes 2026-01-21 02:49:28 +03:00
Mikhail Kilin
32ab1df1fa fixes 2026-01-21 02:27:08 +03:00
39 changed files with 7569 additions and 384 deletions

37
.editorconfig Normal file
View File

@@ -0,0 +1,37 @@
# EditorConfig is awesome: https://EditorConfig.org
# top-most EditorConfig file
root = true
# Unix-style newlines with a newline ending every file
[*]
end_of_line = lf
insert_final_newline = true
charset = utf-8
trim_trailing_whitespace = true
# Rust files
[*.rs]
indent_style = space
indent_size = 4
# TOML files
[*.toml]
indent_style = space
indent_size = 2
# Markdown files
[*.md]
indent_style = space
indent_size = 2
trim_trailing_whitespace = false
# YAML files
[*.{yml,yaml}]
indent_style = space
indent_size = 2
# JSON files
[*.json]
indent_style = space
indent_size = 2

40
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,40 @@
---
name: Bug Report
about: Сообщить о проблеме или баге
title: '[BUG] '
labels: bug
assignees: ''
---
## Описание бага
Четкое и краткое описание проблемы.
## Шаги для воспроизведения
1. Запустить '...'
2. Нажать на '...'
3. Прокрутить вниз до '...'
4. Увидеть ошибку
## Ожидаемое поведение
Что должно было произойти.
## Фактическое поведение
Что произошло на самом деле.
## Скриншоты
Если применимо, добавьте скриншоты для демонстрации проблемы.
## Окружение
- **ОС**: [например, macOS 14.0, Ubuntu 22.04, Windows 11]
- **Rust версия**: [вывод `rustc --version`]
- **tele-tui версия**: [вывод `cargo pkgid`]
- **Размер терминала**: [например, 100x30]
## Логи
Если есть логи или сообщения об ошибках, вставьте их сюда:
```
вставьте логи здесь
```
## Дополнительный контекст
Любая другая информация, которая может помочь в решении проблемы.

View File

@@ -0,0 +1,34 @@
---
name: Feature Request
about: Предложить новую функцию или улучшение
title: '[FEATURE] '
labels: enhancement
assignees: ''
---
## Связано с проблемой?
Есть ли проблема, которую это решит? Например: "Меня расстраивает, что [...]"
## Описание решения
Четкое и краткое описание того, что вы хотите.
## Альтернативы
Какие альтернативные решения или функции вы рассматривали?
## Примеры использования
Как эта функция будет использоваться? Приведите примеры:
1. Пользователь делает X
2. Система делает Y
3. Результат: Z
## Приоритет
- [ ] Критичная функция — без неё приложение малополезно
- [ ] Важная функция — значительно улучшит UX
- [ ] Nice to have — было бы удобно
## Проверка roadmap
- [ ] Я проверил [ROADMAP.md](../ROADMAP.md) и этой функции там нет
## Дополнительный контекст
Скриншоты, ссылки на похожие реализации в других приложениях, и т.д.

51
.github/pull_request_template.md vendored Normal file
View File

@@ -0,0 +1,51 @@
## Описание
Краткое описание изменений в этом PR.
## Тип изменений
- [ ] Bug fix (исправление бага)
- [ ] New feature (новая функция)
- [ ] Breaking change (изменение, ломающее обратную совместимость)
- [ ] Refactoring (рефакторинг без изменения функциональности)
- [ ] Documentation (изменения в документации)
- [ ] Performance improvement (улучшение производительности)
## Связанные Issue
Fixes #(номер issue)
## Как протестировано?
Опишите тесты, которые вы провели:
- [ ] Тест A
- [ ] Тест B
- [ ] Тест C
## Сценарии тестирования
Подробные шаги для проверки изменений:
1. Запустить `cargo run`
2. Сделать X
3. Убедиться, что Y
## Чеклист
- [ ] Мой код следует стилю проекта
- [ ] Я запустил `cargo fmt`
- [ ] Я запустил `cargo clippy` и исправил warnings
- [ ] Код компилируется без ошибок (`cargo build`)
- [ ] Я протестировал изменения вручную
- [ ] Я обновил документацию (если необходимо)
- [ ] Я добавил тесты (если применимо)
- [ ] Все существующие тесты проходят
## Скриншоты (если применимо)
Добавьте скриншоты для демонстрации UI изменений.
## Дополнительные заметки
Любая дополнительная информация для ревьюверов.

50
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,50 @@
name: CI
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main, develop ]
env:
CARGO_TERM_COLOR: always
jobs:
check:
name: Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- run: cargo check --all-features
fmt:
name: Format
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt
- run: cargo fmt --all -- --check
clippy:
name: Clippy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- run: cargo clippy --all-features -- -D warnings
build:
name: Build
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- run: cargo build --release --all-features

4
.gitignore vendored
View File

@@ -6,3 +6,7 @@
# Environment variables (contains API keys)
.env
.DS_Store
# Local config files (if created in project root)
config.toml
credentials

1
.serena/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/cache

89
.serena/project.yml Normal file
View File

@@ -0,0 +1,89 @@
# list of languages for which language servers are started; choose from:
# al bash clojure cpp csharp
# csharp_omnisharp dart elixir elm erlang
# fortran fsharp go groovy haskell
# java julia kotlin lua markdown
# matlab nix pascal perl php
# powershell python python_jedi r rego
# ruby ruby_solargraph rust scala swift
# terraform toml typescript typescript_vts vue
# yaml zig
# (This list may be outdated. For the current list, see values of Language enum here:
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
# Note:
# - For C, use cpp
# - For JavaScript, use typescript
# - For Free Pascal/Lazarus, use pascal
# Special requirements:
# - csharp: Requires the presence of a .sln file in the project folder.
# - pascal: Requires Free Pascal Compiler (fpc) and optionally Lazarus.
# When using multiple languages, the first language server that supports a given file will be used for that file.
# The first language is the default language and the respective language server will be used as a fallback.
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
languages:
- rust
# the encoding used by text files in the project
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
encoding: "utf-8"
# whether to use project's .gitignore files to ignore files
ignore_all_files_in_gitignore: true
# list of additional paths to ignore in all projects
# same syntax as gitignore, so you can use * and **
ignored_paths: []
# whether the project is in read-only mode
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
# Added on 2025-04-18
read_only: false
# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
# Below is the complete list of tools for convenience.
# To make sure you have the latest list of tools, and to view their descriptions,
# execute `uv run scripts/print_tool_overview.py`.
#
# * `activate_project`: Activates a project by name.
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
# * `create_text_file`: Creates/overwrites a file in the project directory.
# * `delete_lines`: Deletes a range of lines within a file.
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
# * `execute_shell_command`: Executes a shell command.
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
# * `initial_instructions`: Gets the initial instructions for the current project.
# Should only be used in settings where the system prompt cannot be set,
# e.g. in clients you have no control over, like Claude Desktop.
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
# * `insert_at_line`: Inserts content at a given line in a file.
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
# * `list_memories`: Lists memories in Serena's project-specific memory store.
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
# * `read_file`: Reads a file within the project directory.
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
# * `remove_project`: Removes a project from the Serena configuration.
# * `replace_lines`: Replaces a range of lines within a file with new content.
# * `replace_symbol_body`: Replaces the full definition of a symbol.
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
# * `search_for_pattern`: Performs a search for a pattern in the project.
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
# * `switch_modes`: Activates modes by providing a list of their names
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
excluded_tools: []
# initial prompt for the project. It will always be given to the LLM upon activating the project
# (contrary to the memories, which are loaded on demand).
initial_prompt: ""
project_name: "tele-tui"
included_optional_tools: []

66
CHANGELOG.md Normal file
View File

@@ -0,0 +1,66 @@
# Changelog
Все значительные изменения в этом проекте будут документированы в этом файле.
Формат основан на [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
и этот проект придерживается [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [0.1.0] - 2024-12-XX
### Добавлено
#### Базовая функциональность
- TDLib интеграция с авторизацией (телефон + код + 2FA)
- Отображение списка чатов с поддержкой папок
- Загрузка и отображение истории сообщений
- Отправка текстовых сообщений
- Vim-style навигация (hjkl) с поддержкой русской раскладки (ролд)
- Поиск по чатам (Ctrl+S)
- Поиск внутри чата (Ctrl+F)
#### Сообщения
- Группировка по дате и отправителю
- Markdown форматирование (жирный, курсив, подчёркивание, зачёркивание, код, спойлеры)
- Редактирование сообщений
- Удаление сообщений с подтверждением
- Reply на сообщения
- Forward сообщений
- Копирование в системный буфер обмена
- Реакции на сообщения с emoji picker
#### UI/UX
- Индикаторы: онлайн-статус (●), прочитанность (✓/✓✓), редактирование (✎)
- Иконки: 📌 закреплённые чаты, 🔇 замьюченные, @ упоминания
- Typing indicator ("печатает...")
- Закреплённые сообщения
- Профиль пользователя/чата
- Черновики с автосохранением
- Динамический инпут (расширение до 10 строк)
- Блочный курсор с навигацией
- Состояние сети в футере
#### Конфигурация
- TOML конфигурация (~/.config/tele-tui/config.toml)
- Настройка часового пояса
- Настройка цветовой схемы
- Приоритетная загрузка credentials из XDG config dir
#### Оптимизации
- 60 FPS рендеринг
- LRU кеширование пользователей (лимит 500)
- Lazy loading имён пользователей
- Лимиты памяти (500 сообщений на чат, 200 чатов)
- Graceful shutdown
### Изменено
- Время отображается с учётом настроенного timezone
### Исправлено
- Корректная обработка TDLib updates в отдельном потоке
- Правильное выравнивание для длинных сообщений
- Приоритет обработки input для модалок
[Unreleased]: https://github.com/your-username/tele-tui/compare/v0.1.0...HEAD
[0.1.0]: https://github.com/your-username/tele-tui/releases/tag/v0.1.0

View File

@@ -1,6 +1,6 @@
# Текущий контекст проекта
## Статус: Фаза 3улучшение UX
## Статус: Фаза 9ЗАВЕРШЕНО
### Что сделано
@@ -10,50 +10,143 @@
- Сессия сохраняется автоматически в папке `tdlib_data/`
- Отключены логи TDLib через FFI вызов `td_execute` до создания клиента
- Updates обрабатываются в отдельном потоке через `mpsc` канал (неблокирующе)
- **Graceful shutdown**: корректное закрытие TDLib при выходе (Ctrl+C)
#### Функциональность
- Загрузка списка чатов (до 50 штук)
- **Фильтрация чатов**: показываются только чаты из ChatList::Main (без архива)
- Отображение названия чата и счётчика непрочитанных
- Загрузка истории сообщений при открытии чата
- Отображение сообщений с именем отправителя и временем
- **Фильтрация удалённых аккаунтов**: "Deleted Account" не отображаются в списке
- Отображение названия чата, счётчика непрочитанных и **@username**
- **Иконка 📌** для закреплённых чатов
- **Иконка 🔇** для замьюченных чатов
- **Индикатор @** для чатов с непрочитанными упоминаниями
- **Онлайн-статус**: зелёная точка ● для онлайн пользователей
- Загрузка истории сообщений при открытии чата (множественные попытки)
- **Группировка сообщений по дате** (разделители "Сегодня", "Вчера", дата) — по центру
- **Группировка сообщений по отправителю** (заголовок с именем)
- **Выравнивание сообщений**: исходящие справа (зелёные), входящие слева
- **Перенос длинных сообщений**: автоматический wrap на несколько строк
- **Отображение времени и галочек**: `текст (HH:MM ✓✓)` для исходящих, `(HH:MM) текст` для входящих
- **Галочки прочтения** (✓ отправлено, ✓✓ прочитано) — обновляются в реальном времени
- **Отметка сообщений как прочитанных**: при открытии чата счётчик непрочитанных сбрасывается
- **Отправка текстовых сообщений**
- **Поиск по чатам** (Ctrl+S): фильтрация списка по названию
- **Редактирование сообщений**: ↑ при пустом инпуте → выбор → Enter → редактирование
- **Удаление сообщений**: в режиме выбора нажать `d` / `в` / `Delete` → модалка подтверждения
- **Reply на сообщения**: в режиме выбора нажать `r` / `к` → режим ответа с превью
- **Forward сообщений**: в режиме выбора нажать `f` / `а` → выбор чата для пересылки
- **Отображение пересланных сообщений**: индикатор "↪ Переслано от" с именем отправителя
- **Индикатор редактирования**: ✎ рядом с временем для отредактированных сообщений
- **Новые сообщения в реальном времени** при открытом чате
- **Поиск по чатам** (Ctrl+S): фильтрация по названию и @username
- **Typing indicator** ("печатает..."): отображение статуса набора текста собеседником и отправка своего статуса
- **Закреплённые сообщения**: отображение pinned message вверху чата с переходом к нему
- **Поиск по сообщениям в чате** (Ctrl+F): поиск текста внутри открытого чата с навигацией по результатам
- **Черновики**: автосохранение набранного текста при переключении между чатами
- **Профиль пользователя/чата** (`i`): просмотр информации о собеседнике или группе
- **Копирование сообщений** (`y`/`н`): копирование текста сообщения в системный буфер обмена
- **Реакции на сообщения**:
- Отображение реакций под сообщениями
- Логика отображения: 1 человек = только emoji, 2+ = emoji + счётчик
- Свои реакции в рамках [👍], чужие без рамок 👍
- Emoji picker с сеткой доступных реакций (8 в ряду)
- Добавление/удаление реакций (toggle)
- Обновление реакций в реальном времени через Update::MessageInteractionInfo
- **Конфигурационный файл** (`~/.config/tele-tui/config.toml`):
- Автоматическое создание дефолтного конфига при первом запуске
- **Настройка timezone**: формат "+03:00" или "-05:00"
- **Настройка цветов**: incoming_message, outgoing_message, selected_message, reaction_chosen, reaction_other
- **Credentials файл** (`~/.config/tele-tui/credentials`): API_ID и API_HASH
- Приоритет загрузки: ~/.config/tele-tui/credentials → .env → сообщение об ошибке с инструкциями
- **Кеширование имён пользователей**: имена загружаются асинхронно и обновляются в UI
- **Папки Telegram**: загрузка и переключение между папками (1-9)
- **Медиа-заглушки**: [Фото], [Видео], [Голосовое], [Стикер], [GIF] и др.
- **Markdown форматирование в сообщениях**:
- **Жирный** (bold)
- *Курсив* (italic)
- __Подчёркнутый__ (underline)
- ~~Зачёркнутый~~ (strikethrough)
- `Код` (inline code, Pre, PreCode) — cyan на тёмном фоне
- Спойлеры — скрытый текст (серый на сером)
- Ссылки (URL, TextUrl, Email, Phone) — синий с подчёркиванием
- @Упоминания — синий с подчёркиванием
#### Состояние сети
- **Индикатор в футере**: показывает текущее состояние подключения
- `⚠ Нет сети` — красный, ожидание сети
- `⏳ Прокси...` — cyan, подключение к прокси
- `⏳ Подключение...` — cyan, подключение к серверам
- `⏳ Обновление...` — cyan, синхронизация данных
#### Оптимизации
- **60 FPS ready**: poll таймаут 16ms, рендеринг только при изменениях (`needs_redraw` флаг)
- **Оптимизация памяти**:
- Очистка сообщений при закрытии чата
- Лимит кэша пользователей (500)
- Периодическая очистка неактивных записей
- **Минимальное разрешение**: предупреждение если терминал меньше 80x20
#### Динамический инпут
- **Автоматическое расширение**: поле ввода увеличивается при длинном тексте (до 10 строк)
- **Перенос текста**: длинные сообщения переносятся на новые строки
- **Блочный курсор**: vim-style курсор █ с возможностью перемещения по тексту
#### Управление
- `↑/↓` стрелки — навигация по списку чатов
- `Enter` — открыть чат / отправить сообщение
- `Esc` — закрыть открытый чат / отменить поиск
- `Ctrl+S` — поиск по чатам (фильтрация по названию)
- `Ctrl+S` — поиск по чатам (фильтрация по названию и username)
- `Ctrl+R` — обновить список чатов
- `Ctrl+C` — выход
- `Cmd+↑/Cmd+↓` — скролл сообщений в открытом чате
- Ввод текста в поле сообщения
- `Ctrl+C` — выход (graceful shutdown)
- `↑/↓` в открытом чате — скролл сообщений (с подгрузкой старых)
- `↑` при пустом инпуте — выбор сообщения для редактирования
- `Enter` в режиме выбора — начать редактирование
- `r` / `к` в режиме выбора — ответить на сообщение (reply)
- `f` / `а` в режиме выбора — переслать сообщение (forward)
- `d` / `в` / `Delete` в режиме выбора — удалить сообщение (с подтверждением)
- `y` / `н` / `Enter` — подтвердить удаление в модалке
- `n` / `т` / `Esc` — отменить удаление в модалке
- `Esc` — отменить выбор/редактирование/reply
- `1-9` — переключение папок (в списке чатов)
- `Ctrl+F` — поиск по сообщениям в открытом чате
- `n` / `N` — навигация по результатам поиска (следующий/предыдущий)
- `i` — открыть профиль пользователя/чата
- `y` / `н` в режиме выбора — скопировать сообщение в буфер обмена
- `e` / `у` в режиме выбора — добавить реакцию (открывает emoji picker)
- `←` / `→` / `↑` / `↓` в emoji picker — навигация по сетке реакций
- `Enter` в emoji picker — добавить/удалить реакцию
- `Esc` в emoji picker — закрыть picker
- **Редактирование текста в инпуте:**
- `←` / `→` — перемещение курсора
- `Home` — курсор в начало
- `End` — курсор в конец
- `Backspace` — удалить символ слева
- `Delete` — удалить символ справа
### Структура проекта
```
src/
├── main.rs # Точка входа, event loop, TDLib инициализация
├── main.rs # Точка входа, event loop, TDLib инициализация, graceful shutdown
├── config.rs # Конфигурация (TOML), загрузка credentials
├── 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 # Список чатов
│ ├── 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)
├── utils.rs # Утилиты (disable_tdlib_logs, format_timestamp_with_tz, format_date, get_day)
└── tdlib/
├── mod.rs # Модуль экспорта
└── client.rs # TdClient: авторизация, загрузка чатов, сообщений, отправка
├── mod.rs # Модуль экспорта (TdClient, UserOnlineStatus, NetworkState)
└── client.rs # TdClient: авторизация, чаты, сообщения, кеш, NetworkState, ReactionInfo
```
### Ключевые решения
@@ -64,9 +157,21 @@ src/
3. **Синхронизация чатов**: Чаты загружаются асинхронно через updates. Main loop периодически синхронизирует `app.chats` с `td_client.chats`.
4. **Фильтрация чатов**: Все чаты добавляются в список при получении `NewChat` update. Позиции обновляются через `ChatPosition` update.
4. **Кеширование имён**: При получении `Update::User` сохраняем имя (first_name + last_name) и username в HashMap. Имена подгружаются асинхронно через очередь `pending_user_ids`. Кэш ограничен 500 записями.
5. **Сортировка по TDLib order**: Используем `position.order` для сортировки чатов (учитывает pinned и время).
5. **Группировка сообщений**: Сообщения группируются по дате (разделители по центру) и по отправителю (заголовки). Исходящие выравниваются вправо, входящие влево.
6. **Отметка прочтения**: При открытии чата вызывается `view_messages` для всех сообщений. Новые входящие сообщения автоматически отмечаются как прочитанные. `Update::ChatReadOutbox` обновляет статус галочек.
7. **Graceful shutdown**: При Ctrl+C устанавливается флаг остановки, закрывается TDLib клиент, ожидается завершение polling задачи с таймаутом 2 сек.
8. **Оптимизация рендеринга**: Флаг `needs_redraw` позволяет пропускать перерисовку когда ничего не изменилось. Триггеры: TDLib updates, пользовательский ввод, изменение размера терминала.
9. **Перенос текста**: Длинные сообщения автоматически разбиваются на строки с учётом ширины терминала. Для исходящих — time_mark на последней строке, для входящих — время на первой строке с отступом для остальных.
10. **Конфигурационный файл**: TOML конфиг создаётся автоматически при первом запуске в `~/.config/tele-tui/config.toml`. Поддерживает настройку timezone (применяется к отображению времени через `format_timestamp_with_tz`) и цветовой схемы (парсится в `ratatui::style::Color`). Credentials загружаются с приоритетом: XDG config dir → .env → ошибка с инструкциями.
11. **Реакции**: Хранятся в `Vec<ReactionInfo>` для каждого сообщения. Обновляются в реальном времени через `Update::MessageInteractionInfo`. Emoji picker использует сетку 8x6 с навигацией стрелками. Приоритет обработки ввода: reaction picker → delete confirmation → остальные модалки (важно для корректной работы Enter/Esc).
### Зависимости (Cargo.toml)
@@ -78,26 +183,63 @@ tokio = { version = "1", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
dotenvy = "0.15"
chrono = "0.4"
clipboard = "0.5"
toml = "0.8"
dirs = "5.0"
```
### Переменные окружения (.env)
### API Credentials
Приоритет загрузки (от высшего к низшему):
1. **Файл credentials** (`~/.config/tele-tui/credentials`):
```
API_ID=your_api_id
API_HASH=your_api_hash
```
2. **Переменные окружения** (`.env` файл в текущей директории):
```
API_ID=your_api_id
API_HASH=your_api_hash
```
3. Если ничего не найдено — показывается сообщение об ошибке с инструкциями.
### Конфигурационный файл
Создаётся автоматически при первом запуске в `~/.config/tele-tui/config.toml`:
```toml
[general]
# Часовой пояс в формате "+03:00" или "-05:00"
# Применяется к отображению времени сообщений
timezone = "+03:00"
[colors]
# Цветовая схема (поддерживаемые цвета: black, red, green, yellow, blue, magenta, cyan, gray, white, darkgray, lightred, lightgreen, lightyellow, lightblue, lightmagenta, lightcyan)
# Цвет входящих сообщений
incoming_message = "white"
# Цвет исходящих сообщений
outgoing_message = "green"
# Цвет выбранного сообщения
selected_message = "yellow"
# Цвет своих реакций (в рамках [👍])
reaction_chosen = "yellow"
# Цвет чужих реакций
reaction_other = "gray"
```
## Что НЕ сделано / TODO
- [ ] Папки телеграма (сейчас только "All")
- [ ] Отображение онлайн-статуса пользователя
- [ ] Markdown форматирование в сообщениях
- [ ] Скролл истории сообщений (больше 50 сообщений)
- [ ] Отметка сообщений как прочитанные
- [ ] Обновление чатов в реальном времени (новые сообщения)
- [ ] Загрузка имён пользователей (сейчас показывается User_ID)
Все пункты Фазы 9 завершены! Можно переходить к следующей фазе разработки.
## Известные проблемы
1. При первом запуске нужно пройти авторизацию
2. Имя отправителя показывается как "User_ID" (нужно загружать имена пользователей)

125
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,125 @@
# Contributing to tele-tui
Спасибо за интерес к проекту! Мы рады любому вкладу.
## Как помочь проекту
### Сообщить о баге
1. Проверьте, нет ли уже такого issue в [Issues](https://github.com/your-username/tele-tui/issues)
2. Создайте новый issue с описанием:
- Шаги для воспроизведения
- Ожидаемое поведение
- Фактическое поведение
- Версия ОС и Rust
- Логи (если есть)
### Предложить новую фичу
1. Проверьте [ROADMAP.md](ROADMAP.md) — возможно, эта фича уже запланирована
2. Создайте issue с меткой `enhancement`
3. Опишите:
- Зачем нужна эта фича
- Как она должна работать
- Примеры использования
### Внести код
1. **Fork** репозитория
2. Создайте **feature branch**: `git checkout -b feature/amazing-feature`
3. Прочитайте [DEVELOPMENT.md](DEVELOPMENT.md) для понимания процесса разработки
4. Внесите изменения
5. Протестируйте локально
6. Commit: `git commit -m 'Add amazing feature'`
7. Push: `git push origin feature/amazing-feature`
8. Создайте **Pull Request**
## Правила кода
### Стиль кода
- Используйте `cargo fmt` перед коммитом
- Следуйте [Rust API Guidelines](https://rust-lang.github.io/api-guidelines/)
- Добавляйте комментарии для сложной логики
### Структура коммитов
```
<type>: <краткое описание>
<подробное описание (опционально)>
```
Типы:
- `feat`: новая фича
- `fix`: исправление бага
- `refactor`: рефакторинг без изменения функциональности
- `docs`: изменения в документации
- `style`: форматирование, отступы
- `test`: добавление тестов
- `chore`: обновление зависимостей, конфигурации
Примеры:
```
feat: add emoji reactions to messages
fix: correct timezone offset calculation
docs: update installation instructions
```
### Тестирование
- Протестируйте вручную все изменения
- Опишите сценарии тестирования в PR
- Убедитесь, что `cargo build` проходит без ошибок
- Убедитесь, что `cargo fmt` и `cargo clippy` не дают предупреждений
## Процесс Review
1. Maintainer проверит ваш PR
2. Возможны комментарии и запросы на изменения
3. После одобрения PR будет смержен
4. Ваш вклад появится в следующем релизе
## Архитектура проекта
Перед началом работы рекомендуем ознакомиться:
- [REQUIREMENTS.md](REQUIREMENTS.md) — функциональные требования
- [CONTEXT.md](CONTEXT.md) — текущий статус и архитектурные решения
- [ROADMAP.md](ROADMAP.md) — план развития
### Структура кода
```
src/
├── main.rs # Event loop, инициализация
├── config.rs # Конфигурация
├── app/ # Состояние приложения
├── ui/ # Отрисовка UI
├── input/ # Обработка ввода
├── utils.rs # Утилиты
└── tdlib/ # TDLib интеграция
```
### Ключевые принципы
1. **Неблокирующий UI**: TDLib updates в отдельном потоке
2. **Оптимизация памяти**: LRU кеши, лимиты на коллекции
3. **Vim-style навигация**: консистентные хоткеи
4. **Graceful degradation**: fallback для отсутствующих данных
## Code of Conduct
- Будьте вежливы и уважительны
- Конструктивная критика приветствуется
- Фокус на технических аспектах
## Вопросы?
Создайте issue с меткой `question` или свяжитесь с maintainers.
## Лицензия
Внося код в этот проект, вы соглашаетесь с лицензией [MIT](LICENSE).

590
Cargo.lock generated
View File

@@ -43,6 +43,26 @@ dependencies = [
"derive_arbitrary",
]
[[package]]
name = "arboard"
version = "3.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf"
dependencies = [
"clipboard-win",
"image",
"log",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-foundation",
"parking_lot",
"percent-encoding",
"windows-sys 0.60.2",
"x11rb",
]
[[package]]
name = "atomic-waker"
version = "1.1.2"
@@ -82,12 +102,24 @@ version = "3.19.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510"
[[package]]
name = "bytemuck"
version = "1.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]]
name = "bytes"
version = "1.11.0"
@@ -130,9 +162,9 @@ dependencies = [
[[package]]
name = "cc"
version = "1.2.53"
version = "1.2.54"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "755d2fce177175ffca841e9a06afdb2c4ab0f593d53b4dee48147dfaade85932"
checksum = "6354c81bbfd62d9cfa9cb3c773c2b7b2a3a482d569de977fd0e961f6e7c00583"
dependencies = [
"find-msvc-tools",
"jobserver",
@@ -153,8 +185,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118"
dependencies = [
"iana-time-zone",
"js-sys",
"num-traits",
"serde",
"wasm-bindgen",
"windows-link",
]
@@ -168,6 +202,15 @@ dependencies = [
"inout",
]
[[package]]
name = "clipboard-win"
version = "5.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4"
dependencies = [
"error-code",
]
[[package]]
name = "compact_str"
version = "0.8.1"
@@ -268,6 +311,12 @@ dependencies = [
"winapi",
]
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "crypto-common"
version = "0.1.7"
@@ -385,13 +434,34 @@ dependencies = [
"subtle",
]
[[package]]
name = "dirs"
version = "5.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
dependencies = [
"dirs-sys 0.4.1",
]
[[package]]
name = "dirs"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
dependencies = [
"dirs-sys",
"dirs-sys 0.5.0",
]
[[package]]
name = "dirs-sys"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
dependencies = [
"libc",
"option-ext",
"redox_users 0.4.6",
"windows-sys 0.48.0",
]
[[package]]
@@ -402,10 +472,20 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
dependencies = [
"libc",
"option-ext",
"redox_users",
"redox_users 0.5.2",
"windows-sys 0.61.2",
]
[[package]]
name = "dispatch2"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec"
dependencies = [
"bitflags",
"objc2",
]
[[package]]
name = "displaydoc"
version = "0.2.5"
@@ -460,12 +540,47 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "error-code"
version = "3.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59"
[[package]]
name = "fastrand"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]]
name = "fax"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab"
dependencies = [
"fax_derive",
]
[[package]]
name = "fax_derive"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "fdeflate"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
dependencies = [
"simd-adler32",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.8"
@@ -578,6 +693,16 @@ dependencies = [
"version_check",
]
[[package]]
name = "gethostname"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
dependencies = [
"rustix 1.1.3",
"windows-link",
]
[[package]]
name = "getrandom"
version = "0.2.17"
@@ -622,6 +747,17 @@ dependencies = [
"tracing",
]
[[package]]
name = "half"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
dependencies = [
"cfg-if",
"crunchy",
"zerocopy",
]
[[package]]
name = "hashbrown"
version = "0.12.3"
@@ -917,6 +1053,20 @@ dependencies = [
"icu_properties",
]
[[package]]
name = "image"
version = "0.25.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a"
dependencies = [
"bytemuck",
"byteorder-lite",
"moxcms",
"num-traits",
"png",
"tiff",
]
[[package]]
name = "indexmap"
version = "1.9.3"
@@ -987,6 +1137,25 @@ dependencies = [
"serde",
]
[[package]]
name = "is-docker"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
dependencies = [
"once_cell",
]
[[package]]
name = "is-wsl"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
dependencies = [
"is-docker",
"once_cell",
]
[[package]]
name = "itertools"
version = "0.13.0"
@@ -1135,6 +1304,16 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "moxcms"
version = "0.7.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97"
dependencies = [
"num-traits",
"pxfm",
]
[[package]]
name = "native-tls"
version = "0.2.14"
@@ -1154,9 +1333,9 @@ dependencies = [
[[package]]
name = "num-conv"
version = "0.1.0"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050"
[[package]]
name = "num-traits"
@@ -1167,12 +1346,96 @@ dependencies = [
"autocfg",
]
[[package]]
name = "objc2"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05"
dependencies = [
"objc2-encode",
]
[[package]]
name = "objc2-app-kit"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
dependencies = [
"bitflags",
"objc2",
"objc2-core-graphics",
"objc2-foundation",
]
[[package]]
name = "objc2-core-foundation"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [
"bitflags",
"dispatch2",
"objc2",
]
[[package]]
name = "objc2-core-graphics"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
dependencies = [
"bitflags",
"dispatch2",
"objc2",
"objc2-core-foundation",
"objc2-io-surface",
]
[[package]]
name = "objc2-encode"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
[[package]]
name = "objc2-foundation"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags",
"objc2",
"objc2-core-foundation",
]
[[package]]
name = "objc2-io-surface"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
dependencies = [
"bitflags",
"objc2",
"objc2-core-foundation",
]
[[package]]
name = "once_cell"
version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "open"
version = "5.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc"
dependencies = [
"is-wsl",
"libc",
"pathdiff",
]
[[package]]
name = "openssl"
version = "0.10.75"
@@ -1252,6 +1515,12 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "pathdiff"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
[[package]]
name = "pbkdf2"
version = "0.12.2"
@@ -1286,6 +1555,19 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
name = "png"
version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0"
dependencies = [
"bitflags",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide",
]
[[package]]
name = "potential_utf"
version = "0.1.4"
@@ -1303,18 +1585,33 @@ checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "proc-macro2"
version = "1.0.105"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.43"
name = "pxfm"
version = "0.1.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a"
checksum = "7186d3822593aa4393561d186d1393b3923e9d6163d3fbfd6e825e3e6cf3e6a8"
dependencies = [
"num-traits",
]
[[package]]
name = "quick-error"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quote"
version = "1.0.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4"
dependencies = [
"proc-macro2",
]
@@ -1355,6 +1652,17 @@ dependencies = [
"bitflags",
]
[[package]]
name = "redox_users"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
dependencies = [
"getrandom 0.2.17",
"libredox",
"thiserror 1.0.69",
]
[[package]]
name = "redox_users"
version = "0.5.2"
@@ -1363,7 +1671,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
dependencies = [
"getrandom 0.2.17",
"libredox",
"thiserror",
"thiserror 2.0.18",
]
[[package]]
@@ -1618,6 +1926,15 @@ dependencies = [
"zmij",
]
[[package]]
name = "serde_spanned"
version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
dependencies = [
"serde",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
@@ -1729,9 +2046,9 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "socket2"
version = "0.6.1"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881"
checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0"
dependencies = [
"libc",
"windows-sys 0.60.2",
@@ -1841,7 +2158,7 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f98c960258301bee0758a669fbe12ad8a97c6e764d2f30c5426eea008eebf2d2"
dependencies = [
"dirs",
"dirs 6.0.0",
"futures-channel",
"log",
"once_cell",
@@ -1873,13 +2190,18 @@ checksum = "87cbdfae498e57fb48d380fff8eb5c9c98d4497c998f6de0d30d5d6b12f5358b"
name = "tele-tui"
version = "0.1.0"
dependencies = [
"arboard",
"chrono",
"crossterm",
"dirs 5.0.1",
"dotenvy",
"open",
"ratatui",
"serde",
"serde_json",
"tdlib-rs",
"tokio",
"toml",
]
[[package]]
@@ -1897,18 +2219,27 @@ dependencies = [
[[package]]
name = "thiserror"
version = "2.0.17"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl",
"thiserror-impl 1.0.69",
]
[[package]]
name = "thiserror"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [
"thiserror-impl 2.0.18",
]
[[package]]
name = "thiserror-impl"
version = "2.0.17"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913"
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
@@ -1916,10 +2247,35 @@ dependencies = [
]
[[package]]
name = "time"
version = "0.3.45"
name = "thiserror-impl"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9e442fc33d7fdb45aa9bfeb312c095964abdf596f7567261062b2a7107aaabd"
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tiff"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f"
dependencies = [
"fax",
"flate2",
"half",
"quick-error",
"weezl",
"zune-jpeg",
]
[[package]]
name = "time"
version = "0.3.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9da98b7d9b7dad93488a84b8248efc35352b0b2657397d4167e7ad67e5d535e5"
dependencies = [
"deranged",
"itoa",
@@ -1932,15 +2288,15 @@ dependencies = [
[[package]]
name = "time-core"
version = "0.1.7"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b36ee98fd31ec7426d599183e8fe26932a8dc1fb76ddb6214d05493377d34ca"
checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
[[package]]
name = "time-macros"
version = "0.2.25"
version = "0.2.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71e552d1249bf61ac2a52db88179fd0673def1e1ad8243a00d9ec9ed71fee3dd"
checksum = "78cc610bac2dcee56805c99642447d4c5dbde4d01f752ffea0199aee1f601dc4"
dependencies = [
"num-conv",
"time-core",
@@ -2017,6 +2373,47 @@ dependencies = [
"tokio",
]
[[package]]
name = "toml"
version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
dependencies = [
"serde",
"serde_spanned",
"toml_datetime",
"toml_edit",
]
[[package]]
name = "toml_datetime"
version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
dependencies = [
"serde",
]
[[package]]
name = "toml_edit"
version = "0.22.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
dependencies = [
"indexmap 2.13.0",
"serde",
"serde_spanned",
"toml_datetime",
"toml_write",
"winnow",
]
[[package]]
name = "toml_write"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]]
name = "tower"
version = "0.5.3"
@@ -2257,6 +2654,12 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "weezl"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
[[package]]
name = "winapi"
version = "0.3.9"
@@ -2349,6 +2752,15 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows-sys"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
dependencies = [
"windows-targets 0.48.5",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
@@ -2385,6 +2797,21 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows-targets"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
dependencies = [
"windows_aarch64_gnullvm 0.48.5",
"windows_aarch64_msvc 0.48.5",
"windows_i686_gnu 0.48.5",
"windows_i686_msvc 0.48.5",
"windows_x86_64_gnu 0.48.5",
"windows_x86_64_gnullvm 0.48.5",
"windows_x86_64_msvc 0.48.5",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
@@ -2418,6 +2845,12 @@ dependencies = [
"windows_x86_64_msvc 0.53.1",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
@@ -2430,6 +2863,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]]
name = "windows_aarch64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
@@ -2442,6 +2881,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]]
name = "windows_i686_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
@@ -2466,6 +2911,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]]
name = "windows_i686_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
@@ -2478,6 +2929,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]]
name = "windows_x86_64_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
@@ -2490,6 +2947,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
@@ -2502,6 +2965,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
[[package]]
name = "windows_x86_64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
@@ -2514,6 +2983,15 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winnow"
version = "0.7.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829"
dependencies = [
"memchr",
]
[[package]]
name = "wit-bindgen"
version = "0.51.0"
@@ -2526,6 +3004,23 @@ version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
[[package]]
name = "x11rb"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"
dependencies = [
"gethostname",
"rustix 1.1.3",
"x11rb-protocol",
]
[[package]]
name = "x11rb-protocol"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
[[package]]
name = "xz2"
version = "0.1.7"
@@ -2558,6 +3053,26 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zerocopy"
version = "0.8.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71ddd76bcebeed25db614f82bf31a9f4222d3fbba300e6fb6c00afa26cbd4d9d"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8187381b52e32220d50b255276aa16a084ec0a9017a0ca2152a1f55c539758d"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "zerofrom"
version = "0.1.6"
@@ -2654,7 +3169,7 @@ dependencies = [
"memchr",
"pbkdf2",
"sha1",
"thiserror",
"thiserror 2.0.18",
"time",
"xz2",
"zeroize",
@@ -2664,9 +3179,9 @@ dependencies = [
[[package]]
name = "zmij"
version = "1.0.15"
version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94f63c051f4fe3c1509da62131a678643c5b6fbdc9273b2b79d4378ebda003d2"
checksum = "02aae0f83f69aafc94776e879363e9771d7ecbffe2c7fbb6c14c5e00dfe88439"
[[package]]
name = "zopfli"
@@ -2707,3 +3222,18 @@ dependencies = [
"cc",
"pkg-config",
]
[[package]]
name = "zune-core"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a"
[[package]]
name = "zune-jpeg"
version = "0.4.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713"
dependencies = [
"zune-core",
]

View File

@@ -2,6 +2,12 @@
name = "tele-tui"
version = "0.1.0"
edition = "2021"
authors = ["Your Name <your.email@example.com>"]
description = "Terminal UI for Telegram with Vim-style navigation"
license = "MIT"
repository = "https://github.com/your-username/tele-tui"
keywords = ["telegram", "tui", "terminal", "cli"]
categories = ["command-line-utilities"]
[dependencies]
ratatui = "0.29"
@@ -11,6 +17,11 @@ tokio = { version = "1", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
dotenvy = "0.15"
chrono = "0.4"
open = "5.0"
arboard = "3.4"
toml = "0.8"
dirs = "5.0"
[build-dependencies]
tdlib-rs = { version = "1.1", features = ["download-tdlib"] }

View File

@@ -100,3 +100,6 @@ cargo run
5. Ждёт фидбек
6. Переходит к следующему этапу
```
## Работа с git
- никогда не добавляй себя в соавторов в тексте коммита

227
FAQ.md Normal file
View File

@@ -0,0 +1,227 @@
# FAQ — Часто задаваемые вопросы
## Установка и запуск
### Где получить API credentials?
1. Перейдите на https://my.telegram.org/apps
2. Войдите с вашим номером телефона
3. Создайте новое приложение
4. Скопируйте `api_id` и `api_hash`
### Где хранить credentials?
**Рекомендуется** (приоритет 1):
```bash
~/.config/tele-tui/credentials
```
**Альтернатива** (приоритет 2):
```bash
.env # в корне проекта
```
### Ошибка "Telegram API credentials not found!"
Убедитесь, что вы создали файл credentials (см. выше) с правильным форматом:
```
API_ID=12345678
API_HASH=abcdef1234567890abcdef1234567890
```
### Где хранится сессия Telegram?
В папке `./tdlib_data/` в директории запуска приложения. Эта папка содержит:
- Токены авторизации
- Кеш сообщений
- Другие данные TDLib
**Важно**: Не удаляйте эту папку, иначе придётся заново авторизоваться.
## Использование
### Как переключаться между папками?
Нажмите клавиши `1-9` для переключения между первыми 9 папками Telegram.
### Как искать сообщения в чате?
1. Откройте чат
2. Нажмите `Ctrl+F`
3. Введите поисковый запрос
4. Используйте `n` / `N` для навигации по результатам
### Как скопировать текст сообщения?
1. При пустом поле ввода нажмите `↑` для выбора сообщения
2. Нажмите `y` (или `н` на русской раскладке)
3. Текст скопирован в системный буфер обмена
### Как ответить на сообщение?
1. Выберите сообщение (`↑` при пустом инпуте)
2. Нажмите `r` (или `к` на русской раскладке)
3. Введите ответ
4. Нажмите `Enter`
### Как удалить сообщение?
1. Выберите сообщение
2. Нажмите `d` / `в` / `Delete`
3. Подтвердите удаление: `y` / `Enter`
### Как добавить реакцию?
1. Выберите сообщение
2. Нажмите `e` (или `у` на русской раскладке)
3. Выберите emoji стрелками
4. Нажмите `Enter`
### Почему не работают хоткеи на русской раскладке?
Убедитесь, что вы используете **русскую раскладку**, а не транслит. Поддерживаемые комбинации:
- `р о л д``h j k l` (навигация)
- `к``r` (reply)
- `а``f` (forward)
- `в``d` (delete)
- `н``y` (copy)
- `у``e` (react)
## Конфигурация
### Где находится конфигурационный файл?
```bash
~/.config/tele-tui/config.toml
```
Создаётся автоматически при первом запуске.
### Как изменить часовой пояс?
Отредактируйте `~/.config/tele-tui/config.toml`:
```toml
[general]
timezone = "+05:00" # Ваш часовой пояс
```
### Как изменить цветовую схему?
Отредактируйте секцию `[colors]` в конфиге:
```toml
[colors]
incoming_message = "cyan"
outgoing_message = "lightgreen"
selected_message = "lightyellow"
```
Поддерживаемые цвета: black, red, green, yellow, blue, magenta, cyan, gray, white, darkgray, lightred, lightgreen, lightyellow, lightblue, lightmagenta, lightcyan.
### Нужно ли перезапускать приложение после изменения конфига?
Да, изменения в `config.toml` применяются только при запуске приложения.
## Проблемы
### Приложение зависает при запуске
Возможные причины:
1. **Нет интернета**: проверьте подключение
2. **TDLib не может подключиться**: проверьте firewall/прокси
3. **Неверные credentials**: проверьте API_ID и API_HASH
### Сообщения не загружаются
1. Проверьте статус сети в футере (внизу экрана)
2. Попробуйте обновить: `Ctrl+R`
3. Перезапустите приложение
### "Deleted Account" в списке чатов
Это пользователи, которые удалили свой аккаунт Telegram. Они автоматически фильтруются и не отображаются в списке.
### Не отображаются медиафайлы
Медиафайлы (фото, видео, голосовые, стикеры) отображаются как заглушки: [Фото], [Видео], [Голосовое], [Стикер]. Полная поддержка медиа может быть добавлена в будущем.
### Ошибка компиляции при сборке
**TDLib download failed**:
- Проверьте интернет-соединение
- Убедитесь, что у вас достаточно места на диске
**Linking with cc failed**:
- macOS: `xcode-select --install`
- Linux: `sudo apt-get install build-essential`
- Windows: установите Visual Studio Build Tools
### Как сбросить сессию?
Удалите папку `tdlib_data/`:
```bash
rm -rf tdlib_data/
```
При следующем запуске потребуется заново авторизоваться.
## Производительность
### Приложение тормозит
Проверьте:
1. Количество открытых чатов (лимит 200)
2. Количество сообщений в открытом чате (лимит 500)
3. Размер терминала (минимум 80x20)
Приложение автоматически очищает старые данные при достижении лимитов.
### Высокое использование памяти
Это нормально при большом количестве чатов и сообщений. Приложение использует LRU кеши с ограничениями:
- 500 пользователей в кеше
- 500 сообщений на чат
- 200 чатов
## Разработка
### Как внести вклад в проект?
См. [CONTRIBUTING.md](CONTRIBUTING.md)
### Где найти план развития?
См. [ROADMAP.md](ROADMAP.md)
### Как сообщить о баге?
Создайте issue на GitHub с описанием:
- Шаги для воспроизведения
- Ожидаемое и фактическое поведение
- Версия ОС и Rust
- Логи (если есть)
## Безопасность
### Безопасно ли хранить credentials в файле?
Да, если вы:
1. Используете `~/.config/tele-tui/credentials`
2. Установили права доступа: `chmod 600 ~/.config/tele-tui/credentials`
3. Не коммитите этот файл в git (уже в `.gitignore`)
### Что делать при компрометации credentials?
1. Удалите приложение на https://my.telegram.org/apps
2. Создайте новое приложение с новыми credentials
3. Обновите файл `credentials`
4. Удалите папку `tdlib_data/` и авторизуйтесь заново
### Включена ли двухфакторная аутентификация?
Если вы включили 2FA в Telegram, приложение запросит пароль при первой авторизации.
## Ещё вопросы?
Создайте issue на GitHub или свяжитесь с maintainers.

144
HOTKEYS.md Normal file
View File

@@ -0,0 +1,144 @@
# Горячие клавиши tele-tui
## Общая навигация
| Клавиша | Русская раскладка | Действие |
|---------|-------------------|----------|
| `↑` / `k` | `р` | Вверх по списку |
| `↓` / `j` | `о` | Вниз по списку |
| `Enter` | | Открыть чат / Отправить сообщение |
| `Esc` | | Закрыть чат / Отменить действие |
| `Ctrl+C` | | Выход из приложения |
| `Ctrl+R` | | Обновить список чатов |
## Папки и поиск
| Клавиша | Действие |
|---------|----------|
| `1-9` | Переключение между папками Telegram |
| `Ctrl+S` | Открыть поиск по чатам |
| `Ctrl+F` | Открыть поиск в текущем чате |
| `n` | Следующий результат поиска |
| `N` | Предыдущий результат поиска |
## Работа с сообщениями
### Навигация и выбор
| Клавиша | Действие |
|---------|----------|
| `↑/↓` | Скролл сообщений (в открытом чате) |
| `↑` | Выбор сообщения (при пустом поле ввода) |
| `Esc` | Отменить выбор |
### Действия с сообщениями
| Клавиша | Русская раскладка | Действие |
|---------|-------------------|----------|
| `Enter` | | Редактировать выбранное сообщение |
| `r` | `к` | Ответить на сообщение (Reply) |
| `f` | `а` | Переслать сообщение (Forward) |
| `d` / `Delete` | `в` | Удалить сообщение |
| `y` | `н` | Копировать текст в буфер обмена |
| `e` | `у` | Добавить реакцию (Emoji picker) |
| `i` | | Открыть профиль чата/пользователя |
## Модалки подтверждения
### Удаление сообщения
| Клавиша | Русская раскладка | Действие |
|---------|-------------------|----------|
| `y` / `Enter` | `н` | Подтвердить удаление |
| `n` / `Esc` | `т` | Отменить удаление |
## Emoji Picker (реакции)
| Клавиша | Действие |
|---------|----------|
| `←` | Влево по сетке эмодзи |
| `→` | Вправо по сетке эмодзи |
| `↑` | Вверх по сетке эмодзи |
| `↓` | Вниз по сетке эмодзи |
| `Enter` | Добавить/удалить реакцию |
| `Esc` | Закрыть emoji picker |
## Редактирование текста
### Навигация по тексту
| Клавиша | Действие |
|---------|----------|
| `←` | Курсор влево |
| `→` | Курсор вправо |
| `Home` | Курсор в начало строки |
| `End` | Курсор в конец строки |
### Редактирование
| Клавиша | Действие |
|---------|----------|
| `Backspace` | Удалить символ слева от курсора |
| `Delete` | Удалить символ справа от курсора |
| `Enter` | Новая строка / Отправить (зависит от контекста) |
## Режимы работы
### Режим списка чатов
- Навигация: `↑/↓`
- Открыть чат: `Enter`
- Поиск: `Ctrl+S`
- Папки: `1-9`
### Режим открытого чата
- Скролл: `↑/↓`
- Выбор сообщения: `↑` (при пустом инпуте)
- Поиск в чате: `Ctrl+F`
- Закрыть чат: `Esc`
### Режим выбора сообщения
- Редактировать: `Enter`
- Ответить: `r` / `к`
- Переслать: `f` / `а`
- Удалить: `d` / `в` / `Delete`
- Копировать: `y` / `н`
- Реакция: `e` / `у`
- Отменить: `Esc`
### Режим редактирования
- Редактировать текст: см. "Редактирование текста"
- Отправить: `Enter`
- Отменить: `Esc`
### Режим ответа (Reply)
- Редактировать ответ: см. "Редактирование текста"
- Отправить: `Enter`
- Отменить: `Esc`
### Режим пересылки (Forward)
- Выбрать чат: `↑/↓`
- Переслать: `Enter`
- Отменить: `Esc`
## Поддержка русской раскладки
Все основные vim-клавиши поддерживают русскую раскладку:
| Английская | Русская | Действие |
|------------|---------|----------|
| `h` | `р` | Влево |
| `j` | `о` | Вниз |
| `k` | `л` | Вверх |
| `l` | `д` | Вправо |
| `r` | `к` | Reply |
| `f` | `а` | Forward |
| `d` | `в` | Delete |
| `y` | `н` | Copy (Yank) |
| `e` | `у` | Emoji reaction |
## Подсказки
- Текущие доступные команды всегда отображаются в нижней части экрана (footer)
- При открытой модалке доступны только действия этой модалки
- `Esc` всегда отменяет текущее действие и возвращает на шаг назад
- Блочный курсор █ показывает текущую позицию при редактировании текста

122
INSTALL.md Normal file
View File

@@ -0,0 +1,122 @@
# Установка tele-tui
## Требования
- **Rust**: версия 1.70 или выше ([установить](https://rustup.rs/))
- **TDLib**: скачивается автоматически через tdlib-rs
## Шаг 1: Клонирование репозитория
```bash
git clone https://github.com/your-username/tele-tui.git
cd tele-tui
```
## Шаг 2: Получение API credentials
1. Перейдите на https://my.telegram.org/apps
2. Войдите с вашим номером телефона
3. Создайте новое приложение
4. Скопируйте **api_id** и **api_hash**
## Шаг 3: Настройка credentials
### Вариант A: XDG config directory (рекомендуется)
Создайте файл `~/.config/tele-tui/credentials`:
```bash
mkdir -p ~/.config/tele-tui
cat > ~/.config/tele-tui/credentials << EOF
API_ID=your_api_id_here
API_HASH=your_api_hash_here
EOF
```
### Вариант B: .env файл
Создайте файл `.env` в корне проекта:
```bash
cp credentials.example .env
# Отредактируйте .env и вставьте ваши credentials
```
## Шаг 4: Сборка
```bash
cargo build --release
```
## Шаг 5: Запуск
```bash
cargo run --release
```
Или запустите скомпилированный бинарник:
```bash
./target/release/tele-tui
```
## Первый запуск
При первом запуске вам нужно будет:
1. Ввести номер телефона (с кодом страны, например: +79991234567)
2. Ввести код подтверждения из Telegram
3. Если включена 2FA — ввести пароль
Сессия сохраняется в `./tdlib_data/`, при следующем запуске авторизация не потребуется.
## Настройка (опционально)
Конфигурационный файл создаётся автоматически при первом запуске в `~/.config/tele-tui/config.toml`.
Вы можете отредактировать его для настройки:
- Часового пояса
- Цветовой схемы
Пример конфигурации см. в файле `config.toml.example`.
## Устранение неполадок
### "Telegram API credentials not found!"
Убедитесь, что вы создали файл credentials (см. Шаг 3).
### "error: linking with `cc` failed"
Убедитесь, что у вас установлен C компилятор:
- macOS: `xcode-select --install`
- Linux: `sudo apt-get install build-essential` (Debian/Ubuntu)
- Windows: установите Visual Studio Build Tools
### TDLib download failed
Проверьте подключение к интернету. TDLib скачивается автоматически при первой сборке.
## Обновление
```bash
git pull
cargo build --release
```
Ваши credentials и конфигурация сохранятся.
## Удаление
Чтобы полностью удалить приложение и все данные:
```bash
# Удалить проект
rm -rf tele-tui/
# Удалить конфигурацию и credentials
rm -rf ~/.config/tele-tui/
# Удалить сессию Telegram (опционально, потребуется новая авторизация)
# rm -rf ./tdlib_data/
```

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 tele-tui contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

355
PROJECT_STRUCTURE.md Normal file
View File

@@ -0,0 +1,355 @@
# Структура проекта
## Обзор директорий
```
tele-tui/
├── .github/ # GitHub конфигурация
│ ├── ISSUE_TEMPLATE/ # Шаблоны для issue
│ │ ├── bug_report.md
│ │ └── feature_request.md
│ ├── workflows/ # GitHub Actions CI/CD
│ │ └── ci.yml
│ └── pull_request_template.md
├── docs/ # Дополнительная документация
│ └── TDLIB_INTEGRATION.md
├── src/ # Исходный код
│ ├── app/ # Состояние приложения
│ │ ├── mod.rs
│ │ └── state.rs
│ ├── input/ # Обработка пользовательского ввода
│ │ ├── mod.rs
│ │ ├── auth.rs
│ │ └── main_input.rs
│ ├── tdlib/ # TDLib интеграция
│ │ ├── mod.rs
│ │ └── client.rs
│ ├── ui/ # Рендеринг интерфейса
│ │ ├── mod.rs
│ │ ├── auth.rs
│ │ ├── chat_list.rs
│ │ ├── footer.rs
│ │ ├── loading.rs
│ │ ├── main_screen.rs
│ │ └── messages.rs
│ ├── config.rs # Конфигурация приложения
│ ├── main.rs # Точка входа
│ └── utils.rs # Утилиты
├── tdlib_data/ # TDLib сессия (НЕ коммитится)
├── target/ # Артефакты сборки (НЕ коммитится)
├── .editorconfig # EditorConfig для IDE
├── .gitignore # Git ignore правила
├── Cargo.lock # Зависимости (точные версии)
├── Cargo.toml # Манифест проекта
├── rustfmt.toml # Конфигурация форматирования
├── config.toml.example # Пример конфигурации
├── credentials.example # Пример credentials
├── CHANGELOG.md # История изменений
├── CLAUDE.md # Инструкции для Claude AI
├── CONTRIBUTING.md # Гайд по контрибуции
├── CONTEXT.md # Текущий статус разработки
├── DEVELOPMENT.md # Правила разработки
├── FAQ.md # Часто задаваемые вопросы
├── HOTKEYS.md # Список горячих клавиш
├── INSTALL.md # Инструкция по установке
├── LICENSE # MIT лицензия
├── PROJECT_STRUCTURE.md # Этот файл
├── README.md # Главная документация
├── REQUIREMENTS.md # Функциональные требования
├── ROADMAP.md # План развития
└── SECURITY.md # Политика безопасности
```
## Исходный код (src/)
### main.rs
**Точка входа приложения**
- Инициализация TDLib клиента
- Event loop (60 FPS)
- Обработка Ctrl+C (graceful shutdown)
- Координация между UI, input и TDLib
### config.rs
**Конфигурация приложения**
- Загрузка/сохранение TOML конфига
- Парсинг timezone и цветов
- Загрузка credentials (приоритетная система)
- XDG directory support
### utils.rs
**Утилитарные функции**
- `disable_tdlib_logs()` — отключение TDLib логов через FFI
- `format_timestamp_with_tz()` — форматирование времени с учётом timezone
- `format_date()` — форматирование дат для разделителей
- `format_datetime()` — полное форматирование даты и времени
- `format_was_online()` — "был(а) X мин. назад"
### app/ — Состояние приложения
#### mod.rs
- `App` struct — главная структура состояния
- `needs_redraw` — флаг для оптимизации рендеринга
- Состояние модалок (delete confirm, reaction picker, profile)
- Состояние поиска и черновиков
- Методы для работы с UI state
#### state.rs
- `AppScreen` enum — текущий экран (Loading, Auth, Main)
### tdlib/ — Telegram интеграция
#### client.rs
- `TdClient` — обёртка над TDLib
- Авторизация (телефон, код, 2FA)
- Загрузка чатов и сообщений
- Отправка/редактирование/удаление сообщений
- Reply, Forward
- Реакции (`ReactionInfo`)
- LRU кеши (users, statuses)
- `NetworkState` enum
#### mod.rs
- Экспорт публичных типов
### ui/ — Рендеринг интерфейса
#### mod.rs
- `render()` — роутинг по экранам
- Проверка минимального размера терминала (80x20)
#### loading.rs
- Экран "Loading..."
#### auth.rs
- Экран авторизации (ввод телефона, кода, пароля)
#### main_screen.rs
- Главный экран
- Отображение папок сверху
#### chat_list.rs
- Список чатов
- Индикаторы: 📌, 🔇, @, (N)
- Онлайн-статус (●)
- Поиск по чатам
#### messages.rs
- Область сообщений
- Группировка по дате и отправителю
- Markdown форматирование
- Реакции под сообщениями
- Emoji picker modal
- Profile modal
- Delete confirmation modal
- Pinned message
- Динамический инпут
- Блочный курсор
#### footer.rs
- Футер с командами
- Индикатор состояния сети
### input/ — Обработка ввода
#### mod.rs
- Роутинг ввода по экранам
#### auth.rs
- Обработка ввода на экране авторизации
#### main_input.rs
- Обработка ввода на главном экране
- **Важно**: порядок обработчиков имеет значение!
1. Reaction picker (Enter/Esc)
2. Delete confirmation
3. Profile modal
4. Search в чате
5. Forward mode
6. Edit/Reply mode
7. Message selection
8. Chat list
- Поддержка русской раскладки
## Конфигурационные файлы
### Cargo.toml
Манифест проекта:
- Metadata (name, version, authors, license)
- Dependencies
- Build dependencies (tdlib-rs)
### rustfmt.toml
Конфигурация `cargo fmt`:
- max_width = 100
- imports_granularity = "Crate"
- Стиль комментариев
### .editorconfig
Универсальные настройки для IDE:
- Unix line endings (LF)
- UTF-8 encoding
- Отступы (4 spaces для Rust)
## Рантайм файлы
### tdlib_data/
Создаётся автоматически TDLib:
- Токены авторизации
- Кеш сообщений и файлов
- **НЕ коммитится** (в .gitignore)
- **НЕ делиться** (содержит чувствительные данные)
### ~/.config/tele-tui/
XDG config directory:
- `config.toml` — пользовательская конфигурация
- `credentials` — API_ID и API_HASH
## Документация
### Пользовательская
- **README.md** — главная страница, overview
- **INSTALL.md** — установка и настройка
- **HOTKEYS.md** — все горячие клавиши
- **FAQ.md** — часто задаваемые вопросы
### Разработчика
- **CONTRIBUTING.md** — как внести вклад
- **DEVELOPMENT.md** — правила разработки
- **PROJECT_STRUCTURE.md** — этот файл
- **ROADMAP.md** — план развития
- **CONTEXT.md** — текущий статус, архитектурные решения
### Спецификации
- **REQUIREMENTS.md** — функциональные требования
- **CHANGELOG.md** — история изменений
- **SECURITY.md** — политика безопасности
### Внутренняя
- **CLAUDE.md** — инструкции для AI ассистента
- **docs/TDLIB_INTEGRATION.md** — детали интеграции TDLib
## Ключевые концепции
### Архитектура
- **Event-driven**: TDLib updates → mpsc channel → main loop
- **Unidirectional data flow**: TDLib → App state → UI rendering
- **Modal stacking**: приоритет обработки ввода для модалок
### Оптимизации
- **needs_redraw**: рендеринг только при изменениях
- **LRU caches**: user_names, user_statuses (500 записей)
- **Limits**: 500 messages/chat, 200 chats
- **Lazy loading**: users загружаются батчами (5 за цикл)
### Состояние
```
App {
screen: AppScreen,
config: Config,
needs_redraw: bool,
// TDLib state
chats: Vec<Chat>,
folders: Vec<Folder>,
// UI state
selected_chat_id: Option<i64>,
input_text: String,
cursor_position: usize,
// Modals
is_delete_confirmation: bool,
is_reaction_picker_mode: bool,
profile_info: Option<ProfileInfo>,
// Search
search_query: String,
search_results: Vec<i64>,
// Drafts
drafts: HashMap<i64, String>,
}
```
## Потоки выполнения
### Main thread
- Event loop (16ms tick для 60 FPS)
- UI rendering
- Input handling
- App state updates
### TDLib thread
- `td_client.receive()` в отдельном Tokio task
- Updates отправляются через `mpsc::channel`
- Неблокирующий для main thread
### Blocking operations
- Загрузка конфига (при запуске)
- Авторизация (блокирует до ввода кода)
- Graceful shutdown (2 sec timeout)
## Зависимости
### UI
- `ratatui` 0.29 — TUI framework
- `crossterm` 0.28 — terminal control
### Telegram
- `tdlib-rs` 1.1 — TDLib bindings
- `tokio` 1.x — async runtime
### Data
- `serde` + `serde_json` 1.0 — serialization
- `toml` 0.8 — config parsing
- `chrono` 0.4 — date/time
### System
- `dirs` 5.0 — XDG directories
- `arboard` 3.4 — clipboard
- `open` 5.0 — открытие URL/файлов
- `dotenvy` 0.15 — .env файлы
## Workflow разработки
1. Изучить [ROADMAP.md](ROADMAP.md) — понять текущую фазу
2. Прочитать [DEVELOPMENT.md](DEVELOPMENT.md) — правила работы
3. Изучить [CONTEXT.md](CONTEXT.md) — архитектурные решения
4. Найти issue или создать новую фичу
5. Создать feature branch
6. Внести изменения
7. `cargo fmt` + `cargo clippy`
8. Протестировать вручную
9. Создать PR с описанием
## CI/CD
### GitHub Actions (.github/workflows/ci.yml)
- **Check**: `cargo check`
- **Format**: `cargo fmt --check`
- **Clippy**: `cargo clippy`
- **Build**: для Ubuntu, macOS, Windows
Запускается на:
- Push в `main` или `develop`
- Pull requests
## Безопасность
### Чувствительные файлы (в .gitignore)
- `.env`
- `credentials`
- `config.toml` (если в корне проекта)
- `tdlib_data/`
- `target/`
### Рекомендации
- Credentials в `~/.config/tele-tui/credentials`
- Права доступа: `chmod 600 ~/.config/tele-tui/credentials`
- Никогда не коммитить `tdlib_data/`

164
README.md
View File

@@ -1 +1,163 @@
telegram-tui консольный телеграм
# tele-tui
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Rust](https://img.shields.io/badge/rust-1.70%2B-orange.svg)](https://www.rust-lang.org/)
Консольный Telegram клиент с Vim-style навигацией.
![tele-tui screenshot](docs/screenshot.png)
## Возможности
- **Полная интеграция с Telegram**: отправка/получение сообщений, редактирование, удаление, пересылка
- **Vim-style навигация**: hjkl + поддержка русской раскладки (ролд)
- **Markdown форматирование**: жирный, курсив, подчёркивание, зачёркивание, код, спойлеры, ссылки
- **Реакции на сообщения**: emoji picker с навигацией стрелками
- **Папки Telegram**: переключение между папками (1-9)
- **Поиск**: по чатам (Ctrl+S) и внутри чата (Ctrl+F)
- **Черновики**: автосохранение набранного текста при переключении чатов
- **Typing indicator**: показывает когда собеседник печатает
- **Закреплённые сообщения**: отображение и переход к закреплённому сообщению
- **Копирование в буфер**: copy сообщений в системный буфер обмена
- **Профиль**: просмотр информации о пользователе/чате
- **Конфигурация**: настройка цветов и часового пояса через TOML
- **Оптимизация**: 60 FPS, умное кеширование, graceful shutdown
## Установка
### Требования
- Rust 1.70+
- TDLib (скачивается автоматически через tdlib-rs)
### Сборка
```bash
git clone https://github.com/your-username/tele-tui.git
cd tele-tui
cargo build --release
```
### API Credentials
Получите API credentials на https://my.telegram.org/apps
Создайте файл `~/.config/tele-tui/credentials`:
```
API_ID=your_api_id
API_HASH=your_api_hash
```
Или используйте `.env` файл в директории проекта:
```
API_ID=your_api_id
API_HASH=your_api_hash
```
## Использование
```bash
cargo run --release
```
При первом запуске нужно пройти авторизацию (телефон + код + опционально 2FA пароль).
## Конфигурация
Конфигурационный файл создаётся автоматически в `~/.config/tele-tui/config.toml`:
```toml
[general]
# Часовой пояс в формате "+03:00" или "-05:00"
timezone = "+03:00"
[colors]
# Поддерживаемые цвета: black, red, green, yellow, blue, magenta, cyan, gray, white,
# darkgray, lightred, lightgreen, lightyellow, lightblue, lightmagenta, lightcyan
incoming_message = "white"
outgoing_message = "green"
selected_message = "yellow"
reaction_chosen = "yellow"
reaction_other = "gray"
```
## Горячие клавиши
### Навигация
- `↑/↓` или `k/j` (р/о) — навигация по списку чатов
- `Enter` — открыть чат / отправить сообщение
- `Esc` — закрыть чат / отменить действие
- `1-9` — переключение между папками
- `Ctrl+S` — поиск по чатам
- `Ctrl+R` — обновить список чатов
- `Ctrl+C` — выход
### В открытом чате
- `↑/↓` — скролл сообщений
- `Ctrl+F` — поиск в чате
- `n/N` — следующий/предыдущий результат поиска
- `i` — информация о чате/пользователе
### Работа с сообщениями
- `↑` при пустом инпуте — выбор сообщения
- `Enter` в режиме выбора — редактировать
- `r` / `к` — ответить (reply)
- `f` / `а` — переслать (forward)
- `d` / `в` / `Delete` — удалить
- `y` / `н` — скопировать в буфер
- `e` / `у` — добавить реакцию
### Emoji Picker (реакции)
- `←/→/↑/↓` — навигация по сетке
- `Enter` — добавить/удалить реакцию
- `Esc` — закрыть picker
### Редактирование текста
- `←/→` — перемещение курсора
- `Home` — в начало строки
- `End` — в конец строки
- `Backspace` — удалить символ слева
- `Delete` — удалить символ справа
## Структура проекта
```
src/
├── main.rs # Точка входа, event loop
├── config.rs # Конфигурация (TOML), credentials
├── app/ # Состояние приложения
├── ui/ # Отрисовка интерфейса
├── input/ # Обработка ввода
├── utils.rs # Утилиты (форматирование времени, логи)
└── tdlib/ # TDLib интеграция
```
## Зависимости
- `ratatui` 0.29 — TUI framework
- `crossterm` 0.28 — terminal handling
- `tdlib-rs` 1.1 — Telegram API
- `tokio` 1.x — async runtime
- `serde` + `serde_json` — serialization
- `toml` 0.8 — config parsing
- `dirs` 5.0 — XDG directories
- `clipboard` 0.5 — clipboard access
- `chrono` 0.4 — date/time formatting
## Документация
- [INSTALL.md](INSTALL.md) — подробная инструкция по установке
- [HOTKEYS.md](HOTKEYS.md) — все горячие клавиши
- [FAQ.md](FAQ.md) — часто задаваемые вопросы
- [CONTRIBUTING.md](CONTRIBUTING.md) — как внести вклад
- [PROJECT_STRUCTURE.md](PROJECT_STRUCTURE.md) — структура проекта
- [SECURITY.md](SECURITY.md) — политика безопасности
- [CHANGELOG.md](CHANGELOG.md) — история изменений
- [REQUIREMENTS.md](REQUIREMENTS.md) — функциональные требования
- [DEVELOPMENT.md](DEVELOPMENT.md) — правила разработки
- [ROADMAP.md](ROADMAP.md) — план развития проекта
- [CONTEXT.md](CONTEXT.md) — текущий статус разработки
## Лицензия
MIT

View File

@@ -55,6 +55,29 @@
11) `@` — пинг/меншн.
12) с видео/картинками/голосовые пока ничего не делаем, ренденим заглушку (с упоминанием что это картинка или видео и тд)
### Дополнительно реализованные возможности
13) **Markdown форматирование**: жирный, курсив, подчёркивание, зачёркивание, код, спойлеры, ссылки, упоминания
14) **Редактирование сообщений**: ↑ при пустом инпуте → выбор → Enter → редактирование
15) **Удаление сообщений**: d/в/Delete в режиме выбора → подтверждение → удаление
16) **Reply на сообщения**: r/к в режиме выбора → превью → отправка ответа
17) **Forward сообщений**: f/а в режиме выбора → выбор чата → пересылка
18) **Typing indicator**: отображение "печатает..." когда собеседник набирает текст
19) **Закреплённые сообщения**: отображение pinned message вверху чата с переходом
20) **Поиск по сообщениям**: Ctrl+F для поиска внутри чата, n/N для навигации
21) **Черновики**: автосохранение текста при переключении между чатами
22) **Профиль**: i для просмотра информации о пользователе/группе
23) **Копирование**: y/н для копирования сообщения в системный буфер
24) **Реакции**: e/у для добавления реакций, emoji picker с навигацией стрелками
25) **Конфигурация**: ~/.config/tele-tui/config.toml для настройки цветов и timezone
26) **Credentials**: приоритетная загрузка из ~/.config/tele-tui/credentials или .env
27) **Блочный курсор**: Vim-style курсор █ с навигацией ←/→/Home/End
28) **Динамический инпут**: автоматическое расширение до 10 строк
29) **Онлайн-статус**: зелёная точка ● для онлайн пользователей
30) **Индикаторы**: 📌 закреплённые чаты, 🔇 замьюченные, @ упоминания
31) **Состояние сети**: индикатор в футере (⚠ Нет сети, ⏳ Подключение...)
32) **Graceful shutdown**: корректное закрытие при Ctrl+C
### Управление
1) ctrl+c или command+c - выход из программы
2) "h j k l" - влево, вниз, вверх, вправо (навигация в левом столбце) vim-style управление
@@ -67,19 +90,38 @@
9) поддержка русской раскладки: "р о л д" соответствует "h j k l"
10) Ctrl+R - обновить список чатов
### Реализованные команды (footer)
### Реализованные команды
#### Навигация
```
j/k: Navigate | Ctrl+k: First | Enter: Open | Esc: Close | Ctrl+R: Refresh | Ctrl+C: Quit
↑/↓ или k/j (р/о): Navigate | Enter: Open/Send | Esc: Close/Cancel | 1-9: Folders
Ctrl+S: Search Chats | Ctrl+R: Refresh | Ctrl+F: Search in Chat | Ctrl+C: Quit
```
#### Работа с сообщениями
```
↑ (пустой инпут): Select message | Enter: Edit | r/к: Reply | f/а: Forward
d/в/Delete: Delete | y/н: Copy | e/у: React | i: Profile
```
#### Emoji Picker (реакции)
```
←/→/↑/↓: Navigate | Enter: Toggle reaction | Esc: Close
```
## Технологии
Пишем на rust-е
1) ratatui - для tui интерфейса
2) tdlib-rs - для подключения апи телеграма (обёртка над TDLib)
3) tokio - async runtime
4) crossterm - кроссплатформенный терминал
1) ratatui 0.29 - для tui интерфейса
2) tdlib-rs 1.1 - для подключения апи телеграма (обёртка над TDLib)
3) tokio 1.x - async runtime
4) crossterm 0.28 - кроссплатформенный терминал
5) serde + serde_json 1.0 - сериализация/десериализация
6) toml 0.8 - парсинг конфигурации
7) dirs 5.0 - XDG директории (config, data)
8) clipboard 0.5 - работа с системным буфером обмена
9) chrono 0.4 - форматирование даты/времени
10) dotenvy 0.15 - загрузка .env файлов
## Нефункциональные требования

View File

@@ -17,33 +17,129 @@
- [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: Глубокий рефакторинг памяти [DONE]
- [x] Удалить дублирование current_messages между App и TdClient
- [x] Использовать единый источник данных для сообщений
- [x] Реализовать LRU-кэш для user_names/user_statuses вместо простого лимита
- [x] Lazy loading для имён пользователей (батчевая загрузка последних 5 за цикл)
- [x] Лимиты памяти:
- MAX_MESSAGES_IN_CHAT = 500
- MAX_CHATS = 200
- MAX_CHAT_USER_IDS = 500
- MAX_USER_CACHE_SIZE = 500 (LRU)
## Фаза 8: Дополнительные фичи [DONE]
- [x] Markdown форматирование в сообщениях
- Bold, Italic, Underline, Strikethrough
- Code (inline, Pre, PreCode)
- Spoiler (скрытый текст)
- URLs, упоминания (@)
- [x] Редактирование сообщений
- ↑ при пустом инпуте → выбор сообщения
- Enter для начала редактирования
- Подсветка выбранного сообщения (▶)
- Esc для отмены
- [x] Удаление сообщений
- d / в / Delete в режиме выбора
- Модалка подтверждения (y/n)
- Удаление для всех если возможно
- [x] Индикатор редактирования (✎)
- Отображается рядом с временем для отредактированных сообщений
- [x] Блочный курсор в поле ввода
- Vim-style курсор █
- Перемещение ←/→, Home/End
- Редактирование в любой позиции
- [x] Reply на сообщения
- `r` / `к` в режиме выбора → режим ответа
- Превью сообщения в поле ввода
- Esc для отмены
- [x] Forward сообщений
- `f` / `а` в режиме выбора → режим пересылки
- Превью сообщения в поле ввода
- Выбор чата стрелками, Enter для пересылки
- Esc для отмены
- Отображение "↪ Переслано от" для пересланных сообщений
## Фаза 9: Расширенные возможности [DONE]
- [x] Typing indicator ("печатает...")
- Показывать когда собеседник печатает
- Отправлять свой статус печати при наборе текста
- [x] Закреплённые сообщения (Pinned)
- Отображать pinned message вверху открытого чата
- Клик/хоткей для перехода к закреплённому сообщению
- [x] Поиск по сообщениям в чате
- `Ctrl+F` — поиск текста внутри открытого чата
- Навигация по результатам (n/N или стрелки)
- Подсветка найденных совпадений
- [x] Черновики
- Сохранять набранный текст при переключении между чатами
- Индикатор черновика в списке чатов
- Восстановление текста при возврате в чат
- [x] Профиль пользователя/чата
- `i` — открыть информацию о чате/собеседнике
- Для личных чатов: имя, username, телефон, био
- Для групп: название, описание, количество участников
- [x] Копирование сообщений
- `y` / `н` в режиме выбора — скопировать текст в системный буфер обмена
- Использовать clipboard crate для кроссплатформенности
- [x] Реакции
- Отображение реакций под сообщениями
- `e` в режиме выбора — добавить реакцию (emoji picker)
- Список доступных реакций чата
- [x] Конфигурационный файл
- `~/.config/tele-tui/config.toml`
- Настройки: цветовая схема, часовой пояс, хоткеи
- Загрузка конфига при старте

64
SECURITY.md Normal file
View File

@@ -0,0 +1,64 @@
# Security Policy
## Поддерживаемые версии
| Версия | Поддержка |
| ------ | ------------------ |
| 0.1.x | :white_check_mark: |
## Сообщить об уязвимости
Если вы обнаружили уязвимость в безопасности, пожалуйста:
1. **НЕ создавайте публичный issue**
2. Отправьте email на: [security@example.com]
3. Опишите:
- Тип уязвимости
- Шаги для воспроизведения
- Потенциальное влияние
- Предложения по исправлению (если есть)
## Безопасность credentials
### Важно
- **НИКОГДА** не коммитьте файлы с credentials в git
- Файлы `credentials` и `.env` уже добавлены в `.gitignore`
- TDLib сессия в `tdlib_data/` содержит токены авторизации — НЕ делитесь этой папкой
### Рекомендации
1. **Используйте XDG config directory**:
- Храните credentials в `~/.config/tele-tui/credentials`
- Установите права доступа: `chmod 600 ~/.config/tele-tui/credentials`
2. **Защита сессии**:
- Папка `tdlib_data/` содержит вашу авторизованную сессию
- Не копируйте её на другие машины
- При компрометации — удалите папку и авторизуйтесь заново
3. **Двухфакторная аутентификация**:
- Настоятельно рекомендуется включить 2FA в Telegram
- Это защитит ваш аккаунт даже при утечке API credentials
## Известные ограничения
### TDLib
- Приложение использует официальную библиотеку TDLib от Telegram
- Безопасность зависит от актуальности TDLib (автообновление через tdlib-rs)
### Конфигурация
- Конфигурационный файл `config.toml` НЕ содержит чувствительных данных
- Credentials хранятся отдельно в файле `credentials`
## Обновления безопасности
Мы оперативно реагируем на сообщения об уязвимостях и выпускаем патчи в течение:
- **Критические**: 24-48 часов
- **Высокие**: 3-7 дней
- **Средние**: 2-4 недели
- **Низкие**: включаются в следующий релиз
## Спасибо
Мы ценим ваш вклад в безопасность проекта!

31
config.toml.example Normal file
View File

@@ -0,0 +1,31 @@
# tele-tui configuration file example
#
# Этот файл автоматически создаётся при первом запуске в ~/.config/tele-tui/config.toml
# Скопируйте его туда и настройте по своему усмотрению
[general]
# Часовой пояс в формате "+03:00" или "-05:00"
# Применяется к отображению времени сообщений
timezone = "+03:00"
[colors]
# Цветовая схема интерфейса
# Поддерживаемые цвета:
# - Основные: black, red, green, yellow, blue, magenta, cyan, gray, white
# - Светлые: lightred, lightgreen, lightyellow, lightblue, lightmagenta, lightcyan
# - Тёмные: darkgray
# Цвет входящих сообщений
incoming_message = "white"
# Цвет исходящих сообщений
outgoing_message = "green"
# Цвет выбранного сообщения
selected_message = "yellow"
# Цвет своих реакций (отображаются в рамках [👍])
reaction_chosen = "yellow"
# Цвет чужих реакций
reaction_other = "gray"

10
credentials.example Normal file
View File

@@ -0,0 +1,10 @@
# Telegram API Credentials
#
# Получите эти данные на https://my.telegram.org/apps
# Создайте приложение и скопируйте api_id и api_hash
#
# Этот файл должен быть размещён в ~/.config/tele-tui/credentials
# Альтернативно можно использовать .env файл в директории проекта
API_ID=your_api_id_here
API_HASH=your_api_hash_here

21
rustfmt.toml Normal file
View File

@@ -0,0 +1,21 @@
# Rustfmt configuration for tele-tui
# https://rust-lang.github.io/rustfmt/
edition = "2021"
max_width = 100
tab_spaces = 4
newline_style = "Unix"
# Imports
imports_granularity = "Crate"
group_imports = "StdExternalCrate"
# Comments
wrap_comments = true
comment_width = 80
normalize_comments = true
# Formatting
use_small_heuristics = "Default"
fn_call_width = 80
struct_lit_width = 50

View File

@@ -3,10 +3,11 @@ mod state;
pub use state::AppScreen;
use ratatui::widgets::ListState;
use crate::tdlib::client::{ChatInfo, MessageInfo};
use crate::tdlib::client::ChatInfo;
use crate::tdlib::TdClient;
pub struct App {
pub config: crate::config::Config,
pub screen: AppScreen,
pub td_client: TdClient,
// Auth state
@@ -19,23 +20,81 @@ pub struct App {
pub chats: Vec<ChatInfo>,
pub chat_list_state: ListState,
pub selected_chat_id: Option<i64>,
pub current_messages: Vec<MessageInfo>,
pub message_input: String,
/// Позиция курсора в message_input (в символах)
pub cursor_position: usize,
pub message_scroll_offset: usize,
pub folders: Vec<String>,
pub selected_folder: usize,
/// None = All (основной список), Some(id) = папка с id
pub selected_folder_id: Option<i32>,
pub is_loading: bool,
// Search state
pub is_searching: bool,
pub search_query: String,
/// Флаг для оптимизации рендеринга - перерисовывать только при изменениях
pub needs_redraw: bool,
// Edit message state
/// ID сообщения, которое редактируется (None = режим отправки нового)
pub editing_message_id: Option<i64>,
/// Индекс выбранного сообщения для навигации (снизу вверх, 0 = последнее)
pub selected_message_index: Option<usize>,
// Delete confirmation
/// ID сообщения для подтверждения удаления (показывает модалку)
pub confirm_delete_message_id: Option<i64>,
// Reply state
/// ID сообщения, на которое отвечаем (None = обычная отправка)
pub replying_to_message_id: Option<i64>,
// Forward state
/// ID сообщения для пересылки
pub forwarding_message_id: Option<i64>,
/// Режим выбора чата для пересылки
pub is_selecting_forward_chat: bool,
// Typing indicator
/// Время последней отправки typing status (для throttling)
pub last_typing_sent: Option<std::time::Instant>,
// Pinned messages mode
/// Режим просмотра закреплённых сообщений
pub is_pinned_mode: bool,
/// Список закреплённых сообщений
pub pinned_messages: Vec<crate::tdlib::client::MessageInfo>,
/// Индекс выбранного pinned сообщения
pub selected_pinned_index: usize,
// Message search mode
/// Режим поиска по сообщениям
pub is_message_search_mode: bool,
/// Поисковый запрос
pub message_search_query: String,
/// Результаты поиска
pub message_search_results: Vec<crate::tdlib::client::MessageInfo>,
/// Индекс выбранного результата
pub selected_search_result_index: usize,
// Profile mode
/// Режим просмотра профиля
pub is_profile_mode: bool,
/// Индекс выбранного действия в профиле
pub selected_profile_action: usize,
/// Шаг подтверждения выхода из группы (0 = не показано, 1 = первое, 2 = второе)
pub leave_group_confirmation_step: u8,
/// Информация профиля для отображения
pub profile_info: Option<crate::tdlib::ProfileInfo>,
// Reaction picker mode
/// Режим выбора реакции
pub is_reaction_picker_mode: bool,
/// ID сообщения для добавления реакции
pub selected_message_for_reaction: Option<i64>,
/// Список доступных реакций
pub available_reactions: Vec<String>,
/// Индекс выбранной реакции в picker
pub selected_reaction_index: usize,
}
impl App {
pub fn new() -> App {
pub fn new(config: crate::config::Config) -> App {
let mut state = ListState::default();
state.select(Some(0));
App {
config,
screen: AppScreen::Loading,
td_client: TdClient::new(),
phone_input: String::new(),
@@ -46,24 +105,47 @@ impl App {
chats: Vec::new(),
chat_list_state: state,
selected_chat_id: None,
current_messages: Vec::new(),
message_input: String::new(),
cursor_position: 0,
message_scroll_offset: 0,
folders: vec!["All".to_string()],
selected_folder: 0,
selected_folder_id: None, // None = All
is_loading: true,
is_searching: false,
search_query: String::new(),
needs_redraw: true,
editing_message_id: None,
selected_message_index: None,
confirm_delete_message_id: None,
replying_to_message_id: None,
forwarding_message_id: None,
is_selecting_forward_chat: false,
last_typing_sent: None,
is_pinned_mode: false,
pinned_messages: Vec::new(),
selected_pinned_index: 0,
is_message_search_mode: false,
message_search_query: String::new(),
message_search_results: Vec::new(),
selected_search_result_index: 0,
is_profile_mode: false,
selected_profile_action: 0,
leave_group_confirmation_step: 0,
profile_info: None,
is_reaction_picker_mode: false,
selected_message_for_reaction: None,
available_reactions: Vec::new(),
selected_reaction_index: 0,
}
}
pub fn next_chat(&mut self) {
if self.chats.is_empty() {
let filtered = self.get_filtered_chats();
if filtered.is_empty() {
return;
}
let i = match self.chat_list_state.selected() {
Some(i) => {
if i >= self.chats.len() - 1 {
if i >= filtered.len() - 1 {
0
} else {
i + 1
@@ -75,13 +157,14 @@ impl App {
}
pub fn previous_chat(&mut self) {
if self.chats.is_empty() {
let filtered = self.get_filtered_chats();
if filtered.is_empty() {
return;
}
let i = match self.chat_list_state.selected() {
Some(i) => {
if i == 0 {
self.chats.len() - 1
filtered.len() - 1
} else {
i - 1
}
@@ -92,8 +175,9 @@ impl App {
}
pub fn select_current_chat(&mut self) {
let filtered = self.get_filtered_chats();
if let Some(i) = self.chat_list_state.selected() {
if let Some(chat) = self.chats.get(i) {
if let Some(chat) = filtered.get(i) {
self.selected_chat_id = Some(chat.id);
}
}
@@ -101,15 +185,108 @@ impl App {
pub fn close_chat(&mut self) {
self.selected_chat_id = None;
self.current_messages.clear();
self.message_input.clear();
self.cursor_position = 0;
self.message_scroll_offset = 0;
self.editing_message_id = None;
self.selected_message_index = None;
self.replying_to_message_id = None;
self.last_typing_sent = None;
// Сбрасываем pinned режим
self.is_pinned_mode = false;
self.pinned_messages.clear();
self.selected_pinned_index = 0;
// Очищаем данные в TdClient
self.td_client.current_chat_id = None;
self.td_client.current_chat_messages.clear();
self.td_client.typing_status = None;
self.td_client.current_pinned_message = None;
// Сбрасываем режим поиска
self.is_message_search_mode = false;
self.message_search_query.clear();
self.message_search_results.clear();
self.selected_search_result_index = 0;
}
pub fn select_first_chat(&mut self) {
if !self.chats.is_empty() {
self.chat_list_state.select(Some(0));
/// Начать выбор сообщения для редактирования (при стрелке вверх в пустом инпуте)
pub fn start_message_selection(&mut self) {
if self.td_client.current_chat_messages.is_empty() {
return;
}
// Начинаем с последнего сообщения (индекс 0 = самое новое снизу)
self.selected_message_index = Some(0);
}
/// Выбрать предыдущее сообщение (вверх по списку = увеличить индекс)
pub fn select_previous_message(&mut self) {
let total = self.td_client.current_chat_messages.len();
if total == 0 {
return;
}
self.selected_message_index = Some(
self.selected_message_index
.map(|i| (i + 1).min(total - 1))
.unwrap_or(0)
);
}
/// Выбрать следующее сообщение (вниз по списку = уменьшить индекс)
pub fn select_next_message(&mut self) {
self.selected_message_index = self.selected_message_index
.map(|i| if i > 0 { Some(i - 1) } else { None })
.flatten();
}
/// Получить выбранное сообщение
pub fn get_selected_message(&self) -> Option<&crate::tdlib::client::MessageInfo> {
self.selected_message_index.and_then(|idx| {
let total = self.td_client.current_chat_messages.len();
if total == 0 || idx >= total {
return None;
}
// idx=0 это последнее сообщение (total-1), idx=1 это предпоследнее (total-2), и т.д.
self.td_client.current_chat_messages.get(total - 1 - idx)
})
}
/// Начать редактирование выбранного сообщения
pub fn start_editing_selected(&mut self) -> bool {
// Сначала извлекаем данные из сообщения
let msg_data = self.get_selected_message().and_then(|msg| {
if msg.can_be_edited && msg.is_outgoing {
Some((msg.id, msg.content.clone()))
} else {
None
}
});
// Затем присваиваем
if let Some((id, content)) = msg_data {
self.editing_message_id = Some(id);
self.cursor_position = content.chars().count();
self.message_input = content;
self.selected_message_index = None;
return true;
}
false
}
/// Отменить редактирование
pub fn cancel_editing(&mut self) {
self.editing_message_id = None;
self.selected_message_index = None;
self.message_input.clear();
self.cursor_position = 0;
}
/// Проверить, находимся ли в режиме редактирования
pub fn is_editing(&self) -> bool {
self.editing_message_id.is_some()
}
/// Проверить, находимся ли в режиме выбора сообщения
pub fn is_selecting_message(&self) -> bool {
self.selected_message_index.is_some()
}
pub fn get_selected_chat_id(&self) -> Option<i64> {
@@ -133,12 +310,20 @@ impl App {
}
pub fn get_filtered_chats(&self) -> Vec<&ChatInfo> {
let folder_filtered: Vec<&ChatInfo> = match self.selected_folder_id {
None => self.chats.iter().collect(), // All - показываем все
Some(folder_id) => self.chats
.iter()
.filter(|c| c.folder_ids.contains(&folder_id))
.collect(),
};
if self.search_query.is_empty() {
self.chats.iter().collect()
folder_filtered
} else {
let query = self.search_query.to_lowercase();
self.chats
.iter()
folder_filtered
.into_iter()
.filter(|c| {
// Поиск по названию чата
c.title.to_lowercase().contains(&query) ||
@@ -196,4 +381,285 @@ impl App {
}
}
}
/// Проверить, показывается ли модалка подтверждения удаления
pub fn is_confirm_delete_shown(&self) -> bool {
self.confirm_delete_message_id.is_some()
}
/// Начать режим ответа на выбранное сообщение
pub fn start_reply_to_selected(&mut self) -> bool {
if let Some(msg) = self.get_selected_message() {
self.replying_to_message_id = Some(msg.id);
self.selected_message_index = None;
return true;
}
false
}
/// Отменить режим ответа
pub fn cancel_reply(&mut self) {
self.replying_to_message_id = None;
}
/// Проверить, находимся ли в режиме ответа
pub fn is_replying(&self) -> bool {
self.replying_to_message_id.is_some()
}
/// Получить сообщение, на которое отвечаем
pub fn get_replying_to_message(&self) -> Option<&crate::tdlib::client::MessageInfo> {
self.replying_to_message_id.and_then(|id| {
self.td_client.current_chat_messages.iter().find(|m| m.id == id)
})
}
/// Начать режим пересылки выбранного сообщения
pub fn start_forward_selected(&mut self) -> bool {
if let Some(msg) = self.get_selected_message() {
self.forwarding_message_id = Some(msg.id);
self.selected_message_index = None;
self.is_selecting_forward_chat = true;
// Сбрасываем выбор чата на первый
self.chat_list_state.select(Some(0));
return true;
}
false
}
/// Отменить режим пересылки
pub fn cancel_forward(&mut self) {
self.forwarding_message_id = None;
self.is_selecting_forward_chat = false;
}
/// Проверить, находимся ли в режиме выбора чата для пересылки
pub fn is_forwarding(&self) -> bool {
self.is_selecting_forward_chat && self.forwarding_message_id.is_some()
}
/// Получить сообщение для пересылки
pub fn get_forwarding_message(&self) -> Option<&crate::tdlib::client::MessageInfo> {
self.forwarding_message_id.and_then(|id| {
self.td_client.current_chat_messages.iter().find(|m| m.id == id)
})
}
// === Pinned messages mode ===
/// Проверка режима pinned
pub fn is_pinned_mode(&self) -> bool {
self.is_pinned_mode
}
/// Войти в режим pinned (вызывается после загрузки pinned сообщений)
pub fn enter_pinned_mode(&mut self, messages: Vec<crate::tdlib::client::MessageInfo>) {
if !messages.is_empty() {
self.pinned_messages = messages;
self.selected_pinned_index = 0;
self.is_pinned_mode = true;
}
}
/// Выйти из режима pinned
pub fn exit_pinned_mode(&mut self) {
self.is_pinned_mode = false;
self.pinned_messages.clear();
self.selected_pinned_index = 0;
}
/// Выбрать предыдущий pinned (вверх = более старый)
pub fn select_previous_pinned(&mut self) {
if !self.pinned_messages.is_empty() && self.selected_pinned_index < self.pinned_messages.len() - 1 {
self.selected_pinned_index += 1;
}
}
/// Выбрать следующий pinned (вниз = более новый)
pub fn select_next_pinned(&mut self) {
if self.selected_pinned_index > 0 {
self.selected_pinned_index -= 1;
}
}
/// Получить текущее выбранное pinned сообщение
pub fn get_selected_pinned(&self) -> Option<&crate::tdlib::client::MessageInfo> {
self.pinned_messages.get(self.selected_pinned_index)
}
/// Получить ID текущего pinned для перехода в историю
pub fn get_selected_pinned_id(&self) -> Option<i64> {
self.get_selected_pinned().map(|m| m.id)
}
// === Message Search Mode ===
/// Проверить, активен ли режим поиска по сообщениям
pub fn is_message_search_mode(&self) -> bool {
self.is_message_search_mode
}
/// Войти в режим поиска по сообщениям
pub fn enter_message_search_mode(&mut self) {
self.is_message_search_mode = true;
self.message_search_query.clear();
self.message_search_results.clear();
self.selected_search_result_index = 0;
}
/// Выйти из режима поиска
pub fn exit_message_search_mode(&mut self) {
self.is_message_search_mode = false;
self.message_search_query.clear();
self.message_search_results.clear();
self.selected_search_result_index = 0;
}
/// Установить результаты поиска
pub fn set_search_results(&mut self, results: Vec<crate::tdlib::client::MessageInfo>) {
self.message_search_results = results;
self.selected_search_result_index = 0;
}
/// Выбрать предыдущий результат (вверх)
pub fn select_previous_search_result(&mut self) {
if self.selected_search_result_index > 0 {
self.selected_search_result_index -= 1;
}
}
/// Выбрать следующий результат (вниз)
pub fn select_next_search_result(&mut self) {
if !self.message_search_results.is_empty()
&& self.selected_search_result_index < self.message_search_results.len() - 1
{
self.selected_search_result_index += 1;
}
}
/// Получить текущий выбранный результат
pub fn get_selected_search_result(&self) -> Option<&crate::tdlib::client::MessageInfo> {
self.message_search_results.get(self.selected_search_result_index)
}
/// Получить ID выбранного результата для перехода
pub fn get_selected_search_result_id(&self) -> Option<i64> {
self.get_selected_search_result().map(|m| m.id)
}
// === Draft Management ===
/// Получить черновик для текущего чата
pub fn get_current_draft(&self) -> Option<String> {
self.selected_chat_id.and_then(|chat_id| {
self.chats
.iter()
.find(|c| c.id == chat_id)
.and_then(|c| c.draft_text.clone())
})
}
/// Загрузить черновик в message_input (вызывается при открытии чата)
pub fn load_draft(&mut self) {
if let Some(draft) = self.get_current_draft() {
self.message_input = draft;
self.cursor_position = self.message_input.chars().count();
}
}
// === Profile Mode ===
/// Проверить, активен ли режим профиля
pub fn is_profile_mode(&self) -> bool {
self.is_profile_mode
}
/// Войти в режим профиля
pub fn enter_profile_mode(&mut self) {
self.is_profile_mode = true;
self.selected_profile_action = 0;
self.leave_group_confirmation_step = 0;
}
/// Выйти из режима профиля
pub fn exit_profile_mode(&mut self) {
self.is_profile_mode = false;
self.selected_profile_action = 0;
self.leave_group_confirmation_step = 0;
self.profile_info = None;
}
/// Выбрать предыдущее действие
pub fn select_previous_profile_action(&mut self) {
if self.selected_profile_action > 0 {
self.selected_profile_action -= 1;
}
}
/// Выбрать следующее действие
pub fn select_next_profile_action(&mut self, max_actions: usize) {
if self.selected_profile_action < max_actions.saturating_sub(1) {
self.selected_profile_action += 1;
}
}
/// Показать первое подтверждение выхода из группы
pub fn show_leave_group_confirmation(&mut self) {
self.leave_group_confirmation_step = 1;
}
/// Показать второе подтверждение выхода из группы
pub fn show_leave_group_final_confirmation(&mut self) {
self.leave_group_confirmation_step = 2;
}
/// Отменить подтверждение выхода из группы
pub fn cancel_leave_group(&mut self) {
self.leave_group_confirmation_step = 0;
}
/// Получить текущий шаг подтверждения
pub fn get_leave_group_confirmation_step(&self) -> u8 {
self.leave_group_confirmation_step
}
// ========== Reaction Picker ==========
pub fn is_reaction_picker_mode(&self) -> bool {
self.is_reaction_picker_mode
}
pub fn enter_reaction_picker_mode(&mut self, message_id: i64, available_reactions: Vec<String>) {
self.is_reaction_picker_mode = true;
self.selected_message_for_reaction = Some(message_id);
self.available_reactions = available_reactions;
self.selected_reaction_index = 0;
}
pub fn exit_reaction_picker_mode(&mut self) {
self.is_reaction_picker_mode = false;
self.selected_message_for_reaction = None;
self.available_reactions.clear();
self.selected_reaction_index = 0;
}
pub fn select_previous_reaction(&mut self) {
if !self.available_reactions.is_empty() && self.selected_reaction_index > 0 {
self.selected_reaction_index -= 1;
}
}
pub fn select_next_reaction(&mut self) {
if self.selected_reaction_index + 1 < self.available_reactions.len() {
self.selected_reaction_index += 1;
}
}
pub fn get_selected_reaction(&self) -> Option<&String> {
self.available_reactions.get(self.selected_reaction_index)
}
pub fn get_selected_message_for_reaction(&self) -> Option<i64> {
self.selected_message_for_reaction
}
}

265
src/config.rs Normal file
View File

@@ -0,0 +1,265 @@
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
#[serde(default)]
pub general: GeneralConfig,
#[serde(default)]
pub colors: ColorsConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GeneralConfig {
/// Часовой пояс в формате "+03:00" или "-05:00"
#[serde(default = "default_timezone")]
pub timezone: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ColorsConfig {
/// Цвет входящих сообщений (white, gray, cyan и т.д.)
#[serde(default = "default_incoming_color")]
pub incoming_message: String,
/// Цвет исходящих сообщений
#[serde(default = "default_outgoing_color")]
pub outgoing_message: String,
/// Цвет выбранного сообщения
#[serde(default = "default_selected_color")]
pub selected_message: String,
/// Цвет своих реакций
#[serde(default = "default_reaction_chosen_color")]
pub reaction_chosen: String,
/// Цвет чужих реакций
#[serde(default = "default_reaction_other_color")]
pub reaction_other: String,
}
// Дефолтные значения
fn default_timezone() -> String {
"+03:00".to_string()
}
fn default_incoming_color() -> String {
"white".to_string()
}
fn default_outgoing_color() -> String {
"green".to_string()
}
fn default_selected_color() -> String {
"yellow".to_string()
}
fn default_reaction_chosen_color() -> String {
"yellow".to_string()
}
fn default_reaction_other_color() -> String {
"gray".to_string()
}
impl Default for GeneralConfig {
fn default() -> Self {
Self {
timezone: default_timezone(),
}
}
}
impl Default for ColorsConfig {
fn default() -> Self {
Self {
incoming_message: default_incoming_color(),
outgoing_message: default_outgoing_color(),
selected_message: default_selected_color(),
reaction_chosen: default_reaction_chosen_color(),
reaction_other: default_reaction_other_color(),
}
}
}
impl Default for Config {
fn default() -> Self {
Self {
general: GeneralConfig::default(),
colors: ColorsConfig::default(),
}
}
}
impl Config {
/// Путь к конфигурационному файлу
pub fn config_path() -> Option<PathBuf> {
dirs::config_dir().map(|mut path| {
path.push("tele-tui");
path.push("config.toml");
path
})
}
/// Путь к директории конфигурации
pub fn config_dir() -> Option<PathBuf> {
dirs::config_dir().map(|mut path| {
path.push("tele-tui");
path
})
}
/// Загрузить конфигурацию из файла
pub fn load() -> Self {
let config_path = match Self::config_path() {
Some(path) => path,
None => {
eprintln!("Warning: Could not determine config directory, using defaults");
return Self::default();
}
};
if !config_path.exists() {
// Создаём дефолтный конфиг при первом запуске
let default_config = Self::default();
if let Err(e) = default_config.save() {
eprintln!("Warning: Could not create default config: {}", e);
}
return default_config;
}
match fs::read_to_string(&config_path) {
Ok(content) => {
match toml::from_str::<Config>(&content) {
Ok(config) => config,
Err(e) => {
eprintln!("Warning: Could not parse config file: {}", e);
Self::default()
}
}
}
Err(e) => {
eprintln!("Warning: Could not read config file: {}", e);
Self::default()
}
}
}
/// Сохранить конфигурацию в файл
pub fn save(&self) -> Result<(), String> {
let config_dir = Self::config_dir()
.ok_or_else(|| "Could not determine config directory".to_string())?;
// Создаём директорию если её нет
fs::create_dir_all(&config_dir)
.map_err(|e| format!("Could not create config directory: {}", e))?;
let config_path = config_dir.join("config.toml");
let toml_string = toml::to_string_pretty(self)
.map_err(|e| format!("Could not serialize config: {}", e))?;
fs::write(&config_path, toml_string)
.map_err(|e| format!("Could not write config file: {}", e))?;
Ok(())
}
/// Парсит строку цвета в ratatui::style::Color
pub fn parse_color(&self, color_str: &str) -> ratatui::style::Color {
use ratatui::style::Color;
match color_str.to_lowercase().as_str() {
"black" => Color::Black,
"red" => Color::Red,
"green" => Color::Green,
"yellow" => Color::Yellow,
"blue" => Color::Blue,
"magenta" => Color::Magenta,
"cyan" => Color::Cyan,
"gray" | "grey" => Color::Gray,
"white" => Color::White,
"darkgray" | "darkgrey" => Color::DarkGray,
"lightred" => Color::LightRed,
"lightgreen" => Color::LightGreen,
"lightyellow" => Color::LightYellow,
"lightblue" => Color::LightBlue,
"lightmagenta" => Color::LightMagenta,
"lightcyan" => Color::LightCyan,
_ => Color::White, // fallback
}
}
/// Путь к файлу credentials
pub fn credentials_path() -> Option<PathBuf> {
Self::config_dir().map(|dir| dir.join("credentials"))
}
/// Загружает API_ID и API_HASH из credentials файла или .env
/// Возвращает (api_id, api_hash) или ошибку с инструкциями
pub fn load_credentials() -> Result<(i32, String), String> {
use std::env;
// 1. Пробуем загрузить из ~/.config/tele-tui/credentials
if let Some(cred_path) = Self::credentials_path() {
if cred_path.exists() {
if let Ok(content) = fs::read_to_string(&cred_path) {
let mut api_id: Option<i32> = None;
let mut api_hash: Option<String> = None;
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some((key, value)) = line.split_once('=') {
let key = key.trim();
let value = value.trim();
match key {
"API_ID" => {
api_id = value.parse().ok();
}
"API_HASH" => {
api_hash = Some(value.to_string());
}
_ => {}
}
}
}
if let (Some(id), Some(hash)) = (api_id, api_hash) {
return Ok((id, hash));
}
}
}
}
// 2. Пробуем загрузить из переменных окружения (.env)
if let (Ok(api_id_str), Ok(api_hash)) = (env::var("API_ID"), env::var("API_HASH")) {
if let Ok(api_id) = api_id_str.parse::<i32>() {
return Ok((api_id, api_hash));
}
}
// 3. Не нашли credentials - возвращаем инструкции
let credentials_path = Self::credentials_path()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "~/.config/tele-tui/credentials".to_string());
Err(format!(
"Telegram API credentials not found!\n\n\
Please create a file at:\n {}\n\n\
With the following content:\n\
API_ID=your_api_id\n\
API_HASH=your_api_hash\n\n\
You can get API credentials at: https://my.telegram.org/apps\n\n\
Alternatively, you can create a .env file in the current directory.",
credentials_path
))
}
}

View File

@@ -1,10 +1,10 @@
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use std::time::Duration;
use std::time::{Duration, Instant};
use tokio::time::timeout;
use crate::app::App;
use crate::tdlib::ChatAction;
pub async fn handle(app: &mut App, key: KeyEvent) {
let has_super = key.modifiers.contains(KeyModifiers::SUPER);
let has_ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
// Глобальные команды (работают всегда)
@@ -22,9 +22,390 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
}
return;
}
KeyCode::Char('p') if has_ctrl => {
// Ctrl+P - режим просмотра закреплённых сообщений
if app.selected_chat_id.is_some() && !app.is_pinned_mode() {
if let Some(chat_id) = app.get_selected_chat_id() {
app.status_message = Some("Загрузка закреплённых...".to_string());
match timeout(Duration::from_secs(5), app.td_client.get_pinned_messages(chat_id)).await {
Ok(Ok(messages)) => {
if messages.is_empty() {
app.status_message = Some("Нет закреплённых сообщений".to_string());
} else {
app.enter_pinned_mode(messages);
app.status_message = None;
}
}
Ok(Err(e)) => {
app.error_message = Some(e);
app.status_message = None;
}
Err(_) => {
app.error_message = Some("Таймаут загрузки".to_string());
app.status_message = None;
}
}
}
}
return;
}
KeyCode::Char('f') if has_ctrl => {
// Ctrl+F - поиск по сообщениям в открытом чате
if app.selected_chat_id.is_some() && !app.is_pinned_mode() && !app.is_message_search_mode() {
app.enter_message_search_mode();
}
return;
}
_ => {}
}
// Режим профиля
if app.is_profile_mode() {
// Обработка подтверждения выхода из группы
let confirmation_step = app.get_leave_group_confirmation_step();
if confirmation_step > 0 {
match key.code {
KeyCode::Char('y') | KeyCode::Char('н') | KeyCode::Enter => {
if confirmation_step == 1 {
// Первое подтверждение - показываем второе
app.show_leave_group_final_confirmation();
} else if confirmation_step == 2 {
// Второе подтверждение - выходим из группы
if let Some(chat_id) = app.selected_chat_id {
let leave_result = app.td_client.leave_chat(chat_id).await;
match leave_result {
Ok(_) => {
app.status_message = Some("Вы вышли из группы".to_string());
app.exit_profile_mode();
app.close_chat();
}
Err(e) => {
app.error_message = Some(e);
app.cancel_leave_group();
}
}
}
}
}
KeyCode::Char('n') | KeyCode::Char('т') | KeyCode::Esc => {
// Отмена
app.cancel_leave_group();
}
_ => {}
}
return;
}
// Обычная навигация по профилю
match key.code {
KeyCode::Esc => {
app.exit_profile_mode();
}
KeyCode::Up => {
app.select_previous_profile_action();
}
KeyCode::Down => {
if let Some(profile) = &app.profile_info {
let max_actions = get_available_actions_count(profile);
app.select_next_profile_action(max_actions);
}
}
KeyCode::Enter => {
// Выполнить выбранное действие
if let Some(profile) = &app.profile_info {
let actions = get_available_actions_count(profile);
let action_index = app.selected_profile_action;
if action_index < actions {
// Определяем какое действие выбрано
let mut current_idx = 0;
// Действие: Открыть в браузере
if profile.username.is_some() {
if action_index == current_idx {
if let Some(username) = &profile.username {
let url = format!("https://t.me/{}", username.trim_start_matches('@'));
match open::that(&url) {
Ok(_) => {
app.status_message = Some(format!("Открыто: {}", url));
}
Err(e) => {
app.error_message = Some(format!("Ошибка открытия браузера: {}", e));
}
}
}
return;
}
current_idx += 1;
}
// Действие: Скопировать ID
if action_index == current_idx {
app.status_message = Some(format!("ID скопирован: {}", profile.chat_id));
return;
}
current_idx += 1;
// Действие: Покинуть группу
if profile.is_group && action_index == current_idx {
app.show_leave_group_confirmation();
}
}
}
}
_ => {}
}
return;
}
// Режим поиска по сообщениям
if app.is_message_search_mode() {
match key.code {
KeyCode::Esc => {
app.exit_message_search_mode();
}
KeyCode::Up | KeyCode::Char('N') => {
app.select_previous_search_result();
}
KeyCode::Down | KeyCode::Char('n') => {
app.select_next_search_result();
}
KeyCode::Enter => {
// Перейти к выбранному сообщению
if let Some(msg_id) = app.get_selected_search_result_id() {
let msg_index = app.td_client.current_chat_messages
.iter()
.position(|m| m.id == msg_id);
if let Some(idx) = msg_index {
let total = app.td_client.current_chat_messages.len();
app.message_scroll_offset = total.saturating_sub(idx + 5);
}
app.exit_message_search_mode();
}
}
KeyCode::Backspace => {
app.message_search_query.pop();
// Выполняем поиск при изменении запроса
if let Some(chat_id) = app.get_selected_chat_id() {
if !app.message_search_query.is_empty() {
if let Ok(Ok(results)) = timeout(
Duration::from_secs(3),
app.td_client.search_messages(chat_id, &app.message_search_query)
).await {
app.set_search_results(results);
}
} else {
app.set_search_results(Vec::new());
}
}
}
KeyCode::Char(c) => {
app.message_search_query.push(c);
// Выполняем поиск при изменении запроса
if let Some(chat_id) = app.get_selected_chat_id() {
if let Ok(Ok(results)) = timeout(
Duration::from_secs(3),
app.td_client.search_messages(chat_id, &app.message_search_query)
).await {
app.set_search_results(results);
}
}
}
_ => {}
}
return;
}
// Режим просмотра закреплённых сообщений
if app.is_pinned_mode() {
match key.code {
KeyCode::Esc => {
app.exit_pinned_mode();
}
KeyCode::Up => {
app.select_previous_pinned();
}
KeyCode::Down => {
app.select_next_pinned();
}
KeyCode::Enter => {
// Перейти к сообщению в истории
if let Some(msg_id) = app.get_selected_pinned_id() {
// Ищем индекс сообщения в текущей истории
let msg_index = app.td_client.current_chat_messages
.iter()
.position(|m| m.id == msg_id);
if let Some(idx) = msg_index {
// Вычисляем scroll offset чтобы показать сообщение
let total = app.td_client.current_chat_messages.len();
app.message_scroll_offset = total.saturating_sub(idx + 5);
}
app.exit_pinned_mode();
}
}
_ => {}
}
return;
}
// Обработка ввода в режиме выбора реакции
if app.is_reaction_picker_mode() {
match key.code {
KeyCode::Left => {
app.select_previous_reaction();
app.needs_redraw = true;
}
KeyCode::Right => {
app.select_next_reaction();
app.needs_redraw = true;
}
KeyCode::Up => {
// Переход на ряд выше (8 эмодзи в ряду)
if app.selected_reaction_index >= 8 {
app.selected_reaction_index = app.selected_reaction_index.saturating_sub(8);
app.needs_redraw = true;
}
}
KeyCode::Down => {
// Переход на ряд ниже (8 эмодзи в ряду)
let new_index = app.selected_reaction_index + 8;
if new_index < app.available_reactions.len() {
app.selected_reaction_index = new_index;
app.needs_redraw = true;
}
}
KeyCode::Enter => {
// Добавить/убрать реакцию
if let Some(emoji) = app.get_selected_reaction().cloned() {
if let Some(message_id) = app.get_selected_message_for_reaction() {
if let Some(chat_id) = app.selected_chat_id {
app.status_message = Some("Отправка реакции...".to_string());
app.needs_redraw = true;
match timeout(
Duration::from_secs(5),
app.td_client.toggle_reaction(chat_id, message_id, emoji.clone())
).await {
Ok(Ok(_)) => {
app.status_message = Some(format!("Реакция {} добавлена", emoji));
app.exit_reaction_picker_mode();
app.needs_redraw = true;
}
Ok(Err(e)) => {
app.error_message = Some(format!("Ошибка: {}", e));
app.status_message = None;
app.needs_redraw = true;
}
Err(_) => {
app.error_message = Some("Таймаут отправки реакции".to_string());
app.status_message = None;
app.needs_redraw = true;
}
}
}
}
}
}
KeyCode::Esc => {
app.exit_reaction_picker_mode();
app.needs_redraw = true;
}
_ => {}
}
return;
}
// Модалка подтверждения удаления
if app.is_confirm_delete_shown() {
match key.code {
KeyCode::Char('y') | KeyCode::Char('н') | KeyCode::Enter => {
// Подтверждение удаления
if let Some(msg_id) = app.confirm_delete_message_id {
if let Some(chat_id) = app.get_selected_chat_id() {
// Находим сообщение для проверки can_be_deleted_for_all_users
let can_delete_for_all = app.td_client.current_chat_messages
.iter()
.find(|m| m.id == msg_id)
.map(|m| m.can_be_deleted_for_all_users)
.unwrap_or(false);
match timeout(
Duration::from_secs(5),
app.td_client.delete_messages(chat_id, vec![msg_id], can_delete_for_all)
).await {
Ok(Ok(_)) => {
// Удаляем из локального списка
app.td_client.current_chat_messages.retain(|m| m.id != msg_id);
app.selected_message_index = None;
}
Ok(Err(e)) => {
app.error_message = Some(e);
}
Err(_) => {
app.error_message = Some("Таймаут удаления".to_string());
}
}
}
}
app.confirm_delete_message_id = None;
}
KeyCode::Char('n') | KeyCode::Char('т') | KeyCode::Esc => {
// Отмена удаления
app.confirm_delete_message_id = None;
}
_ => {}
}
return;
}
// Режим выбора чата для пересылки
if app.is_forwarding() {
match key.code {
KeyCode::Esc => {
app.cancel_forward();
}
KeyCode::Enter => {
// Выбираем чат и пересылаем сообщение
let filtered = app.get_filtered_chats();
if let Some(i) = app.chat_list_state.selected() {
if let Some(chat) = filtered.get(i) {
let to_chat_id = chat.id;
if let Some(msg_id) = app.forwarding_message_id {
if let Some(from_chat_id) = app.get_selected_chat_id() {
match timeout(
Duration::from_secs(5),
app.td_client.forward_messages(to_chat_id, from_chat_id, vec![msg_id])
).await {
Ok(Ok(_)) => {
app.status_message = Some("Сообщение переслано".to_string());
}
Ok(Err(e)) => {
app.error_message = Some(e);
}
Err(_) => {
app.error_message = Some("Таймаут пересылки".to_string());
}
}
}
}
}
}
app.cancel_forward();
}
KeyCode::Down => {
app.next_chat();
}
KeyCode::Up => {
app.previous_chat();
}
_ => {}
}
return;
}
// Режим поиска
if app.is_searching {
match key.code {
@@ -37,9 +418,14 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
if let Some(chat_id) = app.get_selected_chat_id() {
app.status_message = Some("Загрузка сообщений...".to_string());
app.message_scroll_offset = 0;
match timeout(Duration::from_secs(5), app.td_client.get_chat_history(chat_id, 50)).await {
Ok(Ok(messages)) => {
app.current_messages = messages;
match timeout(Duration::from_secs(10), app.td_client.get_chat_history(chat_id, 100)).await {
Ok(Ok(_)) => {
// Загружаем недостающие reply info
let _ = timeout(Duration::from_secs(5), app.td_client.fetch_missing_reply_info()).await;
// Загружаем последнее закреплённое сообщение
let _ = timeout(Duration::from_secs(2), app.td_client.load_current_pinned_message(chat_id)).await;
// Загружаем черновик
app.load_draft();
app.status_message = None;
}
Ok(Err(e)) => {
@@ -74,79 +460,82 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
return;
}
// Cmd+j/k - навигация (работает и в списке чатов, и для скролла сообщений)
if has_super {
match key.code {
// Cmd+Down - вниз (следующий чат ИЛИ скролл вниз)
KeyCode::Down => {
if app.selected_chat_id.is_some() {
// В открытом чате - скролл вниз (к новым сообщениям)
if app.message_scroll_offset > 0 {
app.message_scroll_offset = app.message_scroll_offset.saturating_sub(3);
}
} else {
// В списке чатов - следующий чат
app.next_chat();
}
}
// Cmd+Up - вверх (предыдущий чат ИЛИ скролл вверх)
KeyCode::Up => {
if app.selected_chat_id.is_some() {
// В открытом чате - скролл вверх (к старым сообщениям)
app.message_scroll_offset += 3;
// Проверяем, нужно ли подгрузить старые сообщения
if !app.current_messages.is_empty() {
let oldest_msg_id = app.current_messages.first().map(|m| m.id).unwrap_or(0);
if let Some(chat_id) = app.get_selected_chat_id() {
// Подгружаем больше сообщений если скролл близко к верху
if app.message_scroll_offset > app.current_messages.len().saturating_sub(10) {
if let Ok(Ok(older)) = timeout(
Duration::from_secs(3),
app.td_client.load_older_messages(chat_id, oldest_msg_id, 20)
).await {
if !older.is_empty() {
// Добавляем старые сообщения в начало
let mut new_messages = older;
new_messages.extend(app.current_messages.drain(..));
app.current_messages = new_messages;
}
}
}
}
}
} else {
// В списке чатов - предыдущий чат
app.previous_chat();
}
}
_ => {}
}
return;
}
// Enter - открыть чат или отправить сообщение
// Enter - открыть чат, отправить сообщение или редактировать
if key.code == KeyCode::Enter {
if app.selected_chat_id.is_some() {
// Отправка сообщения
// Режим выбора сообщения
if app.is_selecting_message() {
// Начать редактирование выбранного сообщения
if app.start_editing_selected() {
// Редактирование начато
} else {
// Нельзя редактировать это сообщение
app.selected_message_index = None;
}
return;
}
// Отправка или редактирование сообщения
if !app.message_input.is_empty() {
if let Some(chat_id) = app.get_selected_chat_id() {
let text = app.message_input.clone();
app.message_input.clear();
match timeout(Duration::from_secs(5), app.td_client.send_message(chat_id, text.clone())).await {
Ok(Ok(sent_msg)) => {
// Добавляем отправленное сообщение в список
app.current_messages.push(sent_msg);
// Сбрасываем скролл чтобы видеть новое сообщение
app.message_scroll_offset = 0;
if let Some(msg_id) = app.editing_message_id {
// Режим редактирования
app.message_input.clear();
app.cursor_position = 0;
app.editing_message_id = None;
match timeout(Duration::from_secs(5), app.td_client.edit_message(chat_id, msg_id, text)).await {
Ok(Ok(edited_msg)) => {
// Обновляем сообщение в списке
if let Some(msg) = app.td_client.current_chat_messages.iter_mut().find(|m| m.id == msg_id) {
msg.content = edited_msg.content;
msg.entities = edited_msg.entities;
msg.edit_date = edited_msg.edit_date;
}
}
Ok(Err(e)) => {
app.error_message = Some(e);
}
Err(_) => {
app.error_message = Some("Таймаут редактирования".to_string());
}
}
Ok(Err(e)) => {
app.error_message = Some(e);
}
Err(_) => {
app.error_message = Some("Таймаут отправки".to_string());
} else {
// Обычная отправка (или reply)
let reply_to_id = app.replying_to_message_id;
// Создаём ReplyInfo ДО отправки, пока сообщение точно доступно
let reply_info = app.get_replying_to_message().map(|m| {
crate::tdlib::client::ReplyInfo {
message_id: m.id,
sender_name: m.sender_name.clone(),
text: m.content.clone(),
}
});
app.message_input.clear();
app.cursor_position = 0;
app.replying_to_message_id = None;
app.last_typing_sent = None;
// Отменяем typing status
app.td_client.send_chat_action(chat_id, ChatAction::Cancel).await;
match timeout(Duration::from_secs(5), app.td_client.send_message(chat_id, text, reply_to_id, reply_info)).await {
Ok(Ok(sent_msg)) => {
// Добавляем отправленное сообщение в список (с лимитом)
app.td_client.push_message(sent_msg);
// Сбрасываем скролл чтобы видеть новое сообщение
app.message_scroll_offset = 0;
}
Ok(Err(e)) => {
app.error_message = Some(e);
}
Err(_) => {
app.error_message = Some("Таймаут отправки".to_string());
}
}
}
}
@@ -160,9 +549,14 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
if let Some(chat_id) = app.get_selected_chat_id() {
app.status_message = Some("Загрузка сообщений...".to_string());
app.message_scroll_offset = 0;
match timeout(Duration::from_secs(5), app.td_client.get_chat_history(chat_id, 50)).await {
Ok(Ok(messages)) => {
app.current_messages = messages;
match timeout(Duration::from_secs(10), app.td_client.get_chat_history(chat_id, 100)).await {
Ok(Ok(_)) => {
// Загружаем недостающие reply info
let _ = timeout(Duration::from_secs(5), app.td_client.fetch_missing_reply_info()).await;
// Загружаем последнее закреплённое сообщение
let _ = timeout(Duration::from_secs(2), app.td_client.load_current_pinned_message(chat_id)).await;
// Загружаем черновик
app.load_draft();
app.status_message = None;
}
Ok(Err(e)) => {
@@ -180,22 +574,255 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
return;
}
// Esc - закрыть чат
// Esc - отменить выбор/редактирование/reply или закрыть чат
if key.code == KeyCode::Esc {
if app.selected_chat_id.is_some() {
if app.is_selecting_message() {
// Отменить выбор сообщения
app.selected_message_index = None;
} else if app.is_editing() {
// Отменить редактирование
app.cancel_editing();
} else if app.is_replying() {
// Отменить режим ответа
app.cancel_reply();
} else if app.selected_chat_id.is_some() {
// Сохраняем черновик если есть текст в инпуте
if let Some(chat_id) = app.selected_chat_id {
if !app.message_input.is_empty() && !app.is_editing() && !app.is_replying() {
let draft_text = app.message_input.clone();
let _ = app.td_client.set_draft_message(chat_id, draft_text).await;
} else if app.message_input.is_empty() {
// Очищаем черновик если инпут пустой
let _ = app.td_client.set_draft_message(chat_id, String::new()).await;
}
}
app.close_chat();
}
return;
}
// Ввод текста в режиме открытого чата
// Режим открытого чата
if app.selected_chat_id.is_some() {
// Режим выбора сообщения для редактирования/удаления
if app.is_selecting_message() {
match key.code {
KeyCode::Up => {
app.select_previous_message();
}
KeyCode::Down => {
app.select_next_message();
// Если вышли из режима выбора (индекс стал None), ничего не делаем
}
KeyCode::Char('d') | KeyCode::Char('в') | KeyCode::Delete => {
// Показать модалку подтверждения удаления
if let Some(msg) = app.get_selected_message() {
let can_delete = msg.can_be_deleted_only_for_self || msg.can_be_deleted_for_all_users;
if can_delete {
app.confirm_delete_message_id = Some(msg.id);
}
}
}
KeyCode::Char('r') | KeyCode::Char('к') => {
// Начать режим ответа на выбранное сообщение
app.start_reply_to_selected();
}
KeyCode::Char('f') | KeyCode::Char('а') => {
// Начать режим пересылки
app.start_forward_selected();
}
KeyCode::Char('y') | KeyCode::Char('н') => {
// Копировать сообщение
if let Some(msg) = app.get_selected_message() {
let text = format_message_for_clipboard(msg);
match copy_to_clipboard(&text) {
Ok(_) => {
app.status_message = Some("Сообщение скопировано".to_string());
}
Err(e) => {
app.error_message = Some(format!("Ошибка копирования: {}", e));
}
}
}
}
KeyCode::Char('e') | KeyCode::Char('у') => {
// Открыть emoji picker для добавления реакции
if let Some(msg) = app.get_selected_message() {
let chat_id = app.selected_chat_id.unwrap();
let message_id = msg.id;
app.status_message = Some("Загрузка реакций...".to_string());
app.needs_redraw = true;
// Запрашиваем доступные реакции
match timeout(
Duration::from_secs(5),
app.td_client.get_message_available_reactions(chat_id, message_id)
).await {
Ok(Ok(reactions)) => {
if reactions.is_empty() {
app.error_message = Some("Реакции недоступны для этого сообщения".to_string());
app.status_message = None;
app.needs_redraw = true;
} else {
app.enter_reaction_picker_mode(message_id, reactions);
app.status_message = None;
app.needs_redraw = true;
}
}
Ok(Err(e)) => {
app.error_message = Some(format!("Ошибка загрузки реакций: {}", e));
app.status_message = None;
app.needs_redraw = true;
}
Err(_) => {
app.error_message = Some("Таймаут загрузки реакций".to_string());
app.status_message = None;
app.needs_redraw = true;
}
}
}
}
_ => {}
}
return;
}
// Ctrl+U для профиля
if key.code == KeyCode::Char('u') && has_ctrl {
if let Some(chat_id) = app.selected_chat_id {
app.status_message = Some("Загрузка профиля...".to_string());
match timeout(Duration::from_secs(5), app.td_client.get_profile_info(chat_id)).await {
Ok(Ok(profile)) => {
app.profile_info = Some(profile);
app.enter_profile_mode();
app.status_message = None;
}
Ok(Err(e)) => {
app.error_message = Some(e);
app.status_message = None;
}
Err(_) => {
app.error_message = Some("Таймаут загрузки профиля".to_string());
app.status_message = None;
}
}
}
return;
}
match key.code {
KeyCode::Backspace => {
app.message_input.pop();
// Удаляем символ слева от курсора
if app.cursor_position > 0 {
let chars: Vec<char> = app.message_input.chars().collect();
let mut new_input = String::new();
for (i, ch) in chars.iter().enumerate() {
if i != app.cursor_position - 1 {
new_input.push(*ch);
}
}
app.message_input = new_input;
app.cursor_position -= 1;
}
}
KeyCode::Delete => {
// Удаляем символ справа от курсора
let len = app.message_input.chars().count();
if app.cursor_position < len {
let chars: Vec<char> = app.message_input.chars().collect();
let mut new_input = String::new();
for (i, ch) in chars.iter().enumerate() {
if i != app.cursor_position {
new_input.push(*ch);
}
}
app.message_input = new_input;
}
}
KeyCode::Char(c) => {
app.message_input.push(c);
// Вставляем символ в позицию курсора
let chars: Vec<char> = app.message_input.chars().collect();
let mut new_input = String::new();
for (i, ch) in chars.iter().enumerate() {
if i == app.cursor_position {
new_input.push(c);
}
new_input.push(*ch);
}
if app.cursor_position >= chars.len() {
new_input.push(c);
}
app.message_input = new_input;
app.cursor_position += 1;
// Отправляем typing status с throttling (не чаще 1 раза в 5 сек)
let should_send_typing = app.last_typing_sent
.map(|t| t.elapsed().as_secs() >= 5)
.unwrap_or(true);
if should_send_typing {
if let Some(chat_id) = app.get_selected_chat_id() {
app.td_client.send_chat_action(chat_id, ChatAction::Typing).await;
app.last_typing_sent = Some(Instant::now());
}
}
}
KeyCode::Left => {
// Курсор влево
if app.cursor_position > 0 {
app.cursor_position -= 1;
}
}
KeyCode::Right => {
// Курсор вправо
let len = app.message_input.chars().count();
if app.cursor_position < len {
app.cursor_position += 1;
}
}
KeyCode::Home => {
// Курсор в начало
app.cursor_position = 0;
}
KeyCode::End => {
// Курсор в конец
app.cursor_position = app.message_input.chars().count();
}
// Стрелки вверх/вниз - скролл сообщений или начало выбора
KeyCode::Down => {
// Скролл вниз (к новым сообщениям)
if app.message_scroll_offset > 0 {
app.message_scroll_offset = app.message_scroll_offset.saturating_sub(3);
}
}
KeyCode::Up => {
// Если инпут пустой и не в режиме редактирования — начать выбор сообщения
if app.message_input.is_empty() && !app.is_editing() {
app.start_message_selection();
} else {
// Скролл вверх (к старым сообщениям)
app.message_scroll_offset += 3;
// Проверяем, нужно ли подгрузить старые сообщения
if !app.td_client.current_chat_messages.is_empty() {
let oldest_msg_id = app.td_client.current_chat_messages.first().map(|m| m.id).unwrap_or(0);
if let Some(chat_id) = app.get_selected_chat_id() {
// Подгружаем больше сообщений если скролл близко к верху
if app.message_scroll_offset > app.td_client.current_chat_messages.len().saturating_sub(10) {
if let Ok(Ok(older)) = timeout(
Duration::from_secs(3),
app.td_client.load_older_messages(chat_id, oldest_msg_id, 20)
).await {
if !older.is_empty() {
// Добавляем старые сообщения в начало
let mut new_messages = older;
new_messages.extend(app.td_client.current_chat_messages.drain(..));
app.td_client.current_chat_messages = new_messages;
}
}
}
}
}
}
}
_ => {}
}
@@ -208,14 +835,131 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
KeyCode::Up => {
app.previous_chat();
}
// Цифры - переключение папок
// Цифры 1-9 - переключение папок
KeyCode::Char(c) if c >= '1' && c <= '9' => {
let folder_idx = (c as usize) - ('1' as usize);
if folder_idx < app.folders.len() {
app.selected_folder = folder_idx;
let folder_num = (c as usize) - ('1' as usize); // 0-based
if folder_num == 0 {
// 1 = All
app.selected_folder_id = None;
} else {
// 2, 3, 4... = папки из TDLib
if let Some(folder) = app.td_client.folders.get(folder_num - 1) {
let folder_id = folder.id;
app.selected_folder_id = Some(folder_id);
// Загружаем чаты папки
app.status_message = Some("Загрузка чатов папки...".to_string());
let _ = timeout(Duration::from_secs(5), app.td_client.load_folder_chats(folder_id, 50)).await;
app.status_message = None;
}
}
app.chat_list_state.select(Some(0));
}
_ => {}
}
}
}
/// Подсчёт количества доступных действий в профиле
fn get_available_actions_count(profile: &crate::tdlib::ProfileInfo) -> usize {
let mut count = 0;
if profile.username.is_some() {
count += 1; // Открыть в браузере
}
count += 1; // Скопировать ID
if profile.is_group {
count += 1; // Покинуть группу
}
count
}
/// Копирует текст в системный буфер обмена
fn copy_to_clipboard(text: &str) -> Result<(), String> {
use arboard::Clipboard;
let mut clipboard = Clipboard::new().map_err(|e| format!("Не удалось инициализировать буфер обмена: {}", e))?;
clipboard.set_text(text).map_err(|e| format!("Не удалось скопировать: {}", e))?;
Ok(())
}
/// Форматирует сообщение для копирования с контекстом
fn format_message_for_clipboard(msg: &crate::tdlib::client::MessageInfo) -> String {
let mut result = String::new();
// Добавляем forward контекст если есть
if let Some(forward) = &msg.forward_from {
result.push_str(&format!("↪ Переслано от {}\n", forward.sender_name));
}
// Добавляем reply контекст если есть
if let Some(reply) = &msg.reply_to {
result.push_str(&format!("{}: {}\n", reply.sender_name, reply.text));
}
// Добавляем основной текст с markdown форматированием
result.push_str(&convert_entities_to_markdown(&msg.content, &msg.entities));
result
}
/// Конвертирует текст с entities в markdown
fn convert_entities_to_markdown(text: &str, entities: &[tdlib_rs::types::TextEntity]) -> String {
use tdlib_rs::enums::TextEntityType;
if entities.is_empty() {
return text.to_string();
}
// Создаём вектор символов для работы с unicode
let chars: Vec<char> = text.chars().collect();
let mut result = String::new();
let mut i = 0;
while i < chars.len() {
// Ищем entity, который начинается в текущей позиции
let mut entity_found = false;
for entity in entities {
if entity.offset as usize == i {
entity_found = true;
let end = (entity.offset + entity.length) as usize;
let entity_text: String = chars[i..end.min(chars.len())].iter().collect();
// Применяем форматирование в зависимости от типа
let formatted = match &entity.r#type {
TextEntityType::Bold => format!("**{}**", entity_text),
TextEntityType::Italic => format!("*{}*", entity_text),
TextEntityType::Underline => format!("__{}__", entity_text),
TextEntityType::Strikethrough => format!("~~{}~~", entity_text),
TextEntityType::Code | TextEntityType::Pre | TextEntityType::PreCode(_) => {
format!("`{}`", entity_text)
}
TextEntityType::TextUrl(url_info) => {
format!("[{}]({})", entity_text, url_info.url)
}
TextEntityType::Url => format!("<{}>", entity_text),
TextEntityType::Mention | TextEntityType::MentionName(_) => {
format!("@{}", entity_text.trim_start_matches('@'))
}
TextEntityType::Spoiler => format!("||{}||", entity_text),
_ => entity_text,
};
result.push_str(&formatted);
i = end;
break;
}
}
if !entity_found {
result.push(chars[i]);
i += 1;
}
}
result
}

View File

@@ -1,4 +1,5 @@
mod app;
mod config;
mod input;
mod tdlib;
mod ui;
@@ -11,6 +12,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;
@@ -24,6 +27,9 @@ async fn main() -> Result<(), io::Error> {
// Загружаем переменные окружения из .env
let _ = dotenvy::dotenv();
// Загружаем конфигурацию (создаёт дефолтный если отсутствует)
let config = config::Config::load();
// Отключаем логи TDLib ДО создания клиента
disable_tdlib_logs();
@@ -35,7 +41,7 @@ async fn main() -> Result<(), io::Error> {
let mut terminal = Terminal::new(backend)?;
// Create app state
let mut app = App::new();
let mut app = App::new(config);
let res = run_app(&mut terminal, &mut app).await;
// Restore terminal
@@ -58,16 +64,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,39 +112,95 @@ 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;
}
// Очищаем устаревший typing status
if app.td_client.clear_stale_typing_status() {
app.needs_redraw = true;
}
// Обрабатываем очередь сообщений для отметки как прочитанных
if !app.td_client.pending_view_messages.is_empty() {
app.td_client.process_pending_view_messages().await;
}
// Обрабатываем очередь user_id для загрузки имён
if !app.td_client.pending_user_ids.is_empty() {
app.td_client.process_pending_user_ids().await;
}
// Обновляем состояние экрана на основе 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 => {
@@ -173,4 +241,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
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,7 @@
pub mod client;
pub use client::TdClient;
pub use client::UserOnlineStatus;
pub use client::NetworkState;
pub use client::ProfileInfo;
pub use tdlib_rs::enums::ChatAction;

View File

@@ -5,6 +5,7 @@ use ratatui::{
Frame,
};
use crate::app::App;
use crate::tdlib::UserOnlineStatus;
pub fn render(f: &mut Frame, area: Rect, app: &mut App) {
let chat_chunks = Layout::default()
@@ -42,27 +43,65 @@ pub fn render(f: &mut Frame, area: Rect, app: &mut App) {
.iter()
.map(|chat| {
let is_selected = app.selected_chat_id == Some(chat.id);
let prefix = if is_selected { " " } else { " " };
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) {
Some(UserOnlineStatus::Online) => "",
_ => " ",
};
let prefix = if is_selected { "" } else { " " };
let username_text = chat.username.as_ref()
.map(|u| format!(" {}", u))
.unwrap_or_default();
// Индикатор упоминаний @
let mention_badge = if chat.unread_mention_count > 0 {
" @".to_string()
} else {
String::new()
};
// Индикатор черновика ✎
let draft_badge = if chat.draft_text.is_some() {
"".to_string()
} else {
String::new()
};
let unread_badge = if chat.unread_count > 0 {
format!(" ({})", chat.unread_count)
} else {
String::new()
};
let content = format!("{}{}{}{}", prefix, chat.title, username_text, unread_badge);
let style = Style::default().fg(Color::White);
let content = format!("{}{}{}{}{}{}{}{}{}", prefix, status_icon, pin_icon, mute_icon, chat.title, username_text, mention_badge, draft_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),
};
ListItem::new(content).style(style)
})
.collect();
// Заголовок блока: обычный или режим пересылки
let block = if app.is_forwarding() {
Block::default()
.title(" ↪ Выберите чат ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan))
} else {
Block::default().borders(Borders::ALL)
};
let chats_list = List::new(items)
.block(Block::default().borders(Borders::ALL))
.block(block)
.highlight_style(
Style::default()
.add_modifier(Modifier::ITALIC)
@@ -71,9 +110,52 @@ pub fn render(f: &mut Frame, area: Rect, app: &mut App) {
f.render_stateful_widget(chats_list, chat_chunks[1], &mut app.chat_list_state);
// User status
let status = Paragraph::new("[User: Online]")
// User status - показываем статус выбранного чата
let (status_text, status_color) = if let Some(chat_id) = app.selected_chat_id {
match app.td_client.get_user_status_by_chat_id(chat_id) {
Some(UserOnlineStatus::Online) => ("● онлайн".to_string(), Color::Green),
Some(UserOnlineStatus::Recently) => ("был(а) недавно".to_string(), Color::Yellow),
Some(UserOnlineStatus::Offline(was_online)) => {
let formatted = format_was_online(*was_online);
(formatted, Color::Gray)
}
Some(UserOnlineStatus::LastWeek) => ("был(а) на этой неделе".to_string(), Color::DarkGray),
Some(UserOnlineStatus::LastMonth) => ("был(а) в этом месяце".to_string(), Color::DarkGray),
Some(UserOnlineStatus::LongTimeAgo) => ("был(а) давно".to_string(), Color::DarkGray),
None => ("".to_string(), Color::DarkGray), // Для групп/каналов
}
} else {
// Показываем статус выделенного в списке чата
let filtered = app.get_filtered_chats();
if let Some(i) = app.chat_list_state.selected() {
if let Some(chat) = filtered.get(i) {
match app.td_client.get_user_status_by_chat_id(chat.id) {
Some(UserOnlineStatus::Online) => ("● онлайн".to_string(), Color::Green),
Some(UserOnlineStatus::Recently) => ("был(а) недавно".to_string(), Color::Yellow),
Some(UserOnlineStatus::Offline(was_online)) => {
let formatted = format_was_online(*was_online);
(formatted, Color::Gray)
}
Some(UserOnlineStatus::LastWeek) => ("был(а) на этой неделе".to_string(), Color::DarkGray),
Some(UserOnlineStatus::LastMonth) => ("был(а) в этом месяце".to_string(), Color::DarkGray),
Some(UserOnlineStatus::LongTimeAgo) => ("был(а) давно".to_string(), Color::DarkGray),
None => ("".to_string(), Color::DarkGray),
}
} else {
("".to_string(), Color::DarkGray)
}
} else {
("".to_string(), Color::DarkGray)
}
};
let status = Paragraph::new(status_text)
.block(Block::default().borders(Borders::ALL))
.style(Style::default().fg(Color::Green));
.style(Style::default().fg(status_color));
f.render_widget(status, chat_chunks[2]);
}
/// Форматирование времени "был(а) в ..."
fn format_was_online(timestamp: i32) -> String {
crate::utils::format_was_online(timestamp)
}

View File

@@ -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 | Ctrl+U: Profile | 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)

View File

@@ -8,7 +8,13 @@ use ratatui::{
use crate::app::App;
use super::{chat_list, messages, footer};
/// Порог ширины для компактного режима (одна панель)
const COMPACT_WIDTH: u16 = 80;
pub fn render(f: &mut Frame, app: &mut App) {
let area = f.area();
let is_compact = area.width < COMPACT_WIDTH;
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
@@ -16,39 +22,62 @@ pub fn render(f: &mut Frame, app: &mut App) {
Constraint::Min(0), // Main content
Constraint::Length(1), // Commands footer
])
.split(f.area());
.split(area);
render_folders(f, chunks[0], app);
let main_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(30), // Chat list
Constraint::Percentage(70), // Messages area
])
.split(chunks[1]);
if is_compact {
// Компактный режим: показываем либо список чатов, либо открытый чат
if app.selected_chat_id.is_some() {
// Чат открыт — показываем только сообщения
messages::render(f, chunks[1], app);
} else {
// Чат не открыт — показываем только список чатов
chat_list::render(f, chunks[1], app);
}
} else {
// Обычный режим: две панели
let main_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(30), // Chat list
Constraint::Percentage(70), // Messages area
])
.split(chunks[1]);
chat_list::render(f, main_chunks[0], app);
messages::render(f, main_chunks[1], app);
}
chat_list::render(f, main_chunks[0], app);
messages::render(f, main_chunks[1], app);
footer::render(f, chunks[2], app);
}
fn render_folders(f: &mut Frame, area: Rect, app: &App) {
let mut spans = vec![];
for (i, folder) in app.folders.iter().enumerate() {
let style = if i == app.selected_folder {
// "All" всегда первая (клавиша 1)
let all_style = if app.selected_folder_id.is_none() {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
spans.push(Span::styled(" 1:All ", all_style));
// Папки из TDLib (клавиши 2, 3, 4...)
for (i, folder) in app.td_client.folders.iter().enumerate() {
spans.push(Span::raw(""));
let style = if app.selected_folder_id == Some(folder.id) {
Style::default()
.fg(Color::Cyan)
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
spans.push(Span::styled(format!(" {}:{} ", i + 1, folder), style));
if i < app.folders.len() - 1 {
spans.push(Span::raw(""));
}
spans.push(Span::styled(format!(" {}:{} ", i + 2, folder.name), style));
}
let folders_line = Line::from(spans);

File diff suppressed because it is too large Load Diff

View File

@@ -4,14 +4,42 @@ mod main_screen;
mod chat_list;
mod messages;
mod footer;
pub mod profile;
use ratatui::Frame;
use ratatui::layout::Alignment;
use ratatui::style::{Color, Modifier, Style};
use ratatui::widgets::Paragraph;
use crate::app::{App, AppScreen};
/// Минимальная высота терминала
const MIN_HEIGHT: u16 = 10;
/// Минимальная ширина терминала
const MIN_WIDTH: u16 = 40;
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Минимум: {}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());
}

259
src/ui/profile.rs Normal file
View File

@@ -0,0 +1,259 @@
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
Frame,
};
use crate::app::App;
use crate::tdlib::client::ProfileInfo;
/// Рендерит режим просмотра профиля
pub fn render(f: &mut Frame, area: Rect, app: &App, profile: &ProfileInfo) {
// Проверяем, показывать ли модалку подтверждения
let confirmation_step = app.get_leave_group_confirmation_step();
if confirmation_step > 0 {
render_leave_confirmation_modal(f, area, confirmation_step);
return;
}
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Header
Constraint::Min(0), // Profile info
Constraint::Length(3), // Actions help
])
.split(area);
// Header
let header_text = format!("👤 ПРОФИЛЬ: {}", profile.title);
let header = Paragraph::new(header_text)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan))
)
.style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD));
f.render_widget(header, chunks[0]);
// Profile info
let mut lines: Vec<Line> = Vec::new();
// Тип чата
lines.push(Line::from(vec![
Span::styled("Тип: ", Style::default().fg(Color::Gray)),
Span::styled(&profile.chat_type, Style::default().fg(Color::White)),
]));
lines.push(Line::from(""));
// ID
lines.push(Line::from(vec![
Span::styled("ID: ", Style::default().fg(Color::Gray)),
Span::styled(format!("{}", profile.chat_id), Style::default().fg(Color::White)),
]));
lines.push(Line::from(""));
// Username
if let Some(username) = &profile.username {
lines.push(Line::from(vec![
Span::styled("Username: ", Style::default().fg(Color::Gray)),
Span::styled(username, Style::default().fg(Color::Cyan)),
]));
lines.push(Line::from(""));
}
// Phone number (только для личных чатов)
if let Some(phone) = &profile.phone_number {
lines.push(Line::from(vec![
Span::styled("Телефон: ", Style::default().fg(Color::Gray)),
Span::styled(phone, Style::default().fg(Color::White)),
]));
lines.push(Line::from(""));
}
// Online status (только для личных чатов)
if let Some(status) = &profile.online_status {
lines.push(Line::from(vec![
Span::styled("Статус: ", Style::default().fg(Color::Gray)),
Span::styled(status, Style::default().fg(Color::Green)),
]));
lines.push(Line::from(""));
}
// Bio (только для личных чатов)
if let Some(bio) = &profile.bio {
lines.push(Line::from(vec![
Span::styled("О себе: ", Style::default().fg(Color::Gray)),
]));
// Разбиваем bio на строки если длинное
let bio_lines: Vec<&str> = bio.lines().collect();
for bio_line in bio_lines {
lines.push(Line::from(Span::styled(bio_line, Style::default().fg(Color::White))));
}
lines.push(Line::from(""));
}
// Member count (для групп/каналов)
if let Some(count) = profile.member_count {
lines.push(Line::from(vec![
Span::styled("Участников: ", Style::default().fg(Color::Gray)),
Span::styled(format!("{}", count), Style::default().fg(Color::White)),
]));
lines.push(Line::from(""));
}
// Description (для групп/каналов)
if let Some(desc) = &profile.description {
lines.push(Line::from(vec![
Span::styled("Описание: ", Style::default().fg(Color::Gray)),
]));
let desc_lines: Vec<&str> = desc.lines().collect();
for desc_line in desc_lines {
lines.push(Line::from(Span::styled(desc_line, Style::default().fg(Color::White))));
}
lines.push(Line::from(""));
}
// Invite link (для групп/каналов)
if let Some(link) = &profile.invite_link {
lines.push(Line::from(vec![
Span::styled("Ссылка: ", Style::default().fg(Color::Gray)),
Span::styled(link, Style::default().fg(Color::Blue).add_modifier(Modifier::UNDERLINED)),
]));
lines.push(Line::from(""));
}
// Разделитель
lines.push(Line::from("────────────────────────────────"));
lines.push(Line::from(""));
// Действия
lines.push(Line::from(Span::styled(
"Действия:",
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(""));
let actions = get_available_actions(profile);
for (idx, action) in actions.iter().enumerate() {
let is_selected = idx == app.selected_profile_action;
let marker = if is_selected { "" } else { " " };
let style = if is_selected {
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
lines.push(Line::from(vec![
Span::styled(marker, Style::default().fg(Color::Yellow)),
Span::styled(*action, style),
]));
}
let info_widget = Paragraph::new(lines)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan))
)
.scroll((0, 0));
f.render_widget(info_widget, chunks[1]);
// Help bar
let help_line = Line::from(vec![
Span::styled(" ↑↓ ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Span::raw("навигация"),
Span::raw(" "),
Span::styled(" Enter ", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)),
Span::raw("выбрать"),
Span::raw(" "),
Span::styled(" Esc ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
Span::raw("выход"),
]);
let help = Paragraph::new(help_line)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan))
)
.alignment(Alignment::Center);
f.render_widget(help, chunks[2]);
}
/// Получить список доступных действий
fn get_available_actions(profile: &ProfileInfo) -> Vec<&'static str> {
let mut actions = vec![];
if profile.username.is_some() {
actions.push("Открыть в браузере");
}
actions.push("Скопировать ID");
if profile.is_group {
actions.push("Покинуть группу");
}
actions
}
/// Рендерит модалку подтверждения выхода из группы
fn render_leave_confirmation_modal(f: &mut Frame, area: Rect, step: u8) {
// Затемняем фон
let modal_area = centered_rect(60, 30, area);
let text = if step == 1 {
"Вы хотите выйти из группы?"
} else {
"Вы ТОЧНО хотите выйти из группы?!?!?"
};
let lines = vec![
Line::from(""),
Line::from(Span::styled(
text,
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(""),
Line::from(vec![
Span::styled("y/н/Enter", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)),
Span::raw(" — да "),
Span::styled("n/т/Esc", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
Span::raw(" — нет"),
]),
];
let modal = Paragraph::new(lines)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Red))
.title(" ⚠ ВНИМАНИЕ ")
.title_style(Style::default().fg(Color::Red).add_modifier(Modifier::BOLD))
)
.alignment(Alignment::Center);
f.render_widget(modal, modal_area);
}
/// Вспомогательная функция для центрирования прямоугольника
fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
let popup_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage((100 - percent_y) / 2),
Constraint::Percentage(percent_y),
Constraint::Percentage((100 - percent_y) / 2),
])
.split(r);
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
])
.split(popup_layout[1])[1]
}

View File

@@ -22,8 +22,117 @@ pub fn disable_tdlib_logs() {
}
}
/// Форматирование timestamp в человекочитаемый формат
/// Форматирование timestamp в время HH:MM с учётом timezone offset
/// timezone_str: строка формата "+03:00" или "-05:00"
pub fn format_timestamp_with_tz(timestamp: i32, timezone_str: &str) -> String {
let secs = timestamp as i64;
// Парсим timezone offset (например "+03:00" -> 3 часа)
let offset_hours = parse_timezone_offset(timezone_str);
let hours = ((secs % 86400) / 3600) as i32;
let minutes = ((secs % 3600) / 60) as u32;
// Применяем timezone offset
let local_hours = ((hours + offset_hours) % 24 + 24) % 24;
format!("{:02}:{:02}", local_hours, minutes)
}
/// Парсит timezone строку типа "+03:00" в количество часов
fn parse_timezone_offset(tz: &str) -> i32 {
// Простой парсинг "+03:00" или "-05:00"
if tz.len() >= 3 {
let sign = if tz.starts_with('-') { -1 } else { 1 };
let hours_str = &tz[1..3];
if let Ok(hours) = hours_str.parse::<i32>() {
return sign * hours;
}
}
3 // fallback к MSK
}
/// Устаревшая функция для обратной совместимости (используется дефолтный +03:00)
#[allow(dead_code)]
pub fn format_timestamp(timestamp: i32) -> String {
format_timestamp_with_tz(timestamp, "+03:00")
}
/// Форматирование timestamp в дату для разделителя
pub fn format_date(timestamp: i32) -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
let msg_day = timestamp as i64 / 86400;
let today = now / 86400;
if msg_day == today {
"Сегодня".to_string()
} else if msg_day == today - 1 {
"Вчера".to_string()
} else {
// Простое форматирование даты
let days_since_epoch = timestamp as i64 / 86400;
// Приблизительный расчёт даты (без учёта високосных годов)
let year = 1970 + (days_since_epoch / 365) as i32;
let day_of_year = days_since_epoch % 365;
let months = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
let mut month = 0;
let mut day = day_of_year as i32;
for (i, &m) in months.iter().enumerate() {
if day < m {
month = i + 1;
break;
}
day -= m;
}
format!("{:02}.{:02}.{}", day + 1, month, year)
}
}
/// Получить день из timestamp для группировки
pub fn get_day(timestamp: i32) -> i64 {
timestamp as i64 / 86400
}
/// Форматирование timestamp в полную дату и время (DD.MM.YYYY HH:MM)
pub fn format_datetime(timestamp: i32) -> String {
let secs = timestamp as i64;
// Время
let hours = ((secs % 86400) / 3600) as u32;
let minutes = ((secs % 3600) / 60) as u32;
let local_hours = (hours + 3) % 24; // MSK
// Дата
let days_since_epoch = secs / 86400;
let year = 1970 + (days_since_epoch / 365) as i32;
let day_of_year = days_since_epoch % 365;
let months = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
let mut month = 1;
let mut day = day_of_year as i32;
for (i, &m) in months.iter().enumerate() {
if day < m {
month = i + 1;
break;
}
day -= m;
}
format!("{:02}.{:02}.{} {:02}:{:02}", day + 1, month, year, local_hours, minutes)
}
/// Форматирование "был(а) онлайн" из timestamp
pub fn format_was_online(timestamp: i32) -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let now = SystemTime::now()
@@ -34,14 +143,18 @@ pub fn format_timestamp(timestamp: i32) -> String {
let diff = now - timestamp;
if diff < 60 {
"just now".to_string()
"был(а) только что".to_string()
} else if diff < 3600 {
format!("{}m ago", diff / 60)
let mins = diff / 60;
format!("был(а) {} мин. назад", mins)
} else if diff < 86400 {
format!("{}h ago", diff / 3600)
let hours = diff / 3600;
format!("был(а) {} ч. назад", hours)
} else {
let secs = timestamp as u64;
let days = secs / 86400;
format!("{}d ago", (now as u64 / 86400) - days)
// Показываем дату
let datetime = chrono::DateTime::from_timestamp(timestamp as i64, 0)
.map(|dt| dt.format("%d.%m %H:%M").to_string())
.unwrap_or_else(|| "давно".to_string());
format!("был(а) {}", datetime)
}
}