diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..0a5a199 --- /dev/null +++ b/.editorconfig @@ -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 diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..e4acc8b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -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] + +## Логи +Если есть логи или сообщения об ошибках, вставьте их сюда: +``` +вставьте логи здесь +``` + +## Дополнительный контекст +Любая другая информация, которая может помочь в решении проблемы. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..c5702cd --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -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) и этой функции там нет + +## Дополнительный контекст +Скриншоты, ссылки на похожие реализации в других приложениях, и т.д. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..caaf515 --- /dev/null +++ b/.github/pull_request_template.md @@ -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 изменений. + +## Дополнительные заметки + +Любая дополнительная информация для ревьюверов. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f3369cd --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore index bb9e215..fd2f35f 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,7 @@ # Environment variables (contains API keys) .env .DS_Store + +# Local config files (if created in project root) +config.toml +credentials diff --git a/.serena/.gitignore b/.serena/.gitignore new file mode 100644 index 0000000..14d86ad --- /dev/null +++ b/.serena/.gitignore @@ -0,0 +1 @@ +/cache diff --git a/.serena/project.yml b/.serena/project.yml new file mode 100644 index 0000000..33722ad --- /dev/null +++ b/.serena/project.yml @@ -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: [] diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..712a3c5 --- /dev/null +++ b/CHANGELOG.md @@ -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 diff --git a/CONTEXT.md b/CONTEXT.md index be71814..54c0ceb 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -1,6 +1,6 @@ # Текущий контекст проекта -## Статус: Фаза 9 — Расширенные возможности +## Статус: Фаза 9 — ЗАВЕРШЕНО ### Что сделано @@ -38,6 +38,25 @@ - **Индикатор редактирования**: ✎ рядом с временем для отредактированных сообщений - **Новые сообщения в реальном времени** при открытом чате - **Поиск по чатам** (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] и др. @@ -88,6 +107,14 @@ - `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` — курсор в начало @@ -100,6 +127,7 @@ ``` src/ ├── main.rs # Точка входа, event loop, TDLib инициализация, graceful shutdown +├── config.rs # Конфигурация (TOML), загрузка credentials ├── app/ │ ├── mod.rs # App структура и состояние (needs_redraw флаг) │ └── state.rs # AppScreen enum @@ -115,10 +143,10 @@ src/ │ ├── mod.rs # Роутинг ввода │ ├── auth.rs # Обработка ввода на экране авторизации │ └── main_input.rs # Обработка ввода на главном экране -├── utils.rs # Утилиты (disable_tdlib_logs, format_timestamp, format_date, get_day) +├── utils.rs # Утилиты (disable_tdlib_logs, format_timestamp_with_tz, format_date, get_day) └── tdlib/ ├── mod.rs # Модуль экспорта (TdClient, UserOnlineStatus, NetworkState) - └── client.rs # TdClient: авторизация, чаты, сообщения, кеш, NetworkState + └── client.rs # TdClient: авторизация, чаты, сообщения, кеш, NetworkState, ReactionInfo ``` ### Ключевые решения @@ -141,6 +169,10 @@ src/ 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` для каждого сообщения. Обновляются в реальном времени через `Update::MessageInteractionInfo`. Emoji picker использует сетку 8x6 с навигацией стрелками. Приоритет обработки ввода: reaction picker → delete confirmation → остальные модалки (важно для корректной работы Enter/Esc). + ### Зависимости (Cargo.toml) ```toml @@ -152,27 +184,62 @@ 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 ``` -## Что НЕ сделано / TODO (Фаза 9) +2. **Переменные окружения** (`.env` файл в текущей директории): +``` +API_ID=your_api_id +API_HASH=your_api_hash +``` -- [ ] Typing indicator ("печатает...") -- [ ] Закреплённые сообщения (Pinned) — отображение вверху чата -- [ ] Поиск по сообщениям в чате (Ctrl+F) -- [ ] Черновики — сохранение текста при переключении чатов -- [ ] Профиль пользователя/чата (хоткей `i`) -- [ ] Копирование сообщений в буфер обмена (`y` в режиме выбора) -- [ ] Реакции — просмотр и добавление -- [ ] Конфигурационный файл (~/.config/tele-tui/config.toml) +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 + +Все пункты Фазы 9 завершены! Можно переходить к следующей фазе разработки. ## Известные проблемы 1. При первом запуске нужно пройти авторизацию -2. Время отображается с фиксированным смещением +3 (MSK) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..b452d5c --- /dev/null +++ b/CONTRIBUTING.md @@ -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/) +- Добавляйте комментарии для сложной логики + +### Структура коммитов + +``` +: <краткое описание> + +<подробное описание (опционально)> +``` + +Типы: +- `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). diff --git a/Cargo.lock b/Cargo.lock index 9e70428..f420f16 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", @@ -170,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" @@ -270,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" @@ -387,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]] @@ -404,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" @@ -462,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" @@ -580,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" @@ -624,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" @@ -919,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" @@ -989,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" @@ -1137,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" @@ -1156,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" @@ -1169,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" @@ -1254,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" @@ -1288,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" @@ -1305,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", ] @@ -1357,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" @@ -1365,7 +1671,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -1620,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" @@ -1731,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", @@ -1843,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", @@ -1875,14 +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]] @@ -1900,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", @@ -1919,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", @@ -1935,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", @@ -2020,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" @@ -2260,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" @@ -2352,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" @@ -2388,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" @@ -2421,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" @@ -2433,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" @@ -2445,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" @@ -2469,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" @@ -2481,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" @@ -2493,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" @@ -2505,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" @@ -2517,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" @@ -2529,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" @@ -2561,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" @@ -2657,7 +3169,7 @@ dependencies = [ "memchr", "pbkdf2", "sha1", - "thiserror", + "thiserror 2.0.18", "time", "xz2", "zeroize", @@ -2667,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" @@ -2710,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", +] diff --git a/Cargo.toml b/Cargo.toml index 2a2f65f..809d884 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,12 @@ name = "tele-tui" version = "0.1.0" edition = "2021" +authors = ["Your Name "] +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" @@ -12,6 +18,10 @@ 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"] } diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 8380470..af25ffd 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -99,4 +99,7 @@ cargo run 5. Ждёт фидбек 6. Переходит к следующему этапу -``` \ No newline at end of file +``` + +## Работа с git +- никогда не добавляй себя в соавторов в тексте коммита \ No newline at end of file diff --git a/FAQ.md b/FAQ.md new file mode 100644 index 0000000..1bbfac1 --- /dev/null +++ b/FAQ.md @@ -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. diff --git a/HOTKEYS.md b/HOTKEYS.md new file mode 100644 index 0000000..df67c01 --- /dev/null +++ b/HOTKEYS.md @@ -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` всегда отменяет текущее действие и возвращает на шаг назад +- Блочный курсор █ показывает текущую позицию при редактировании текста diff --git a/INSTALL.md b/INSTALL.md new file mode 100644 index 0000000..776834a --- /dev/null +++ b/INSTALL.md @@ -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/ +``` diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..dadce1d --- /dev/null +++ b/LICENSE @@ -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. diff --git a/PROJECT_STRUCTURE.md b/PROJECT_STRUCTURE.md new file mode 100644 index 0000000..93c94e6 --- /dev/null +++ b/PROJECT_STRUCTURE.md @@ -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, + folders: Vec, + + // UI state + selected_chat_id: Option, + input_text: String, + cursor_position: usize, + + // Modals + is_delete_confirmation: bool, + is_reaction_picker_mode: bool, + profile_info: Option, + + // Search + search_query: String, + search_results: Vec, + + // Drafts + drafts: HashMap, +} +``` + +## Потоки выполнения + +### 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/` diff --git a/README.md b/README.md index b766576..2f7365b 100644 --- a/README.md +++ b/README.md @@ -1 +1,163 @@ -telegram-tui консольный телеграм \ No newline at end of file +# 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 diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index 9e1278b..d423c71 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -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 файлов ## Нефункциональные требования diff --git a/ROADMAP.md b/ROADMAP.md index ae967e1..c1e6d0d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -112,34 +112,34 @@ - Esc для отмены - Отображение "↪ Переслано от" для пересланных сообщений -## Фаза 9: Расширенные возможности [TODO] +## Фаза 9: Расширенные возможности [DONE] -- [ ] Typing indicator ("печатает...") +- [x] Typing indicator ("печатает...") - Показывать когда собеседник печатает - Отправлять свой статус печати при наборе текста -- [ ] Закреплённые сообщения (Pinned) +- [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` - Настройки: цветовая схема, часовой пояс, хоткеи - Загрузка конфига при старте diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..8b8d966 --- /dev/null +++ b/SECURITY.md @@ -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 недели +- **Низкие**: включаются в следующий релиз + +## Спасибо + +Мы ценим ваш вклад в безопасность проекта! diff --git a/config.toml.example b/config.toml.example new file mode 100644 index 0000000..000f35c --- /dev/null +++ b/config.toml.example @@ -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" diff --git a/credentials.example b/credentials.example new file mode 100644 index 0000000..d037160 --- /dev/null +++ b/credentials.example @@ -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 diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..1283a72 --- /dev/null +++ b/rustfmt.toml @@ -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 diff --git a/src/app/mod.rs b/src/app/mod.rs index d86a479..fa48e81 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -7,6 +7,7 @@ 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 @@ -66,14 +67,34 @@ pub struct App { pub message_search_results: Vec, /// Индекс выбранного результата 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, + // Reaction picker mode + /// Режим выбора реакции + pub is_reaction_picker_mode: bool, + /// ID сообщения для добавления реакции + pub selected_message_for_reaction: Option, + /// Список доступных реакций + pub available_reactions: Vec, + /// Индекс выбранной реакции в 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(), @@ -106,6 +127,14 @@ impl App { 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, } } @@ -517,4 +546,120 @@ impl App { pub fn get_selected_search_result_id(&self) -> Option { self.get_selected_search_result().map(|m| m.id) } + + // === Draft Management === + + /// Получить черновик для текущего чата + pub fn get_current_draft(&self) -> Option { + 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) { + 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 { + self.selected_message_for_reaction + } } diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..118d266 --- /dev/null +++ b/src/config.rs @@ -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 { + dirs::config_dir().map(|mut path| { + path.push("tele-tui"); + path.push("config.toml"); + path + }) + } + + /// Путь к директории конфигурации + pub fn config_dir() -> Option { + 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::(&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 { + 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 = None; + let mut api_hash: Option = 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::() { + 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 + )) + } +} diff --git a/src/input/main_input.rs b/src/input/main_input.rs index d2c96c6..3dbe3c5 100644 --- a/src/input/main_input.rs +++ b/src/input/main_input.rs @@ -56,9 +56,109 @@ pub async fn handle(app: &mut App, key: KeyEvent) { } 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 { @@ -151,6 +251,73 @@ pub async fn handle(app: &mut App, key: KeyEvent) { 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 { @@ -257,6 +424,8 @@ pub async fn handle(app: &mut App, key: KeyEvent) { 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)) => { @@ -386,6 +555,8 @@ pub async fn handle(app: &mut App, key: KeyEvent) { 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)) => { @@ -415,6 +586,16 @@ pub async fn handle(app: &mut App, key: KeyEvent) { // Отменить режим ответа 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; @@ -449,11 +630,86 @@ pub async fn handle(app: &mut App, key: KeyEvent) { // Начать режим пересылки 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 => { // Удаляем символ слева от курсора @@ -602,3 +858,108 @@ pub async fn handle(app: &mut App, key: KeyEvent) { } } } + +/// Подсчёт количества доступных действий в профиле +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 = 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 +} diff --git a/src/main.rs b/src/main.rs index 3324c0c..3f595e1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,5 @@ mod app; +mod config; mod input; mod tdlib; mod ui; @@ -26,6 +27,9 @@ async fn main() -> Result<(), io::Error> { // Загружаем переменные окружения из .env let _ = dotenvy::dotenv(); + // Загружаем конфигурацию (создаёт дефолтный если отсутствует) + let config = config::Config::load(); + // Отключаем логи TDLib ДО создания клиента disable_tdlib_logs(); @@ -37,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 diff --git a/src/tdlib/client.rs b/src/tdlib/client.rs index 9880a1a..2acbce0 100644 --- a/src/tdlib/client.rs +++ b/src/tdlib/client.rs @@ -112,6 +112,8 @@ pub struct ChatInfo { pub folder_ids: Vec, /// Чат замьючен (уведомления отключены) pub is_muted: bool, + /// Черновик сообщения + pub draft_text: Option, } /// Информация о сообщении, на которое отвечают @@ -135,6 +137,17 @@ pub struct ForwardInfo { pub date: i32, } +/// Информация о реакции на сообщение +#[derive(Debug, Clone)] +pub struct ReactionInfo { + /// Эмодзи реакции (например, "👍") + pub emoji: String, + /// Количество людей, поставивших эту реакцию + pub count: i32, + /// Поставил ли текущий пользователь эту реакцию + pub is_chosen: bool, +} + #[derive(Debug, Clone)] pub struct MessageInfo { pub id: i64, @@ -157,6 +170,8 @@ pub struct MessageInfo { pub reply_to: Option, /// Информация о forward (если сообщение переслано) pub forward_from: Option, + /// Реакции на сообщение + pub reactions: Vec, } #[derive(Debug, Clone)] @@ -165,6 +180,22 @@ pub struct FolderInfo { pub name: String, } +/// Информация о профиле чата/пользователя +#[derive(Debug, Clone)] +pub struct ProfileInfo { + pub chat_id: i64, + pub title: String, + pub username: Option, + pub bio: Option, + pub phone_number: Option, + pub chat_type: String, // "Личный чат", "Группа", "Канал" + pub member_count: Option, + pub description: Option, + pub invite_link: Option, + pub is_group: bool, + pub online_status: Option, +} + /// Состояние сетевого соединения #[derive(Debug, Clone, PartialEq)] pub enum NetworkState { @@ -233,11 +264,16 @@ pub struct TdClient { #[allow(dead_code)] impl TdClient { pub fn new() -> Self { - let api_id: i32 = env::var("API_ID") - .unwrap_or_else(|_| "0".to_string()) - .parse() - .unwrap_or(0); - let api_hash = env::var("API_HASH").unwrap_or_default(); + // Загружаем credentials из ~/.config/tele-tui/credentials или .env + let (api_id, api_hash) = match crate::config::Config::load_credentials() { + Ok(creds) => creds, + Err(err_msg) => { + eprintln!("\n{}\n", err_msg); + // Используем дефолтные значения, чтобы приложение запустилось + // Пользователь увидит сообщение об ошибке в UI + (0, String::new()) + } + }; let client_id = tdlib_rs::create_client(); @@ -592,6 +628,50 @@ impl TdClient { } } } + Update::ChatDraftMessage(update) => { + // Обновляем черновик в списке чатов + if let Some(chat) = self.chats.iter_mut().find(|c| c.id == update.chat_id) { + chat.draft_text = update.draft_message.as_ref().and_then(|draft| { + // Извлекаем текст из InputMessageText + if let tdlib_rs::enums::InputMessageContent::InputMessageText(text_msg) = &draft.input_message_text { + Some(text_msg.text.text.clone()) + } else { + None + } + }); + } + } + Update::MessageInteractionInfo(update) => { + // Обновляем реакции в текущем открытом чате + if Some(update.chat_id) == self.current_chat_id { + if let Some(msg) = self.current_chat_messages.iter_mut().find(|m| m.id == update.message_id) { + // Извлекаем реакции из interaction_info + msg.reactions = update + .interaction_info + .as_ref() + .and_then(|info| info.reactions.as_ref()) + .map(|reactions| { + reactions + .reactions + .iter() + .filter_map(|reaction| { + let emoji = match &reaction.r#type { + tdlib_rs::enums::ReactionType::Emoji(e) => e.emoji.clone(), + tdlib_rs::enums::ReactionType::CustomEmoji(_) => return None, + }; + + Some(ReactionInfo { + emoji, + count: reaction.total_count, + is_chosen: reaction.is_chosen, + }) + }) + .collect() + }) + .unwrap_or_default(); + } + } + } _ => {} } } @@ -678,6 +758,7 @@ impl TdClient { last_read_outbox_message_id: td_chat.last_read_outbox_message_id, folder_ids, is_muted, + draft_text: None, }; if let Some(existing) = self.chats.iter_mut().find(|c| c.id == td_chat.id) { @@ -757,6 +838,9 @@ impl TdClient { // Извлекаем информацию о forward let forward_from = self.extract_forward_info(message); + // Извлекаем реакции + let reactions = self.extract_reactions(message); + MessageInfo { id: message.id, sender_name, @@ -771,6 +855,7 @@ impl TdClient { can_be_deleted_for_all_users: message.can_be_deleted_for_all_users, reply_to, forward_from, + reactions, } } @@ -827,6 +912,34 @@ impl TdClient { }) } + /// Извлекает информацию о реакциях из сообщения + fn extract_reactions(&self, message: &TdMessage) -> Vec { + message + .interaction_info + .as_ref() + .and_then(|info| info.reactions.as_ref()) + .map(|reactions| { + reactions + .reactions + .iter() + .filter_map(|reaction| { + // Извлекаем эмодзи из ReactionType + let emoji = match &reaction.r#type { + tdlib_rs::enums::ReactionType::Emoji(e) => e.emoji.clone(), + tdlib_rs::enums::ReactionType::CustomEmoji(_) => return None, // Пока игнорируем custom emoji + }; + + Some(ReactionInfo { + emoji, + count: reaction.total_count, + is_chosen: reaction.is_chosen, + }) + }) + .collect() + }) + .unwrap_or_default() + } + /// Получает имя отправителя из MessageOrigin fn get_origin_sender_name(&self, origin: &tdlib_rs::enums::MessageOrigin) -> String { use tdlib_rs::enums::MessageOrigin; @@ -1197,6 +1310,137 @@ impl TdClient { } } + /// Получение полной информации о чате для профиля + pub async fn get_profile_info(&self, chat_id: i64) -> Result { + use tdlib_rs::enums::ChatType; + + // Получаем основную информацию о чате + let chat_result = functions::get_chat(chat_id, self.client_id).await; + let chat = match chat_result { + Ok(tdlib_rs::enums::Chat::Chat(c)) => c, + Err(e) => return Err(format!("Ошибка загрузки чата: {:?}", e)), + }; + + let mut profile = ProfileInfo { + chat_id, + title: chat.title.clone(), + username: None, + bio: None, + phone_number: None, + chat_type: String::new(), + member_count: None, + description: None, + invite_link: None, + is_group: false, + online_status: None, + }; + + match &chat.r#type { + ChatType::Private(private_chat) => { + profile.chat_type = "Личный чат".to_string(); + profile.is_group = false; + + // Получаем полную информацию о пользователе + let user_result = functions::get_user(private_chat.user_id, self.client_id).await; + if let Ok(tdlib_rs::enums::User::User(user)) = user_result { + // Username + if let Some(usernames) = user.usernames { + if let Some(username) = usernames.active_usernames.first() { + profile.username = Some(format!("@{}", username)); + } + } + + // Phone number + if !user.phone_number.is_empty() { + profile.phone_number = Some(format!("+{}", user.phone_number)); + } + + // Online status + profile.online_status = Some(match user.status { + tdlib_rs::enums::UserStatus::Online(_) => "Онлайн".to_string(), + tdlib_rs::enums::UserStatus::Recently(_) => "Был(а) недавно".to_string(), + tdlib_rs::enums::UserStatus::LastWeek(_) => "Был(а) на этой неделе".to_string(), + tdlib_rs::enums::UserStatus::LastMonth(_) => "Был(а) в этом месяце".to_string(), + tdlib_rs::enums::UserStatus::Offline(offline) => { + crate::utils::format_was_online(offline.was_online) + } + _ => "Давно не был(а)".to_string(), + }); + } + + // Bio (getUserFullInfo) + let full_info_result = functions::get_user_full_info(private_chat.user_id, self.client_id).await; + if let Ok(tdlib_rs::enums::UserFullInfo::UserFullInfo(full_info)) = full_info_result { + if let Some(bio_obj) = full_info.bio { + profile.bio = Some(bio_obj.text); + } + } + } + ChatType::BasicGroup(basic_group) => { + profile.chat_type = "Группа".to_string(); + profile.is_group = true; + + // Получаем информацию о группе + let group_result = functions::get_basic_group(basic_group.basic_group_id, self.client_id).await; + if let Ok(tdlib_rs::enums::BasicGroup::BasicGroup(group)) = group_result { + profile.member_count = Some(group.member_count); + } + + // Полная информация о группе + let full_info_result = functions::get_basic_group_full_info(basic_group.basic_group_id, self.client_id).await; + if let Ok(tdlib_rs::enums::BasicGroupFullInfo::BasicGroupFullInfo(full_info)) = full_info_result { + if !full_info.description.is_empty() { + profile.description = Some(full_info.description); + } + if let Some(link) = full_info.invite_link { + profile.invite_link = Some(link.invite_link); + } + } + } + ChatType::Supergroup(supergroup) => { + // Получаем информацию о супергруппе + let sg_result = functions::get_supergroup(supergroup.supergroup_id, self.client_id).await; + if let Ok(tdlib_rs::enums::Supergroup::Supergroup(sg)) = sg_result { + profile.chat_type = if sg.is_channel { "Канал".to_string() } else { "Супергруппа".to_string() }; + profile.is_group = !sg.is_channel; + profile.member_count = Some(sg.member_count); + + // Username + if let Some(usernames) = sg.usernames { + if let Some(username) = usernames.active_usernames.first() { + profile.username = Some(format!("@{}", username)); + } + } + } + + // Полная информация о супергруппе + let full_info_result = functions::get_supergroup_full_info(supergroup.supergroup_id, self.client_id).await; + if let Ok(tdlib_rs::enums::SupergroupFullInfo::SupergroupFullInfo(full_info)) = full_info_result { + if !full_info.description.is_empty() { + profile.description = Some(full_info.description); + } + if let Some(link) = full_info.invite_link { + profile.invite_link = Some(link.invite_link); + } + } + } + ChatType::Secret(_) => { + profile.chat_type = "Секретный чат".to_string(); + } + } + + Ok(profile) + } + + /// Выйти из группы/канала + pub async fn leave_chat(&self, chat_id: i64) -> Result<(), String> { + let result = functions::leave_chat(chat_id, self.client_id).await; + match result { + Ok(_) => Ok(()), + Err(e) => Err(format!("Ошибка выхода из чата: {:?}", e)), + } + } + /// Загрузка старых сообщений (для скролла вверх) pub async fn load_older_messages( &mut self, @@ -1341,13 +1585,148 @@ impl TdClient { can_be_deleted_for_all_users: msg.can_be_deleted_for_all_users, reply_to: reply_info, forward_from: None, + reactions: Vec::new(), }) } Err(e) => Err(format!("Ошибка отправки сообщения: {:?}", e)), } } + + /// Получить доступные реакции для сообщения + pub async fn get_message_available_reactions( + &mut self, + chat_id: i64, + message_id: i64, + ) -> Result, String> { + use tdlib_rs::functions; + + let result = functions::get_message_available_reactions( + chat_id, + message_id, + 8, // row_size - количество реакций в ряду + self.client_id, + ) + .await; + + match result { + Ok(tdlib_rs::enums::AvailableReactions::AvailableReactions(reactions)) => { + // Извлекаем эмодзи из доступных реакций + // Используем top_reactions (самые популярные реакции) + let mut emojis: Vec = reactions + .top_reactions + .iter() + .filter_map(|reaction| { + if let tdlib_rs::enums::ReactionType::Emoji(e) = &reaction.r#type { + Some(e.emoji.clone()) + } else { + None + } + }) + .collect(); + + // Если top_reactions пустой, используем popular_reactions + if emojis.is_empty() { + emojis = reactions + .popular_reactions + .iter() + .filter_map(|reaction| { + if let tdlib_rs::enums::ReactionType::Emoji(e) = &reaction.r#type { + Some(e.emoji.clone()) + } else { + None + } + }) + .collect(); + } + + Ok(emojis) + } + Err(e) => Err(format!("Ошибка получения реакций: {:?}", e)), + } + } + + /// Добавить реакцию на сообщение (или убрать, если уже поставлена) + pub async fn toggle_reaction( + &mut self, + chat_id: i64, + message_id: i64, + emoji: String, + ) -> Result<(), String> { + use tdlib_rs::functions; + use tdlib_rs::types::ReactionTypeEmoji; + use tdlib_rs::enums::ReactionType; + + let reaction_type = ReactionType::Emoji(ReactionTypeEmoji { emoji }); + + let result = functions::add_message_reaction( + chat_id, + message_id, + reaction_type, + false, // is_big - обычная реакция (не "большая" анимация) + true, // update_recent_reactions - обновить список недавних реакций + self.client_id, + ) + .await; + + match result { + Ok(_) => Ok(()), + Err(e) => Err(format!("Ошибка добавления реакции: {:?}", e)), + } + } + /// Редактирование текстового сообщения с поддержкой Markdown + /// Устанавливает черновик для чата через TDLib API + pub async fn set_draft_message(&self, chat_id: i64, text: String) -> Result<(), String> { + use tdlib_rs::types::{FormattedText, InputMessageText, DraftMessage}; + use tdlib_rs::enums::InputMessageContent; + + if text.is_empty() { + // Очищаем черновик + let result = functions::set_chat_draft_message( + chat_id, + 0, // message_thread_id + None, // draft_message (None = очистить) + self.client_id, + ).await; + + match result { + Ok(_) => Ok(()), + Err(e) => Err(format!("Ошибка очистки черновика: {:?}", e)), + } + } else { + // Создаём черновик + let formatted_text = FormattedText { + text: text.clone(), + entities: vec![], + }; + + let input_message = InputMessageContent::InputMessageText(InputMessageText { + text: formatted_text, + link_preview_options: None, + clear_draft: false, + }); + + let draft = DraftMessage { + reply_to: None, + date: 0, // TDLib установит текущее время + input_message_text: input_message, + }; + + let result = functions::set_chat_draft_message( + chat_id, + 0, // message_thread_id + Some(draft), + self.client_id, + ).await; + + match result { + Ok(_) => Ok(()), + Err(e) => Err(format!("Ошибка установки черновика: {:?}", e)), + } + } + } + pub async fn edit_message(&self, chat_id: i64, message_id: i64, text: String) -> Result { use tdlib_rs::types::{FormattedText, InputMessageText, TextParseModeMarkdown}; use tdlib_rs::enums::{InputMessageContent, TextParseMode}; @@ -1402,6 +1781,7 @@ impl TdClient { can_be_deleted_for_all_users: msg.can_be_deleted_for_all_users, reply_to: None, // При редактировании reply сохраняется из оригинала forward_from: None, // При редактировании forward сохраняется из оригинала + reactions: Vec::new(), // При редактировании реакции сохраняются из оригинала }) } Err(e) => Err(format!("Ошибка редактирования сообщения: {:?}", e)), diff --git a/src/tdlib/mod.rs b/src/tdlib/mod.rs index 4bd9a4c..f58cd6a 100644 --- a/src/tdlib/mod.rs +++ b/src/tdlib/mod.rs @@ -3,4 +3,5 @@ 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; diff --git a/src/ui/chat_list.rs b/src/ui/chat_list.rs index e060774..999a9e8 100644 --- a/src/ui/chat_list.rs +++ b/src/ui/chat_list.rs @@ -65,13 +65,20 @@ pub fn render(f: &mut Frame, area: Rect, app: &mut App) { 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, status_icon, pin_icon, mute_icon, chat.title, username_text, mention_badge, unread_badge); + 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) { @@ -150,28 +157,5 @@ pub fn render(f: &mut Frame, area: Rect, app: &mut App) { /// Форматирование времени "был(а) в ..." fn format_was_online(timestamp: i32) -> String { - use std::time::{SystemTime, UNIX_EPOCH}; - - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs() as i32; - - let diff = now - timestamp; - - if diff < 60 { - "был(а) только что".to_string() - } else if diff < 3600 { - let mins = diff / 60; - format!("был(а) {} мин. назад", mins) - } else if diff < 86400 { - let hours = diff / 3600; - format!("был(а) {} ч. назад", hours) - } else { - // Показываем дату - 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) - } + crate::utils::format_was_online(timestamp) } diff --git a/src/ui/footer.rs b/src/ui/footer.rs index 7856119..95a5a6a 100644 --- a/src/ui/footer.rs +++ b/src/ui/footer.rs @@ -24,7 +24,7 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { } else if app.is_searching { format!(" {}↑/↓: Navigate | Enter: Select | Esc: Cancel ", network_indicator) } else if app.selected_chat_id.is_some() { - format!(" {}↑/↓: Scroll | Enter: Send | Esc: Close | Ctrl+R: Refresh | Ctrl+C: Quit ", network_indicator) + 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) }; diff --git a/src/ui/messages.rs b/src/ui/messages.rs index db7d7c9..cf165e0 100644 --- a/src/ui/messages.rs +++ b/src/ui/messages.rs @@ -6,7 +6,7 @@ use ratatui::{ Frame, }; use crate::app::App; -use crate::utils::{format_timestamp, format_date, get_day}; +use crate::utils::{format_timestamp_with_tz, format_date, get_day}; use tdlib_rs::enums::TextEntityType; use tdlib_rs::types::TextEntity; @@ -148,15 +148,18 @@ fn render_input_with_cursor(prefix: &str, text: &str, cursor_pos: usize, color: let chars: Vec = text.chars().collect(); let mut spans: Vec = vec![Span::raw(prefix.to_string())]; + // Ограничиваем cursor_pos границами текста + let safe_cursor_pos = cursor_pos.min(chars.len()); + // Текст до курсора - if cursor_pos > 0 { - let before: String = chars[..cursor_pos].iter().collect(); + if safe_cursor_pos > 0 { + let before: String = chars[..safe_cursor_pos].iter().collect(); spans.push(Span::styled(before, Style::default().fg(color))); } // Символ под курсором (или █ если курсор в конце) - if cursor_pos < chars.len() { - let cursor_char = chars[cursor_pos].to_string(); + if safe_cursor_pos < chars.len() { + let cursor_char = chars[safe_cursor_pos].to_string(); spans.push(Span::styled(cursor_char, Style::default().fg(Color::Black).bg(color))); } else { // Курсор в конце - показываем блок @@ -164,8 +167,8 @@ fn render_input_with_cursor(prefix: &str, text: &str, cursor_pos: usize, color: } // Текст после курсора - if cursor_pos + 1 < chars.len() { - let after: String = chars[cursor_pos + 1..].iter().collect(); + if safe_cursor_pos + 1 < chars.len() { + let after: String = chars[safe_cursor_pos + 1..].iter().collect(); spans.push(Span::styled(after, Style::default().fg(color))); } @@ -307,6 +310,14 @@ fn adjust_entities_for_substring( } pub fn render(f: &mut Frame, area: Rect, app: &App) { + // Режим профиля + if app.is_profile_mode() { + if let Some(profile) = &app.profile_info { + crate::ui::profile::render(f, area, app, profile); + } + return; + } + // Режим поиска по сообщениям if app.is_message_search_mode() { render_search_mode(f, area, app); @@ -499,16 +510,16 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { last_sender = Some(current_sender); } - // Форматируем время (HH:MM) - let time = format_timestamp(msg.date); + // Форматируем время (HH:MM) с учётом timezone из config + let time = format_timestamp_with_tz(msg.date, &app.config.general.timezone); - // Цвет сообщения (жёлтый если выбрано) + // Цвет сообщения (из config или жёлтый если выбрано) let msg_color = if is_selected { - Color::Yellow + app.config.parse_color(&app.config.colors.selected_message) } else if msg.is_outgoing { - Color::Green + app.config.parse_color(&app.config.colors.outgoing_message) } else { - Color::White + app.config.parse_color(&app.config.colors.incoming_message) }; // Маркер выбора @@ -657,6 +668,58 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { } } } + + // Отображаем реакции под сообщением + if !msg.reactions.is_empty() { + let mut reaction_spans = vec![]; + + for reaction in &msg.reactions { + if !reaction_spans.is_empty() { + reaction_spans.push(Span::raw(" ")); + } + + // Свои реакции в рамках [emoji], чужие просто emoji + let reaction_text = if reaction.is_chosen { + if reaction.count > 1 { + format!("[{}] {}", reaction.emoji, reaction.count) + } else { + format!("[{}]", reaction.emoji) + } + } else { + if reaction.count > 1 { + format!("{} {}", reaction.emoji, reaction.count) + } else { + reaction.emoji.clone() + } + }; + + let style = if reaction.is_chosen { + Style::default().fg(app.config.parse_color(&app.config.colors.reaction_chosen)) + } else { + Style::default().fg(app.config.parse_color(&app.config.colors.reaction_other)) + }; + + reaction_spans.push(Span::styled(reaction_text, style)); + } + + // Выравниваем реакции в зависимости от типа сообщения + if msg.is_outgoing { + // Реакции справа для исходящих + let reactions_text: String = reaction_spans + .iter() + .map(|s| s.content.as_ref()) + .collect::>() + .join(" "); + let reactions_len = reactions_text.chars().count(); + let padding = content_width.saturating_sub(reactions_len + 1); + let mut line_spans = vec![Span::raw(" ".repeat(padding))]; + line_spans.extend(reaction_spans); + lines.push(Line::from(line_spans)); + } else { + // Реакции слева для входящих + lines.push(Line::from(reaction_spans)); + } + } } if lines.is_empty() { @@ -723,10 +786,10 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { let can_delete = selected_msg.map(|m| m.can_be_deleted_only_for_self || m.can_be_deleted_for_all_users).unwrap_or(false); let hint = match (can_edit, can_delete) { - (true, true) => "↑↓ · Enter ред. · r ответ · f перслть · d удал. · Esc", - (true, false) => "↑↓ · Enter ред. · r ответ · f переслть · Esc", - (false, true) => "↑↓ · r ответ · f переслать · d удалить · Esc", - (false, false) => "↑↓ · r ответить · f переслать · Esc", + (true, true) => "↑↓ · Enter ред. · r ответ · f перслть · y копир. · d удал. · Esc", + (true, false) => "↑↓ · Enter ред. · r ответ · f переслть · y копир. · Esc", + (false, true) => "↑↓ · r ответ · f переслать · y копир. · d удалить · Esc", + (false, false) => "↑↓ · r ответить · f переслать · y копировать · Esc", }; (Line::from(Span::styled(hint, Style::default().fg(Color::Cyan))), " Выбор сообщения ") } else if app.is_editing() { @@ -816,6 +879,11 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { if app.is_confirm_delete_shown() { render_delete_confirm_modal(f, area); } + + // Модалка выбора реакции + if app.is_reaction_picker_mode() { + render_reaction_picker_modal(f, area, &app.available_reactions, app.selected_reaction_index); + } } /// Рендерит режим поиска по сообщениям @@ -1125,3 +1193,78 @@ fn render_delete_confirm_modal(f: &mut Frame, area: Rect) { f.render_widget(modal, modal_area); } + + +/// Рендерит модалку выбора реакции +fn render_reaction_picker_modal(f: &mut Frame, area: Rect, available_reactions: &[String], selected_index: usize) { + use ratatui::widgets::Clear; + + // Размеры модалки (зависят от количества реакций) + let emojis_per_row = 8; + let rows = (available_reactions.len() + emojis_per_row - 1) / emojis_per_row; + let modal_width = 50u16; + let modal_height = (rows + 4) as u16; // +4 для заголовка, отступов и подсказки + + // Центрируем модалку + let x = area.x + (area.width.saturating_sub(modal_width)) / 2; + let y = area.y + (area.height.saturating_sub(modal_height)) / 2; + + let modal_area = Rect::new(x, y, modal_width.min(area.width), modal_height.min(area.height)); + + // Очищаем область под модалкой + f.render_widget(Clear, modal_area); + + // Формируем содержимое - сетка эмодзи + let mut text_lines = vec![Line::from("")]; // Пустая строка сверху + + for row in 0..rows { + let mut row_spans = vec![Span::raw(" ")]; // Отступ слева + + for col in 0..emojis_per_row { + let idx = row * emojis_per_row + col; + if idx >= available_reactions.len() { + break; + } + + let emoji = &available_reactions[idx]; + let is_selected = idx == selected_index; + + let style = if is_selected { + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) + .add_modifier(Modifier::REVERSED) + } else { + Style::default().fg(Color::White) + }; + + row_spans.push(Span::styled(format!(" {} ", emoji), style)); + row_spans.push(Span::raw(" ")); // Пробел между эмодзи + } + + text_lines.push(Line::from(row_spans)); + } + + // Добавляем пустую строку и подсказку + text_lines.push(Line::from("")); + text_lines.push(Line::from(vec![ + Span::styled(" [←/→/↑/↓] ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)), + Span::raw("Выбор "), + Span::styled(" [Enter] ", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)), + Span::raw("Добавить "), + Span::styled(" [Esc] ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)), + Span::raw("Отмена"), + ])); + + let modal = Paragraph::new(text_lines) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Yellow)) + .title(" Выбери реакцию ") + .title_style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), + ) + .alignment(Alignment::Left); + + f.render_widget(modal, modal_area); +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index ba751c1..9fd3679 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -4,6 +4,7 @@ mod main_screen; mod chat_list; mod messages; mod footer; +pub mod profile; use ratatui::Frame; use ratatui::layout::Alignment; diff --git a/src/ui/profile.rs b/src/ui/profile.rs new file mode 100644 index 0000000..e4af9b3 --- /dev/null +++ b/src/ui/profile.rs @@ -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 = 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] +} diff --git a/src/utils.rs b/src/utils.rs index 832aa94..076fc50 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -22,20 +22,42 @@ pub fn disable_tdlib_logs() { } } -/// Форматирование timestamp в время HH:MM -pub fn format_timestamp(timestamp: i32) -> String { +/// Форматирование 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; - // Конвертируем в локальное время (простой способ без chrono) - // UTC + смещение для локального времени - let hours = ((secs % 86400) / 3600) as u32; + + // Парсим 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; - // Примерное локальное время (добавим 3 часа для MSK, можно настроить) - let local_hours = (hours + 3) % 24; + // Применяем 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::() { + 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}; @@ -108,3 +130,31 @@ pub fn format_datetime(timestamp: i32) -> String { 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() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() as i32; + + let diff = now - timestamp; + + if diff < 60 { + "был(а) только что".to_string() + } else if diff < 3600 { + let mins = diff / 60; + format!("был(а) {} мин. назад", mins) + } else if diff < 86400 { + let hours = diff / 3600; + format!("был(а) {} ч. назад", hours) + } else { + // Показываем дату + 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) + } +}