Compare commits
18 Commits
e1bceada6d
...
yet-anothe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
051c4a0265 | ||
|
|
f291191577 | ||
|
|
356d2d3064 | ||
|
|
ac684da820 | ||
|
|
dc76e01f3c | ||
|
|
81dc5b9007 | ||
|
|
4d5625f950 | ||
|
|
46720b3584 | ||
|
|
e4dabbe3ac | ||
|
|
fa749d24c5 | ||
|
|
22c4e17377 | ||
|
|
c18f43664e | ||
|
|
1ef341d907 | ||
|
|
0a9ae8b448 | ||
|
|
32ab1df1fa | ||
|
|
9912ac11bd | ||
|
|
699f50a59c | ||
|
|
b6d9291864 |
37
.editorconfig
Normal file
37
.editorconfig
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# EditorConfig is awesome: https://EditorConfig.org
|
||||||
|
|
||||||
|
# top-most EditorConfig file
|
||||||
|
root = true
|
||||||
|
|
||||||
|
# Unix-style newlines with a newline ending every file
|
||||||
|
[*]
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
charset = utf-8
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
# Rust files
|
||||||
|
[*.rs]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 4
|
||||||
|
|
||||||
|
# TOML files
|
||||||
|
[*.toml]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
# Markdown files
|
||||||
|
[*.md]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
trim_trailing_whitespace = false
|
||||||
|
|
||||||
|
# YAML files
|
||||||
|
[*.{yml,yaml}]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
# JSON files
|
||||||
|
[*.json]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
40
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
40
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
---
|
||||||
|
name: Bug Report
|
||||||
|
about: Сообщить о проблеме или баге
|
||||||
|
title: '[BUG] '
|
||||||
|
labels: bug
|
||||||
|
assignees: ''
|
||||||
|
---
|
||||||
|
|
||||||
|
## Описание бага
|
||||||
|
Четкое и краткое описание проблемы.
|
||||||
|
|
||||||
|
## Шаги для воспроизведения
|
||||||
|
1. Запустить '...'
|
||||||
|
2. Нажать на '...'
|
||||||
|
3. Прокрутить вниз до '...'
|
||||||
|
4. Увидеть ошибку
|
||||||
|
|
||||||
|
## Ожидаемое поведение
|
||||||
|
Что должно было произойти.
|
||||||
|
|
||||||
|
## Фактическое поведение
|
||||||
|
Что произошло на самом деле.
|
||||||
|
|
||||||
|
## Скриншоты
|
||||||
|
Если применимо, добавьте скриншоты для демонстрации проблемы.
|
||||||
|
|
||||||
|
## Окружение
|
||||||
|
- **ОС**: [например, macOS 14.0, Ubuntu 22.04, Windows 11]
|
||||||
|
- **Rust версия**: [вывод `rustc --version`]
|
||||||
|
- **tele-tui версия**: [вывод `cargo pkgid`]
|
||||||
|
- **Размер терминала**: [например, 100x30]
|
||||||
|
|
||||||
|
## Логи
|
||||||
|
Если есть логи или сообщения об ошибках, вставьте их сюда:
|
||||||
|
```
|
||||||
|
вставьте логи здесь
|
||||||
|
```
|
||||||
|
|
||||||
|
## Дополнительный контекст
|
||||||
|
Любая другая информация, которая может помочь в решении проблемы.
|
||||||
34
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
34
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
---
|
||||||
|
name: Feature Request
|
||||||
|
about: Предложить новую функцию или улучшение
|
||||||
|
title: '[FEATURE] '
|
||||||
|
labels: enhancement
|
||||||
|
assignees: ''
|
||||||
|
---
|
||||||
|
|
||||||
|
## Связано с проблемой?
|
||||||
|
Есть ли проблема, которую это решит? Например: "Меня расстраивает, что [...]"
|
||||||
|
|
||||||
|
## Описание решения
|
||||||
|
Четкое и краткое описание того, что вы хотите.
|
||||||
|
|
||||||
|
## Альтернативы
|
||||||
|
Какие альтернативные решения или функции вы рассматривали?
|
||||||
|
|
||||||
|
## Примеры использования
|
||||||
|
Как эта функция будет использоваться? Приведите примеры:
|
||||||
|
|
||||||
|
1. Пользователь делает X
|
||||||
|
2. Система делает Y
|
||||||
|
3. Результат: Z
|
||||||
|
|
||||||
|
## Приоритет
|
||||||
|
- [ ] Критичная функция — без неё приложение малополезно
|
||||||
|
- [ ] Важная функция — значительно улучшит UX
|
||||||
|
- [ ] Nice to have — было бы удобно
|
||||||
|
|
||||||
|
## Проверка roadmap
|
||||||
|
- [ ] Я проверил [ROADMAP.md](../ROADMAP.md) и этой функции там нет
|
||||||
|
|
||||||
|
## Дополнительный контекст
|
||||||
|
Скриншоты, ссылки на похожие реализации в других приложениях, и т.д.
|
||||||
51
.github/pull_request_template.md
vendored
Normal file
51
.github/pull_request_template.md
vendored
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
## Описание
|
||||||
|
|
||||||
|
Краткое описание изменений в этом PR.
|
||||||
|
|
||||||
|
## Тип изменений
|
||||||
|
|
||||||
|
- [ ] Bug fix (исправление бага)
|
||||||
|
- [ ] New feature (новая функция)
|
||||||
|
- [ ] Breaking change (изменение, ломающее обратную совместимость)
|
||||||
|
- [ ] Refactoring (рефакторинг без изменения функциональности)
|
||||||
|
- [ ] Documentation (изменения в документации)
|
||||||
|
- [ ] Performance improvement (улучшение производительности)
|
||||||
|
|
||||||
|
## Связанные Issue
|
||||||
|
|
||||||
|
Fixes #(номер issue)
|
||||||
|
|
||||||
|
## Как протестировано?
|
||||||
|
|
||||||
|
Опишите тесты, которые вы провели:
|
||||||
|
|
||||||
|
- [ ] Тест A
|
||||||
|
- [ ] Тест B
|
||||||
|
- [ ] Тест C
|
||||||
|
|
||||||
|
## Сценарии тестирования
|
||||||
|
|
||||||
|
Подробные шаги для проверки изменений:
|
||||||
|
|
||||||
|
1. Запустить `cargo run`
|
||||||
|
2. Сделать X
|
||||||
|
3. Убедиться, что Y
|
||||||
|
|
||||||
|
## Чеклист
|
||||||
|
|
||||||
|
- [ ] Мой код следует стилю проекта
|
||||||
|
- [ ] Я запустил `cargo fmt`
|
||||||
|
- [ ] Я запустил `cargo clippy` и исправил warnings
|
||||||
|
- [ ] Код компилируется без ошибок (`cargo build`)
|
||||||
|
- [ ] Я протестировал изменения вручную
|
||||||
|
- [ ] Я обновил документацию (если необходимо)
|
||||||
|
- [ ] Я добавил тесты (если применимо)
|
||||||
|
- [ ] Все существующие тесты проходят
|
||||||
|
|
||||||
|
## Скриншоты (если применимо)
|
||||||
|
|
||||||
|
Добавьте скриншоты для демонстрации UI изменений.
|
||||||
|
|
||||||
|
## Дополнительные заметки
|
||||||
|
|
||||||
|
Любая дополнительная информация для ревьюверов.
|
||||||
50
.github/workflows/ci.yml
vendored
Normal file
50
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ main, develop ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ main, develop ]
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
name: Check
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
- run: cargo check --all-features
|
||||||
|
|
||||||
|
fmt:
|
||||||
|
name: Format
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
components: rustfmt
|
||||||
|
- run: cargo fmt --all -- --check
|
||||||
|
|
||||||
|
clippy:
|
||||||
|
name: Clippy
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
components: clippy
|
||||||
|
- run: cargo clippy --all-features -- -D warnings
|
||||||
|
|
||||||
|
build:
|
||||||
|
name: Build
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
- run: cargo build --release --all-features
|
||||||
11
.gitignore
vendored
11
.gitignore
vendored
@@ -1 +1,12 @@
|
|||||||
/target
|
/target
|
||||||
|
|
||||||
|
# TDLib session data (contains auth tokens - NEVER commit!)
|
||||||
|
/tdlib_data/
|
||||||
|
|
||||||
|
# Environment variables (contains API keys)
|
||||||
|
.env
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Local config files (if created in project root)
|
||||||
|
config.toml
|
||||||
|
credentials
|
||||||
|
|||||||
1
.serena/.gitignore
vendored
Normal file
1
.serena/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/cache
|
||||||
89
.serena/project.yml
Normal file
89
.serena/project.yml
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
# list of languages for which language servers are started; choose from:
|
||||||
|
# al bash clojure cpp csharp
|
||||||
|
# csharp_omnisharp dart elixir elm erlang
|
||||||
|
# fortran fsharp go groovy haskell
|
||||||
|
# java julia kotlin lua markdown
|
||||||
|
# matlab nix pascal perl php
|
||||||
|
# powershell python python_jedi r rego
|
||||||
|
# ruby ruby_solargraph rust scala swift
|
||||||
|
# terraform toml typescript typescript_vts vue
|
||||||
|
# yaml zig
|
||||||
|
# (This list may be outdated. For the current list, see values of Language enum here:
|
||||||
|
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
|
||||||
|
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
|
||||||
|
# Note:
|
||||||
|
# - For C, use cpp
|
||||||
|
# - For JavaScript, use typescript
|
||||||
|
# - For Free Pascal/Lazarus, use pascal
|
||||||
|
# Special requirements:
|
||||||
|
# - csharp: Requires the presence of a .sln file in the project folder.
|
||||||
|
# - pascal: Requires Free Pascal Compiler (fpc) and optionally Lazarus.
|
||||||
|
# When using multiple languages, the first language server that supports a given file will be used for that file.
|
||||||
|
# The first language is the default language and the respective language server will be used as a fallback.
|
||||||
|
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
|
||||||
|
languages:
|
||||||
|
- rust
|
||||||
|
|
||||||
|
# the encoding used by text files in the project
|
||||||
|
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
|
||||||
|
encoding: "utf-8"
|
||||||
|
|
||||||
|
# whether to use project's .gitignore files to ignore files
|
||||||
|
ignore_all_files_in_gitignore: true
|
||||||
|
|
||||||
|
# list of additional paths to ignore in all projects
|
||||||
|
# same syntax as gitignore, so you can use * and **
|
||||||
|
ignored_paths: []
|
||||||
|
|
||||||
|
# whether the project is in read-only mode
|
||||||
|
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
|
||||||
|
# Added on 2025-04-18
|
||||||
|
read_only: false
|
||||||
|
|
||||||
|
# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
|
||||||
|
# Below is the complete list of tools for convenience.
|
||||||
|
# To make sure you have the latest list of tools, and to view their descriptions,
|
||||||
|
# execute `uv run scripts/print_tool_overview.py`.
|
||||||
|
#
|
||||||
|
# * `activate_project`: Activates a project by name.
|
||||||
|
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
|
||||||
|
# * `create_text_file`: Creates/overwrites a file in the project directory.
|
||||||
|
# * `delete_lines`: Deletes a range of lines within a file.
|
||||||
|
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
|
||||||
|
# * `execute_shell_command`: Executes a shell command.
|
||||||
|
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
|
||||||
|
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
|
||||||
|
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
|
||||||
|
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
|
||||||
|
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
|
||||||
|
# * `initial_instructions`: Gets the initial instructions for the current project.
|
||||||
|
# Should only be used in settings where the system prompt cannot be set,
|
||||||
|
# e.g. in clients you have no control over, like Claude Desktop.
|
||||||
|
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
|
||||||
|
# * `insert_at_line`: Inserts content at a given line in a file.
|
||||||
|
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
|
||||||
|
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
|
||||||
|
# * `list_memories`: Lists memories in Serena's project-specific memory store.
|
||||||
|
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
|
||||||
|
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
|
||||||
|
# * `read_file`: Reads a file within the project directory.
|
||||||
|
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
|
||||||
|
# * `remove_project`: Removes a project from the Serena configuration.
|
||||||
|
# * `replace_lines`: Replaces a range of lines within a file with new content.
|
||||||
|
# * `replace_symbol_body`: Replaces the full definition of a symbol.
|
||||||
|
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
|
||||||
|
# * `search_for_pattern`: Performs a search for a pattern in the project.
|
||||||
|
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
|
||||||
|
# * `switch_modes`: Activates modes by providing a list of their names
|
||||||
|
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
|
||||||
|
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
|
||||||
|
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
|
||||||
|
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
|
||||||
|
excluded_tools: []
|
||||||
|
|
||||||
|
# initial prompt for the project. It will always be given to the LLM upon activating the project
|
||||||
|
# (contrary to the memories, which are loaded on demand).
|
||||||
|
initial_prompt: ""
|
||||||
|
|
||||||
|
project_name: "tele-tui"
|
||||||
|
included_optional_tools: []
|
||||||
66
CHANGELOG.md
Normal file
66
CHANGELOG.md
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
Все значительные изменения в этом проекте будут документированы в этом файле.
|
||||||
|
|
||||||
|
Формат основан на [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
|
и этот проект придерживается [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.1.0] - 2024-12-XX
|
||||||
|
|
||||||
|
### Добавлено
|
||||||
|
|
||||||
|
#### Базовая функциональность
|
||||||
|
- TDLib интеграция с авторизацией (телефон + код + 2FA)
|
||||||
|
- Отображение списка чатов с поддержкой папок
|
||||||
|
- Загрузка и отображение истории сообщений
|
||||||
|
- Отправка текстовых сообщений
|
||||||
|
- Vim-style навигация (hjkl) с поддержкой русской раскладки (ролд)
|
||||||
|
- Поиск по чатам (Ctrl+S)
|
||||||
|
- Поиск внутри чата (Ctrl+F)
|
||||||
|
|
||||||
|
#### Сообщения
|
||||||
|
- Группировка по дате и отправителю
|
||||||
|
- Markdown форматирование (жирный, курсив, подчёркивание, зачёркивание, код, спойлеры)
|
||||||
|
- Редактирование сообщений
|
||||||
|
- Удаление сообщений с подтверждением
|
||||||
|
- Reply на сообщения
|
||||||
|
- Forward сообщений
|
||||||
|
- Копирование в системный буфер обмена
|
||||||
|
- Реакции на сообщения с emoji picker
|
||||||
|
|
||||||
|
#### UI/UX
|
||||||
|
- Индикаторы: онлайн-статус (●), прочитанность (✓/✓✓), редактирование (✎)
|
||||||
|
- Иконки: 📌 закреплённые чаты, 🔇 замьюченные, @ упоминания
|
||||||
|
- Typing indicator ("печатает...")
|
||||||
|
- Закреплённые сообщения
|
||||||
|
- Профиль пользователя/чата
|
||||||
|
- Черновики с автосохранением
|
||||||
|
- Динамический инпут (расширение до 10 строк)
|
||||||
|
- Блочный курсор с навигацией
|
||||||
|
- Состояние сети в футере
|
||||||
|
|
||||||
|
#### Конфигурация
|
||||||
|
- TOML конфигурация (~/.config/tele-tui/config.toml)
|
||||||
|
- Настройка часового пояса
|
||||||
|
- Настройка цветовой схемы
|
||||||
|
- Приоритетная загрузка credentials из XDG config dir
|
||||||
|
|
||||||
|
#### Оптимизации
|
||||||
|
- 60 FPS рендеринг
|
||||||
|
- LRU кеширование пользователей (лимит 500)
|
||||||
|
- Lazy loading имён пользователей
|
||||||
|
- Лимиты памяти (500 сообщений на чат, 200 чатов)
|
||||||
|
- Graceful shutdown
|
||||||
|
|
||||||
|
### Изменено
|
||||||
|
- Время отображается с учётом настроенного timezone
|
||||||
|
|
||||||
|
### Исправлено
|
||||||
|
- Корректная обработка TDLib updates в отдельном потоке
|
||||||
|
- Правильное выравнивание для длинных сообщений
|
||||||
|
- Приоритет обработки input для модалок
|
||||||
|
|
||||||
|
[Unreleased]: https://github.com/your-username/tele-tui/compare/v0.1.0...HEAD
|
||||||
|
[0.1.0]: https://github.com/your-username/tele-tui/releases/tag/v0.1.0
|
||||||
29
CLAUDE.md
Normal file
29
CLAUDE.md
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# Telegram TUI
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
Проект - TUI интерфейс для телеграмма
|
||||||
|
|
||||||
|
Порядок чтения:
|
||||||
|
1) DEVELOPMENT.md - правило работы (обязательно)
|
||||||
|
2) CONTEXT.md - текущий статус
|
||||||
|
3) ROADMAP.md - план и задачи
|
||||||
|
4) REQUIREMENTS.md / ARCHITECTURE.md - по необходимости
|
||||||
|
5) E2E_TESTS.md - перед написанием тестов
|
||||||
|
|
||||||
|
После работы обнови CONTEXT.md файл
|
||||||
|
|
||||||
|
После прочтения скажи "Жду инструкций"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Важные файлы
|
||||||
|
|
||||||
|
- [DEVELOPMENT.md](DEVELOPMENT.md) — **читай первым!** Правила локальной разработки
|
||||||
|
- [CONTEXT.md](CONTEXT.md) — текущий статус, что сделано
|
||||||
|
- [ROADMAP.md](ROADMAP.md) — план разработки, задачи по фазам
|
||||||
|
- [REQUIREMENTS.md](REQUIREMENTS.md) — требования к продукту
|
||||||
|
- [ARCHITECTURE.md](ARCHITECTURE.md) — C4, sequence diagrams, API контракты, UI прототипы
|
||||||
|
- [E2E_TESTING.md](E2E_TESTING.md) — **читай перед написанием тестов!** Гайд по e2e тестированию
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
245
CONTEXT.md
Normal file
245
CONTEXT.md
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
# Текущий контекст проекта
|
||||||
|
|
||||||
|
## Статус: Фаза 9 — ЗАВЕРШЕНО
|
||||||
|
|
||||||
|
### Что сделано
|
||||||
|
|
||||||
|
#### TDLib интеграция
|
||||||
|
- Подключена библиотека `tdlib-rs` v1.1 с автоматической загрузкой TDLib
|
||||||
|
- Реализована авторизация через телефон + код + 2FA пароль
|
||||||
|
- Сессия сохраняется автоматически в папке `tdlib_data/`
|
||||||
|
- Отключены логи TDLib через FFI вызов `td_execute` до создания клиента
|
||||||
|
- Updates обрабатываются в отдельном потоке через `mpsc` канал (неблокирующе)
|
||||||
|
- **Graceful shutdown**: корректное закрытие TDLib при выходе (Ctrl+C)
|
||||||
|
|
||||||
|
#### Функциональность
|
||||||
|
- Загрузка списка чатов (до 50 штук)
|
||||||
|
- **Фильтрация чатов**: показываются только чаты из ChatList::Main (без архива)
|
||||||
|
- **Фильтрация удалённых аккаунтов**: "Deleted Account" не отображаются в списке
|
||||||
|
- Отображение названия чата, счётчика непрочитанных и **@username**
|
||||||
|
- **Иконка 📌** для закреплённых чатов
|
||||||
|
- **Иконка 🔇** для замьюченных чатов
|
||||||
|
- **Индикатор @** для чатов с непрочитанными упоминаниями
|
||||||
|
- **Онлайн-статус**: зелёная точка ● для онлайн пользователей
|
||||||
|
- Загрузка истории сообщений при открытии чата (множественные попытки)
|
||||||
|
- **Группировка сообщений по дате** (разделители "Сегодня", "Вчера", дата) — по центру
|
||||||
|
- **Группировка сообщений по отправителю** (заголовок с именем)
|
||||||
|
- **Выравнивание сообщений**: исходящие справа (зелёные), входящие слева
|
||||||
|
- **Перенос длинных сообщений**: автоматический wrap на несколько строк
|
||||||
|
- **Отображение времени и галочек**: `текст (HH:MM ✓✓)` для исходящих, `(HH:MM) текст` для входящих
|
||||||
|
- **Галочки прочтения** (✓ отправлено, ✓✓ прочитано) — обновляются в реальном времени
|
||||||
|
- **Отметка сообщений как прочитанных**: при открытии чата счётчик непрочитанных сбрасывается
|
||||||
|
- **Отправка текстовых сообщений**
|
||||||
|
- **Редактирование сообщений**: ↑ при пустом инпуте → выбор → Enter → редактирование
|
||||||
|
- **Удаление сообщений**: в режиме выбора нажать `d` / `в` / `Delete` → модалка подтверждения
|
||||||
|
- **Reply на сообщения**: в режиме выбора нажать `r` / `к` → режим ответа с превью
|
||||||
|
- **Forward сообщений**: в режиме выбора нажать `f` / `а` → выбор чата для пересылки
|
||||||
|
- **Отображение пересланных сообщений**: индикатор "↪ Переслано от" с именем отправителя
|
||||||
|
- **Индикатор редактирования**: ✎ рядом с временем для отредактированных сообщений
|
||||||
|
- **Новые сообщения в реальном времени** при открытом чате
|
||||||
|
- **Поиск по чатам** (Ctrl+S): фильтрация по названию и @username
|
||||||
|
- **Typing indicator** ("печатает..."): отображение статуса набора текста собеседником и отправка своего статуса
|
||||||
|
- **Закреплённые сообщения**: отображение pinned message вверху чата с переходом к нему
|
||||||
|
- **Поиск по сообщениям в чате** (Ctrl+F): поиск текста внутри открытого чата с навигацией по результатам
|
||||||
|
- **Черновики**: автосохранение набранного текста при переключении между чатами
|
||||||
|
- **Профиль пользователя/чата** (`i`): просмотр информации о собеседнике или группе
|
||||||
|
- **Копирование сообщений** (`y`/`н`): копирование текста сообщения в системный буфер обмена
|
||||||
|
- **Реакции на сообщения**:
|
||||||
|
- Отображение реакций под сообщениями
|
||||||
|
- Логика отображения: 1 человек = только emoji, 2+ = emoji + счётчик
|
||||||
|
- Свои реакции в рамках [👍], чужие без рамок 👍
|
||||||
|
- Emoji picker с сеткой доступных реакций (8 в ряду)
|
||||||
|
- Добавление/удаление реакций (toggle)
|
||||||
|
- Обновление реакций в реальном времени через Update::MessageInteractionInfo
|
||||||
|
- **Конфигурационный файл** (`~/.config/tele-tui/config.toml`):
|
||||||
|
- Автоматическое создание дефолтного конфига при первом запуске
|
||||||
|
- **Настройка timezone**: формат "+03:00" или "-05:00"
|
||||||
|
- **Настройка цветов**: incoming_message, outgoing_message, selected_message, reaction_chosen, reaction_other
|
||||||
|
- **Credentials файл** (`~/.config/tele-tui/credentials`): API_ID и API_HASH
|
||||||
|
- Приоритет загрузки: ~/.config/tele-tui/credentials → .env → сообщение об ошибке с инструкциями
|
||||||
|
- **Кеширование имён пользователей**: имена загружаются асинхронно и обновляются в UI
|
||||||
|
- **Папки Telegram**: загрузка и переключение между папками (1-9)
|
||||||
|
- **Медиа-заглушки**: [Фото], [Видео], [Голосовое], [Стикер], [GIF] и др.
|
||||||
|
- **Markdown форматирование в сообщениях**:
|
||||||
|
- **Жирный** (bold)
|
||||||
|
- *Курсив* (italic)
|
||||||
|
- __Подчёркнутый__ (underline)
|
||||||
|
- ~~Зачёркнутый~~ (strikethrough)
|
||||||
|
- `Код` (inline code, Pre, PreCode) — cyan на тёмном фоне
|
||||||
|
- Спойлеры — скрытый текст (серый на сером)
|
||||||
|
- Ссылки (URL, TextUrl, Email, Phone) — синий с подчёркиванием
|
||||||
|
- @Упоминания — синий с подчёркиванием
|
||||||
|
|
||||||
|
#### Состояние сети
|
||||||
|
- **Индикатор в футере**: показывает текущее состояние подключения
|
||||||
|
- `⚠ Нет сети` — красный, ожидание сети
|
||||||
|
- `⏳ Прокси...` — cyan, подключение к прокси
|
||||||
|
- `⏳ Подключение...` — cyan, подключение к серверам
|
||||||
|
- `⏳ Обновление...` — cyan, синхронизация данных
|
||||||
|
|
||||||
|
#### Оптимизации
|
||||||
|
- **60 FPS ready**: poll таймаут 16ms, рендеринг только при изменениях (`needs_redraw` флаг)
|
||||||
|
- **Оптимизация памяти**:
|
||||||
|
- Очистка сообщений при закрытии чата
|
||||||
|
- Лимит кэша пользователей (500)
|
||||||
|
- Периодическая очистка неактивных записей
|
||||||
|
- **Минимальное разрешение**: предупреждение если терминал меньше 80x20
|
||||||
|
|
||||||
|
#### Динамический инпут
|
||||||
|
- **Автоматическое расширение**: поле ввода увеличивается при длинном тексте (до 10 строк)
|
||||||
|
- **Перенос текста**: длинные сообщения переносятся на новые строки
|
||||||
|
- **Блочный курсор**: vim-style курсор █ с возможностью перемещения по тексту
|
||||||
|
|
||||||
|
#### Управление
|
||||||
|
- `↑/↓` стрелки — навигация по списку чатов
|
||||||
|
- `Enter` — открыть чат / отправить сообщение
|
||||||
|
- `Esc` — закрыть открытый чат / отменить поиск
|
||||||
|
- `Ctrl+S` — поиск по чатам (фильтрация по названию и username)
|
||||||
|
- `Ctrl+R` — обновить список чатов
|
||||||
|
- `Ctrl+C` — выход (graceful shutdown)
|
||||||
|
- `↑/↓` в открытом чате — скролл сообщений (с подгрузкой старых)
|
||||||
|
- `↑` при пустом инпуте — выбор сообщения для редактирования
|
||||||
|
- `Enter` в режиме выбора — начать редактирование
|
||||||
|
- `r` / `к` в режиме выбора — ответить на сообщение (reply)
|
||||||
|
- `f` / `а` в режиме выбора — переслать сообщение (forward)
|
||||||
|
- `d` / `в` / `Delete` в режиме выбора — удалить сообщение (с подтверждением)
|
||||||
|
- `y` / `н` / `Enter` — подтвердить удаление в модалке
|
||||||
|
- `n` / `т` / `Esc` — отменить удаление в модалке
|
||||||
|
- `Esc` — отменить выбор/редактирование/reply
|
||||||
|
- `1-9` — переключение папок (в списке чатов)
|
||||||
|
- `Ctrl+F` — поиск по сообщениям в открытом чате
|
||||||
|
- `n` / `N` — навигация по результатам поиска (следующий/предыдущий)
|
||||||
|
- `i` — открыть профиль пользователя/чата
|
||||||
|
- `y` / `н` в режиме выбора — скопировать сообщение в буфер обмена
|
||||||
|
- `e` / `у` в режиме выбора — добавить реакцию (открывает emoji picker)
|
||||||
|
- `←` / `→` / `↑` / `↓` в emoji picker — навигация по сетке реакций
|
||||||
|
- `Enter` в emoji picker — добавить/удалить реакцию
|
||||||
|
- `Esc` в emoji picker — закрыть picker
|
||||||
|
- **Редактирование текста в инпуте:**
|
||||||
|
- `←` / `→` — перемещение курсора
|
||||||
|
- `Home` — курсор в начало
|
||||||
|
- `End` — курсор в конец
|
||||||
|
- `Backspace` — удалить символ слева
|
||||||
|
- `Delete` — удалить символ справа
|
||||||
|
|
||||||
|
### Структура проекта
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── main.rs # Точка входа, event loop, TDLib инициализация, graceful shutdown
|
||||||
|
├── config.rs # Конфигурация (TOML), загрузка credentials
|
||||||
|
├── app/
|
||||||
|
│ ├── mod.rs # App структура и состояние (needs_redraw флаг)
|
||||||
|
│ └── state.rs # AppScreen enum
|
||||||
|
├── ui/
|
||||||
|
│ ├── mod.rs # Роутинг UI по экранам, проверка минимального размера
|
||||||
|
│ ├── loading.rs # Экран загрузки
|
||||||
|
│ ├── auth.rs # Экран авторизации
|
||||||
|
│ ├── main_screen.rs # Главный экран с папками
|
||||||
|
│ ├── chat_list.rs # Список чатов (pin, mute, online, mentions)
|
||||||
|
│ ├── messages.rs # Область сообщений (wrap, группировка, динамический инпут)
|
||||||
|
│ └── footer.rs # Подвал с командами и статусом сети
|
||||||
|
├── input/
|
||||||
|
│ ├── mod.rs # Роутинг ввода
|
||||||
|
│ ├── auth.rs # Обработка ввода на экране авторизации
|
||||||
|
│ └── main_input.rs # Обработка ввода на главном экране
|
||||||
|
├── utils.rs # Утилиты (disable_tdlib_logs, format_timestamp_with_tz, format_date, get_day)
|
||||||
|
└── tdlib/
|
||||||
|
├── mod.rs # Модуль экспорта (TdClient, UserOnlineStatus, NetworkState)
|
||||||
|
└── client.rs # TdClient: авторизация, чаты, сообщения, кеш, NetworkState, ReactionInfo
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ключевые решения
|
||||||
|
|
||||||
|
1. **Неблокирующий receive**: TDLib updates приходят в отдельном потоке и передаются в main loop через `mpsc::channel`. Это позволяет UI оставаться отзывчивым.
|
||||||
|
|
||||||
|
2. **FFI для логов**: Используем прямой вызов `td_execute` для отключения логов синхронно, до создания клиента, чтобы избежать вывода в терминал.
|
||||||
|
|
||||||
|
3. **Синхронизация чатов**: Чаты загружаются асинхронно через updates. Main loop периодически синхронизирует `app.chats` с `td_client.chats`.
|
||||||
|
|
||||||
|
4. **Кеширование имён**: При получении `Update::User` сохраняем имя (first_name + last_name) и username в HashMap. Имена подгружаются асинхронно через очередь `pending_user_ids`. Кэш ограничен 500 записями.
|
||||||
|
|
||||||
|
5. **Группировка сообщений**: Сообщения группируются по дате (разделители по центру) и по отправителю (заголовки). Исходящие выравниваются вправо, входящие влево.
|
||||||
|
|
||||||
|
6. **Отметка прочтения**: При открытии чата вызывается `view_messages` для всех сообщений. Новые входящие сообщения автоматически отмечаются как прочитанные. `Update::ChatReadOutbox` обновляет статус галочек.
|
||||||
|
|
||||||
|
7. **Graceful shutdown**: При Ctrl+C устанавливается флаг остановки, закрывается TDLib клиент, ожидается завершение polling задачи с таймаутом 2 сек.
|
||||||
|
|
||||||
|
8. **Оптимизация рендеринга**: Флаг `needs_redraw` позволяет пропускать перерисовку когда ничего не изменилось. Триггеры: TDLib updates, пользовательский ввод, изменение размера терминала.
|
||||||
|
|
||||||
|
9. **Перенос текста**: Длинные сообщения автоматически разбиваются на строки с учётом ширины терминала. Для исходящих — time_mark на последней строке, для входящих — время на первой строке с отступом для остальных.
|
||||||
|
|
||||||
|
10. **Конфигурационный файл**: TOML конфиг создаётся автоматически при первом запуске в `~/.config/tele-tui/config.toml`. Поддерживает настройку timezone (применяется к отображению времени через `format_timestamp_with_tz`) и цветовой схемы (парсится в `ratatui::style::Color`). Credentials загружаются с приоритетом: XDG config dir → .env → ошибка с инструкциями.
|
||||||
|
|
||||||
|
11. **Реакции**: Хранятся в `Vec<ReactionInfo>` для каждого сообщения. Обновляются в реальном времени через `Update::MessageInteractionInfo`. Emoji picker использует сетку 8x6 с навигацией стрелками. Приоритет обработки ввода: reaction picker → delete confirmation → остальные модалки (важно для корректной работы Enter/Esc).
|
||||||
|
|
||||||
|
### Зависимости (Cargo.toml)
|
||||||
|
|
||||||
|
```toml
|
||||||
|
ratatui = "0.29"
|
||||||
|
crossterm = "0.28"
|
||||||
|
tdlib-rs = { version = "1.1", features = ["download-tdlib"] }
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
dotenvy = "0.15"
|
||||||
|
chrono = "0.4"
|
||||||
|
clipboard = "0.5"
|
||||||
|
toml = "0.8"
|
||||||
|
dirs = "5.0"
|
||||||
|
```
|
||||||
|
|
||||||
|
### API Credentials
|
||||||
|
|
||||||
|
Приоритет загрузки (от высшего к низшему):
|
||||||
|
|
||||||
|
1. **Файл credentials** (`~/.config/tele-tui/credentials`):
|
||||||
|
```
|
||||||
|
API_ID=your_api_id
|
||||||
|
API_HASH=your_api_hash
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Переменные окружения** (`.env` файл в текущей директории):
|
||||||
|
```
|
||||||
|
API_ID=your_api_id
|
||||||
|
API_HASH=your_api_hash
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Если ничего не найдено — показывается сообщение об ошибке с инструкциями.
|
||||||
|
|
||||||
|
### Конфигурационный файл
|
||||||
|
|
||||||
|
Создаётся автоматически при первом запуске в `~/.config/tele-tui/config.toml`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[general]
|
||||||
|
# Часовой пояс в формате "+03:00" или "-05:00"
|
||||||
|
# Применяется к отображению времени сообщений
|
||||||
|
timezone = "+03:00"
|
||||||
|
|
||||||
|
[colors]
|
||||||
|
# Цветовая схема (поддерживаемые цвета: black, red, green, yellow, blue, magenta, cyan, gray, white, darkgray, lightred, lightgreen, lightyellow, lightblue, lightmagenta, lightcyan)
|
||||||
|
|
||||||
|
# Цвет входящих сообщений
|
||||||
|
incoming_message = "white"
|
||||||
|
|
||||||
|
# Цвет исходящих сообщений
|
||||||
|
outgoing_message = "green"
|
||||||
|
|
||||||
|
# Цвет выбранного сообщения
|
||||||
|
selected_message = "yellow"
|
||||||
|
|
||||||
|
# Цвет своих реакций (в рамках [👍])
|
||||||
|
reaction_chosen = "yellow"
|
||||||
|
|
||||||
|
# Цвет чужих реакций
|
||||||
|
reaction_other = "gray"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Что НЕ сделано / TODO
|
||||||
|
|
||||||
|
Все пункты Фазы 9 завершены! Можно переходить к следующей фазе разработки.
|
||||||
|
|
||||||
|
## Известные проблемы
|
||||||
|
|
||||||
|
1. При первом запуске нужно пройти авторизацию
|
||||||
125
CONTRIBUTING.md
Normal file
125
CONTRIBUTING.md
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
# Contributing to tele-tui
|
||||||
|
|
||||||
|
Спасибо за интерес к проекту! Мы рады любому вкладу.
|
||||||
|
|
||||||
|
## Как помочь проекту
|
||||||
|
|
||||||
|
### Сообщить о баге
|
||||||
|
|
||||||
|
1. Проверьте, нет ли уже такого issue в [Issues](https://github.com/your-username/tele-tui/issues)
|
||||||
|
2. Создайте новый issue с описанием:
|
||||||
|
- Шаги для воспроизведения
|
||||||
|
- Ожидаемое поведение
|
||||||
|
- Фактическое поведение
|
||||||
|
- Версия ОС и Rust
|
||||||
|
- Логи (если есть)
|
||||||
|
|
||||||
|
### Предложить новую фичу
|
||||||
|
|
||||||
|
1. Проверьте [ROADMAP.md](ROADMAP.md) — возможно, эта фича уже запланирована
|
||||||
|
2. Создайте issue с меткой `enhancement`
|
||||||
|
3. Опишите:
|
||||||
|
- Зачем нужна эта фича
|
||||||
|
- Как она должна работать
|
||||||
|
- Примеры использования
|
||||||
|
|
||||||
|
### Внести код
|
||||||
|
|
||||||
|
1. **Fork** репозитория
|
||||||
|
2. Создайте **feature branch**: `git checkout -b feature/amazing-feature`
|
||||||
|
3. Прочитайте [DEVELOPMENT.md](DEVELOPMENT.md) для понимания процесса разработки
|
||||||
|
4. Внесите изменения
|
||||||
|
5. Протестируйте локально
|
||||||
|
6. Commit: `git commit -m 'Add amazing feature'`
|
||||||
|
7. Push: `git push origin feature/amazing-feature`
|
||||||
|
8. Создайте **Pull Request**
|
||||||
|
|
||||||
|
## Правила кода
|
||||||
|
|
||||||
|
### Стиль кода
|
||||||
|
|
||||||
|
- Используйте `cargo fmt` перед коммитом
|
||||||
|
- Следуйте [Rust API Guidelines](https://rust-lang.github.io/api-guidelines/)
|
||||||
|
- Добавляйте комментарии для сложной логики
|
||||||
|
|
||||||
|
### Структура коммитов
|
||||||
|
|
||||||
|
```
|
||||||
|
<type>: <краткое описание>
|
||||||
|
|
||||||
|
<подробное описание (опционально)>
|
||||||
|
```
|
||||||
|
|
||||||
|
Типы:
|
||||||
|
- `feat`: новая фича
|
||||||
|
- `fix`: исправление бага
|
||||||
|
- `refactor`: рефакторинг без изменения функциональности
|
||||||
|
- `docs`: изменения в документации
|
||||||
|
- `style`: форматирование, отступы
|
||||||
|
- `test`: добавление тестов
|
||||||
|
- `chore`: обновление зависимостей, конфигурации
|
||||||
|
|
||||||
|
Примеры:
|
||||||
|
```
|
||||||
|
feat: add emoji reactions to messages
|
||||||
|
|
||||||
|
fix: correct timezone offset calculation
|
||||||
|
|
||||||
|
docs: update installation instructions
|
||||||
|
```
|
||||||
|
|
||||||
|
### Тестирование
|
||||||
|
|
||||||
|
- Протестируйте вручную все изменения
|
||||||
|
- Опишите сценарии тестирования в PR
|
||||||
|
- Убедитесь, что `cargo build` проходит без ошибок
|
||||||
|
- Убедитесь, что `cargo fmt` и `cargo clippy` не дают предупреждений
|
||||||
|
|
||||||
|
## Процесс Review
|
||||||
|
|
||||||
|
1. Maintainer проверит ваш PR
|
||||||
|
2. Возможны комментарии и запросы на изменения
|
||||||
|
3. После одобрения PR будет смержен
|
||||||
|
4. Ваш вклад появится в следующем релизе
|
||||||
|
|
||||||
|
## Архитектура проекта
|
||||||
|
|
||||||
|
Перед началом работы рекомендуем ознакомиться:
|
||||||
|
|
||||||
|
- [REQUIREMENTS.md](REQUIREMENTS.md) — функциональные требования
|
||||||
|
- [CONTEXT.md](CONTEXT.md) — текущий статус и архитектурные решения
|
||||||
|
- [ROADMAP.md](ROADMAP.md) — план развития
|
||||||
|
|
||||||
|
### Структура кода
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── main.rs # Event loop, инициализация
|
||||||
|
├── config.rs # Конфигурация
|
||||||
|
├── app/ # Состояние приложения
|
||||||
|
├── ui/ # Отрисовка UI
|
||||||
|
├── input/ # Обработка ввода
|
||||||
|
├── utils.rs # Утилиты
|
||||||
|
└── tdlib/ # TDLib интеграция
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ключевые принципы
|
||||||
|
|
||||||
|
1. **Неблокирующий UI**: TDLib updates в отдельном потоке
|
||||||
|
2. **Оптимизация памяти**: LRU кеши, лимиты на коллекции
|
||||||
|
3. **Vim-style навигация**: консистентные хоткеи
|
||||||
|
4. **Graceful degradation**: fallback для отсутствующих данных
|
||||||
|
|
||||||
|
## Code of Conduct
|
||||||
|
|
||||||
|
- Будьте вежливы и уважительны
|
||||||
|
- Конструктивная критика приветствуется
|
||||||
|
- Фокус на технических аспектах
|
||||||
|
|
||||||
|
## Вопросы?
|
||||||
|
|
||||||
|
Создайте issue с меткой `question` или свяжитесь с maintainers.
|
||||||
|
|
||||||
|
## Лицензия
|
||||||
|
|
||||||
|
Внося код в этот проект, вы соглашаетесь с лицензией [MIT](LICENSE).
|
||||||
2267
Cargo.lock
generated
2267
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
18
Cargo.toml
18
Cargo.toml
@@ -2,14 +2,26 @@
|
|||||||
name = "tele-tui"
|
name = "tele-tui"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
authors = ["Your Name <your.email@example.com>"]
|
||||||
|
description = "Terminal UI for Telegram with Vim-style navigation"
|
||||||
|
license = "MIT"
|
||||||
|
repository = "https://github.com/your-username/tele-tui"
|
||||||
|
keywords = ["telegram", "tui", "terminal", "cli"]
|
||||||
|
categories = ["command-line-utilities"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
ratatui = "0.29"
|
ratatui = "0.29"
|
||||||
crossterm = "0.28"
|
crossterm = "0.28"
|
||||||
|
tdlib-rs = { version = "1.1", features = ["download-tdlib"] }
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
grammers-client = "0.7"
|
|
||||||
grammers-session = "0.7"
|
|
||||||
anyhow = "1.0"
|
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
|
dotenvy = "0.15"
|
||||||
chrono = "0.4"
|
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"] }
|
||||||
|
|||||||
105
DEVELOPMENT.md
Normal file
105
DEVELOPMENT.md
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
# Правила локальной разработки
|
||||||
|
|
||||||
|
> **Обязательно к прочтению перед началом работы!**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Инструменты
|
||||||
|
|
||||||
|
### MCP серверы
|
||||||
|
- **Serena** — для работы с кодом (символьная навигация, редактирование)
|
||||||
|
- **Context7** — для получения актуальной документации по библиотекам
|
||||||
|
|
||||||
|
Используй эти инструменты для эффективной работы с кодовой базой.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Правила работы
|
||||||
|
|
||||||
|
### 1. Никогда не запускай сервисы самостоятельно
|
||||||
|
|
||||||
|
**ЗАПРЕЩЕНО** запускать `cargo run`, `cargo build` и подобные команды.
|
||||||
|
|
||||||
|
**Вместо этого попроси пользователя запустить:**
|
||||||
|
|
||||||
|
```
|
||||||
|
Запусти, пожалуйста:
|
||||||
|
cargo run
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Тестирование — только ручное
|
||||||
|
|
||||||
|
После завершения задачи:
|
||||||
|
1. Опиши сценарии для проверки
|
||||||
|
2. Попроси пользователя проверить вручную
|
||||||
|
3. Дождись фидбека
|
||||||
|
|
||||||
|
**Формат:**
|
||||||
|
```
|
||||||
|
Готово! Проверь, пожалуйста:
|
||||||
|
|
||||||
|
1. Открой cargo run
|
||||||
|
2. понавигируйся в списке чатов кнопками h j k l
|
||||||
|
3. Нажми Enter для открытия чата
|
||||||
|
4. Убедись, что чат прогурзился
|
||||||
|
|
||||||
|
Напиши, если что-то не работает.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Работа поэтапно
|
||||||
|
|
||||||
|
Делай работу **небольшими итерациями**:
|
||||||
|
|
||||||
|
1. **Один этап = одна логическая единица**
|
||||||
|
- Один endpoint
|
||||||
|
- Один компонент
|
||||||
|
- Одна фича
|
||||||
|
|
||||||
|
2. **После каждого этапа:**
|
||||||
|
- Сообщи что сделано
|
||||||
|
- Дай сценарий проверки
|
||||||
|
- Дождись подтверждения
|
||||||
|
|
||||||
|
3. **Не делай сразу много:**
|
||||||
|
- ❌ Весь CRUD за раз
|
||||||
|
- ✅ Сначала GET, проверили, потом POST, проверили...
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Чеклист перед началом работы
|
||||||
|
|
||||||
|
- [ ] Прочитал CONTEXT.md
|
||||||
|
- [ ] Прочитал ROADMAP.md (понял текущую фазу)
|
||||||
|
- [ ] Понял задачу
|
||||||
|
- [ ] Готов работать поэтапно
|
||||||
|
- [ ] Помню: не запускаю сервисы сам, прошу пользователя
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Пример правильного workflow
|
||||||
|
|
||||||
|
```
|
||||||
|
Пользователь: Сделай endpoint для получения списка идей
|
||||||
|
|
||||||
|
Агент:
|
||||||
|
1. Читает документацию через Context7 (NestJS, TypeORM)
|
||||||
|
2. Использует Serena для навигации по коду
|
||||||
|
3. Создаёт endpoint GET /api/ideas
|
||||||
|
4. Сообщает:
|
||||||
|
|
||||||
|
"Создал endpoint GET /api/ideas.
|
||||||
|
|
||||||
|
Запусти backend:
|
||||||
|
cd backend && npm run dev
|
||||||
|
|
||||||
|
Проверь:
|
||||||
|
curl http://localhost:4001/api/ideas
|
||||||
|
|
||||||
|
Должен вернуться пустой массив: { data: [], meta: {...} }"
|
||||||
|
|
||||||
|
5. Ждёт фидбек
|
||||||
|
6. Переходит к следующему этапу
|
||||||
|
```
|
||||||
|
|
||||||
|
## Работа с git
|
||||||
|
- никогда не добавляй себя в соавторов в тексте коммита
|
||||||
227
FAQ.md
Normal file
227
FAQ.md
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
# FAQ — Часто задаваемые вопросы
|
||||||
|
|
||||||
|
## Установка и запуск
|
||||||
|
|
||||||
|
### Где получить API credentials?
|
||||||
|
|
||||||
|
1. Перейдите на https://my.telegram.org/apps
|
||||||
|
2. Войдите с вашим номером телефона
|
||||||
|
3. Создайте новое приложение
|
||||||
|
4. Скопируйте `api_id` и `api_hash`
|
||||||
|
|
||||||
|
### Где хранить credentials?
|
||||||
|
|
||||||
|
**Рекомендуется** (приоритет 1):
|
||||||
|
```bash
|
||||||
|
~/.config/tele-tui/credentials
|
||||||
|
```
|
||||||
|
|
||||||
|
**Альтернатива** (приоритет 2):
|
||||||
|
```bash
|
||||||
|
.env # в корне проекта
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ошибка "Telegram API credentials not found!"
|
||||||
|
|
||||||
|
Убедитесь, что вы создали файл credentials (см. выше) с правильным форматом:
|
||||||
|
```
|
||||||
|
API_ID=12345678
|
||||||
|
API_HASH=abcdef1234567890abcdef1234567890
|
||||||
|
```
|
||||||
|
|
||||||
|
### Где хранится сессия Telegram?
|
||||||
|
|
||||||
|
В папке `./tdlib_data/` в директории запуска приложения. Эта папка содержит:
|
||||||
|
- Токены авторизации
|
||||||
|
- Кеш сообщений
|
||||||
|
- Другие данные TDLib
|
||||||
|
|
||||||
|
**Важно**: Не удаляйте эту папку, иначе придётся заново авторизоваться.
|
||||||
|
|
||||||
|
## Использование
|
||||||
|
|
||||||
|
### Как переключаться между папками?
|
||||||
|
|
||||||
|
Нажмите клавиши `1-9` для переключения между первыми 9 папками Telegram.
|
||||||
|
|
||||||
|
### Как искать сообщения в чате?
|
||||||
|
|
||||||
|
1. Откройте чат
|
||||||
|
2. Нажмите `Ctrl+F`
|
||||||
|
3. Введите поисковый запрос
|
||||||
|
4. Используйте `n` / `N` для навигации по результатам
|
||||||
|
|
||||||
|
### Как скопировать текст сообщения?
|
||||||
|
|
||||||
|
1. При пустом поле ввода нажмите `↑` для выбора сообщения
|
||||||
|
2. Нажмите `y` (или `н` на русской раскладке)
|
||||||
|
3. Текст скопирован в системный буфер обмена
|
||||||
|
|
||||||
|
### Как ответить на сообщение?
|
||||||
|
|
||||||
|
1. Выберите сообщение (`↑` при пустом инпуте)
|
||||||
|
2. Нажмите `r` (или `к` на русской раскладке)
|
||||||
|
3. Введите ответ
|
||||||
|
4. Нажмите `Enter`
|
||||||
|
|
||||||
|
### Как удалить сообщение?
|
||||||
|
|
||||||
|
1. Выберите сообщение
|
||||||
|
2. Нажмите `d` / `в` / `Delete`
|
||||||
|
3. Подтвердите удаление: `y` / `Enter`
|
||||||
|
|
||||||
|
### Как добавить реакцию?
|
||||||
|
|
||||||
|
1. Выберите сообщение
|
||||||
|
2. Нажмите `e` (или `у` на русской раскладке)
|
||||||
|
3. Выберите emoji стрелками
|
||||||
|
4. Нажмите `Enter`
|
||||||
|
|
||||||
|
### Почему не работают хоткеи на русской раскладке?
|
||||||
|
|
||||||
|
Убедитесь, что вы используете **русскую раскладку**, а не транслит. Поддерживаемые комбинации:
|
||||||
|
- `р о л д` → `h j k l` (навигация)
|
||||||
|
- `к` → `r` (reply)
|
||||||
|
- `а` → `f` (forward)
|
||||||
|
- `в` → `d` (delete)
|
||||||
|
- `н` → `y` (copy)
|
||||||
|
- `у` → `e` (react)
|
||||||
|
|
||||||
|
## Конфигурация
|
||||||
|
|
||||||
|
### Где находится конфигурационный файл?
|
||||||
|
|
||||||
|
```bash
|
||||||
|
~/.config/tele-tui/config.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
Создаётся автоматически при первом запуске.
|
||||||
|
|
||||||
|
### Как изменить часовой пояс?
|
||||||
|
|
||||||
|
Отредактируйте `~/.config/tele-tui/config.toml`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[general]
|
||||||
|
timezone = "+05:00" # Ваш часовой пояс
|
||||||
|
```
|
||||||
|
|
||||||
|
### Как изменить цветовую схему?
|
||||||
|
|
||||||
|
Отредактируйте секцию `[colors]` в конфиге:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[colors]
|
||||||
|
incoming_message = "cyan"
|
||||||
|
outgoing_message = "lightgreen"
|
||||||
|
selected_message = "lightyellow"
|
||||||
|
```
|
||||||
|
|
||||||
|
Поддерживаемые цвета: black, red, green, yellow, blue, magenta, cyan, gray, white, darkgray, lightred, lightgreen, lightyellow, lightblue, lightmagenta, lightcyan.
|
||||||
|
|
||||||
|
### Нужно ли перезапускать приложение после изменения конфига?
|
||||||
|
|
||||||
|
Да, изменения в `config.toml` применяются только при запуске приложения.
|
||||||
|
|
||||||
|
## Проблемы
|
||||||
|
|
||||||
|
### Приложение зависает при запуске
|
||||||
|
|
||||||
|
Возможные причины:
|
||||||
|
1. **Нет интернета**: проверьте подключение
|
||||||
|
2. **TDLib не может подключиться**: проверьте firewall/прокси
|
||||||
|
3. **Неверные credentials**: проверьте API_ID и API_HASH
|
||||||
|
|
||||||
|
### Сообщения не загружаются
|
||||||
|
|
||||||
|
1. Проверьте статус сети в футере (внизу экрана)
|
||||||
|
2. Попробуйте обновить: `Ctrl+R`
|
||||||
|
3. Перезапустите приложение
|
||||||
|
|
||||||
|
### "Deleted Account" в списке чатов
|
||||||
|
|
||||||
|
Это пользователи, которые удалили свой аккаунт Telegram. Они автоматически фильтруются и не отображаются в списке.
|
||||||
|
|
||||||
|
### Не отображаются медиафайлы
|
||||||
|
|
||||||
|
Медиафайлы (фото, видео, голосовые, стикеры) отображаются как заглушки: [Фото], [Видео], [Голосовое], [Стикер]. Полная поддержка медиа может быть добавлена в будущем.
|
||||||
|
|
||||||
|
### Ошибка компиляции при сборке
|
||||||
|
|
||||||
|
**TDLib download failed**:
|
||||||
|
- Проверьте интернет-соединение
|
||||||
|
- Убедитесь, что у вас достаточно места на диске
|
||||||
|
|
||||||
|
**Linking with cc failed**:
|
||||||
|
- macOS: `xcode-select --install`
|
||||||
|
- Linux: `sudo apt-get install build-essential`
|
||||||
|
- Windows: установите Visual Studio Build Tools
|
||||||
|
|
||||||
|
### Как сбросить сессию?
|
||||||
|
|
||||||
|
Удалите папку `tdlib_data/`:
|
||||||
|
```bash
|
||||||
|
rm -rf tdlib_data/
|
||||||
|
```
|
||||||
|
|
||||||
|
При следующем запуске потребуется заново авторизоваться.
|
||||||
|
|
||||||
|
## Производительность
|
||||||
|
|
||||||
|
### Приложение тормозит
|
||||||
|
|
||||||
|
Проверьте:
|
||||||
|
1. Количество открытых чатов (лимит 200)
|
||||||
|
2. Количество сообщений в открытом чате (лимит 500)
|
||||||
|
3. Размер терминала (минимум 80x20)
|
||||||
|
|
||||||
|
Приложение автоматически очищает старые данные при достижении лимитов.
|
||||||
|
|
||||||
|
### Высокое использование памяти
|
||||||
|
|
||||||
|
Это нормально при большом количестве чатов и сообщений. Приложение использует LRU кеши с ограничениями:
|
||||||
|
- 500 пользователей в кеше
|
||||||
|
- 500 сообщений на чат
|
||||||
|
- 200 чатов
|
||||||
|
|
||||||
|
## Разработка
|
||||||
|
|
||||||
|
### Как внести вклад в проект?
|
||||||
|
|
||||||
|
См. [CONTRIBUTING.md](CONTRIBUTING.md)
|
||||||
|
|
||||||
|
### Где найти план развития?
|
||||||
|
|
||||||
|
См. [ROADMAP.md](ROADMAP.md)
|
||||||
|
|
||||||
|
### Как сообщить о баге?
|
||||||
|
|
||||||
|
Создайте issue на GitHub с описанием:
|
||||||
|
- Шаги для воспроизведения
|
||||||
|
- Ожидаемое и фактическое поведение
|
||||||
|
- Версия ОС и Rust
|
||||||
|
- Логи (если есть)
|
||||||
|
|
||||||
|
## Безопасность
|
||||||
|
|
||||||
|
### Безопасно ли хранить credentials в файле?
|
||||||
|
|
||||||
|
Да, если вы:
|
||||||
|
1. Используете `~/.config/tele-tui/credentials`
|
||||||
|
2. Установили права доступа: `chmod 600 ~/.config/tele-tui/credentials`
|
||||||
|
3. Не коммитите этот файл в git (уже в `.gitignore`)
|
||||||
|
|
||||||
|
### Что делать при компрометации credentials?
|
||||||
|
|
||||||
|
1. Удалите приложение на https://my.telegram.org/apps
|
||||||
|
2. Создайте новое приложение с новыми credentials
|
||||||
|
3. Обновите файл `credentials`
|
||||||
|
4. Удалите папку `tdlib_data/` и авторизуйтесь заново
|
||||||
|
|
||||||
|
### Включена ли двухфакторная аутентификация?
|
||||||
|
|
||||||
|
Если вы включили 2FA в Telegram, приложение запросит пароль при первой авторизации.
|
||||||
|
|
||||||
|
## Ещё вопросы?
|
||||||
|
|
||||||
|
Создайте issue на GitHub или свяжитесь с maintainers.
|
||||||
144
HOTKEYS.md
Normal file
144
HOTKEYS.md
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
# Горячие клавиши tele-tui
|
||||||
|
|
||||||
|
## Общая навигация
|
||||||
|
|
||||||
|
| Клавиша | Русская раскладка | Действие |
|
||||||
|
|---------|-------------------|----------|
|
||||||
|
| `↑` / `k` | `р` | Вверх по списку |
|
||||||
|
| `↓` / `j` | `о` | Вниз по списку |
|
||||||
|
| `Enter` | | Открыть чат / Отправить сообщение |
|
||||||
|
| `Esc` | | Закрыть чат / Отменить действие |
|
||||||
|
| `Ctrl+C` | | Выход из приложения |
|
||||||
|
| `Ctrl+R` | | Обновить список чатов |
|
||||||
|
|
||||||
|
## Папки и поиск
|
||||||
|
|
||||||
|
| Клавиша | Действие |
|
||||||
|
|---------|----------|
|
||||||
|
| `1-9` | Переключение между папками Telegram |
|
||||||
|
| `Ctrl+S` | Открыть поиск по чатам |
|
||||||
|
| `Ctrl+F` | Открыть поиск в текущем чате |
|
||||||
|
| `n` | Следующий результат поиска |
|
||||||
|
| `N` | Предыдущий результат поиска |
|
||||||
|
|
||||||
|
## Работа с сообщениями
|
||||||
|
|
||||||
|
### Навигация и выбор
|
||||||
|
|
||||||
|
| Клавиша | Действие |
|
||||||
|
|---------|----------|
|
||||||
|
| `↑/↓` | Скролл сообщений (в открытом чате) |
|
||||||
|
| `↑` | Выбор сообщения (при пустом поле ввода) |
|
||||||
|
| `Esc` | Отменить выбор |
|
||||||
|
|
||||||
|
### Действия с сообщениями
|
||||||
|
|
||||||
|
| Клавиша | Русская раскладка | Действие |
|
||||||
|
|---------|-------------------|----------|
|
||||||
|
| `Enter` | | Редактировать выбранное сообщение |
|
||||||
|
| `r` | `к` | Ответить на сообщение (Reply) |
|
||||||
|
| `f` | `а` | Переслать сообщение (Forward) |
|
||||||
|
| `d` / `Delete` | `в` | Удалить сообщение |
|
||||||
|
| `y` | `н` | Копировать текст в буфер обмена |
|
||||||
|
| `e` | `у` | Добавить реакцию (Emoji picker) |
|
||||||
|
| `i` | | Открыть профиль чата/пользователя |
|
||||||
|
|
||||||
|
## Модалки подтверждения
|
||||||
|
|
||||||
|
### Удаление сообщения
|
||||||
|
|
||||||
|
| Клавиша | Русская раскладка | Действие |
|
||||||
|
|---------|-------------------|----------|
|
||||||
|
| `y` / `Enter` | `н` | Подтвердить удаление |
|
||||||
|
| `n` / `Esc` | `т` | Отменить удаление |
|
||||||
|
|
||||||
|
## Emoji Picker (реакции)
|
||||||
|
|
||||||
|
| Клавиша | Действие |
|
||||||
|
|---------|----------|
|
||||||
|
| `←` | Влево по сетке эмодзи |
|
||||||
|
| `→` | Вправо по сетке эмодзи |
|
||||||
|
| `↑` | Вверх по сетке эмодзи |
|
||||||
|
| `↓` | Вниз по сетке эмодзи |
|
||||||
|
| `Enter` | Добавить/удалить реакцию |
|
||||||
|
| `Esc` | Закрыть emoji picker |
|
||||||
|
|
||||||
|
## Редактирование текста
|
||||||
|
|
||||||
|
### Навигация по тексту
|
||||||
|
|
||||||
|
| Клавиша | Действие |
|
||||||
|
|---------|----------|
|
||||||
|
| `←` | Курсор влево |
|
||||||
|
| `→` | Курсор вправо |
|
||||||
|
| `Home` | Курсор в начало строки |
|
||||||
|
| `End` | Курсор в конец строки |
|
||||||
|
|
||||||
|
### Редактирование
|
||||||
|
|
||||||
|
| Клавиша | Действие |
|
||||||
|
|---------|----------|
|
||||||
|
| `Backspace` | Удалить символ слева от курсора |
|
||||||
|
| `Delete` | Удалить символ справа от курсора |
|
||||||
|
| `Enter` | Новая строка / Отправить (зависит от контекста) |
|
||||||
|
|
||||||
|
## Режимы работы
|
||||||
|
|
||||||
|
### Режим списка чатов
|
||||||
|
- Навигация: `↑/↓`
|
||||||
|
- Открыть чат: `Enter`
|
||||||
|
- Поиск: `Ctrl+S`
|
||||||
|
- Папки: `1-9`
|
||||||
|
|
||||||
|
### Режим открытого чата
|
||||||
|
- Скролл: `↑/↓`
|
||||||
|
- Выбор сообщения: `↑` (при пустом инпуте)
|
||||||
|
- Поиск в чате: `Ctrl+F`
|
||||||
|
- Закрыть чат: `Esc`
|
||||||
|
|
||||||
|
### Режим выбора сообщения
|
||||||
|
- Редактировать: `Enter`
|
||||||
|
- Ответить: `r` / `к`
|
||||||
|
- Переслать: `f` / `а`
|
||||||
|
- Удалить: `d` / `в` / `Delete`
|
||||||
|
- Копировать: `y` / `н`
|
||||||
|
- Реакция: `e` / `у`
|
||||||
|
- Отменить: `Esc`
|
||||||
|
|
||||||
|
### Режим редактирования
|
||||||
|
- Редактировать текст: см. "Редактирование текста"
|
||||||
|
- Отправить: `Enter`
|
||||||
|
- Отменить: `Esc`
|
||||||
|
|
||||||
|
### Режим ответа (Reply)
|
||||||
|
- Редактировать ответ: см. "Редактирование текста"
|
||||||
|
- Отправить: `Enter`
|
||||||
|
- Отменить: `Esc`
|
||||||
|
|
||||||
|
### Режим пересылки (Forward)
|
||||||
|
- Выбрать чат: `↑/↓`
|
||||||
|
- Переслать: `Enter`
|
||||||
|
- Отменить: `Esc`
|
||||||
|
|
||||||
|
## Поддержка русской раскладки
|
||||||
|
|
||||||
|
Все основные vim-клавиши поддерживают русскую раскладку:
|
||||||
|
|
||||||
|
| Английская | Русская | Действие |
|
||||||
|
|------------|---------|----------|
|
||||||
|
| `h` | `р` | Влево |
|
||||||
|
| `j` | `о` | Вниз |
|
||||||
|
| `k` | `л` | Вверх |
|
||||||
|
| `l` | `д` | Вправо |
|
||||||
|
| `r` | `к` | Reply |
|
||||||
|
| `f` | `а` | Forward |
|
||||||
|
| `d` | `в` | Delete |
|
||||||
|
| `y` | `н` | Copy (Yank) |
|
||||||
|
| `e` | `у` | Emoji reaction |
|
||||||
|
|
||||||
|
## Подсказки
|
||||||
|
|
||||||
|
- Текущие доступные команды всегда отображаются в нижней части экрана (footer)
|
||||||
|
- При открытой модалке доступны только действия этой модалки
|
||||||
|
- `Esc` всегда отменяет текущее действие и возвращает на шаг назад
|
||||||
|
- Блочный курсор █ показывает текущую позицию при редактировании текста
|
||||||
122
INSTALL.md
Normal file
122
INSTALL.md
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
# Установка tele-tui
|
||||||
|
|
||||||
|
## Требования
|
||||||
|
|
||||||
|
- **Rust**: версия 1.70 или выше ([установить](https://rustup.rs/))
|
||||||
|
- **TDLib**: скачивается автоматически через tdlib-rs
|
||||||
|
|
||||||
|
## Шаг 1: Клонирование репозитория
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/your-username/tele-tui.git
|
||||||
|
cd tele-tui
|
||||||
|
```
|
||||||
|
|
||||||
|
## Шаг 2: Получение API credentials
|
||||||
|
|
||||||
|
1. Перейдите на https://my.telegram.org/apps
|
||||||
|
2. Войдите с вашим номером телефона
|
||||||
|
3. Создайте новое приложение
|
||||||
|
4. Скопируйте **api_id** и **api_hash**
|
||||||
|
|
||||||
|
## Шаг 3: Настройка credentials
|
||||||
|
|
||||||
|
### Вариант A: XDG config directory (рекомендуется)
|
||||||
|
|
||||||
|
Создайте файл `~/.config/tele-tui/credentials`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/.config/tele-tui
|
||||||
|
cat > ~/.config/tele-tui/credentials << EOF
|
||||||
|
API_ID=your_api_id_here
|
||||||
|
API_HASH=your_api_hash_here
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
### Вариант B: .env файл
|
||||||
|
|
||||||
|
Создайте файл `.env` в корне проекта:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp credentials.example .env
|
||||||
|
# Отредактируйте .env и вставьте ваши credentials
|
||||||
|
```
|
||||||
|
|
||||||
|
## Шаг 4: Сборка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build --release
|
||||||
|
```
|
||||||
|
|
||||||
|
## Шаг 5: Запуск
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo run --release
|
||||||
|
```
|
||||||
|
|
||||||
|
Или запустите скомпилированный бинарник:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./target/release/tele-tui
|
||||||
|
```
|
||||||
|
|
||||||
|
## Первый запуск
|
||||||
|
|
||||||
|
При первом запуске вам нужно будет:
|
||||||
|
|
||||||
|
1. Ввести номер телефона (с кодом страны, например: +79991234567)
|
||||||
|
2. Ввести код подтверждения из Telegram
|
||||||
|
3. Если включена 2FA — ввести пароль
|
||||||
|
|
||||||
|
Сессия сохраняется в `./tdlib_data/`, при следующем запуске авторизация не потребуется.
|
||||||
|
|
||||||
|
## Настройка (опционально)
|
||||||
|
|
||||||
|
Конфигурационный файл создаётся автоматически при первом запуске в `~/.config/tele-tui/config.toml`.
|
||||||
|
|
||||||
|
Вы можете отредактировать его для настройки:
|
||||||
|
- Часового пояса
|
||||||
|
- Цветовой схемы
|
||||||
|
|
||||||
|
Пример конфигурации см. в файле `config.toml.example`.
|
||||||
|
|
||||||
|
## Устранение неполадок
|
||||||
|
|
||||||
|
### "Telegram API credentials not found!"
|
||||||
|
|
||||||
|
Убедитесь, что вы создали файл credentials (см. Шаг 3).
|
||||||
|
|
||||||
|
### "error: linking with `cc` failed"
|
||||||
|
|
||||||
|
Убедитесь, что у вас установлен C компилятор:
|
||||||
|
- macOS: `xcode-select --install`
|
||||||
|
- Linux: `sudo apt-get install build-essential` (Debian/Ubuntu)
|
||||||
|
- Windows: установите Visual Studio Build Tools
|
||||||
|
|
||||||
|
### TDLib download failed
|
||||||
|
|
||||||
|
Проверьте подключение к интернету. TDLib скачивается автоматически при первой сборке.
|
||||||
|
|
||||||
|
## Обновление
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git pull
|
||||||
|
cargo build --release
|
||||||
|
```
|
||||||
|
|
||||||
|
Ваши credentials и конфигурация сохранятся.
|
||||||
|
|
||||||
|
## Удаление
|
||||||
|
|
||||||
|
Чтобы полностью удалить приложение и все данные:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Удалить проект
|
||||||
|
rm -rf tele-tui/
|
||||||
|
|
||||||
|
# Удалить конфигурацию и credentials
|
||||||
|
rm -rf ~/.config/tele-tui/
|
||||||
|
|
||||||
|
# Удалить сессию Telegram (опционально, потребуется новая авторизация)
|
||||||
|
# rm -rf ./tdlib_data/
|
||||||
|
```
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2024 tele-tui contributors
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
355
PROJECT_STRUCTURE.md
Normal file
355
PROJECT_STRUCTURE.md
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
# Структура проекта
|
||||||
|
|
||||||
|
## Обзор директорий
|
||||||
|
|
||||||
|
```
|
||||||
|
tele-tui/
|
||||||
|
├── .github/ # GitHub конфигурация
|
||||||
|
│ ├── ISSUE_TEMPLATE/ # Шаблоны для issue
|
||||||
|
│ │ ├── bug_report.md
|
||||||
|
│ │ └── feature_request.md
|
||||||
|
│ ├── workflows/ # GitHub Actions CI/CD
|
||||||
|
│ │ └── ci.yml
|
||||||
|
│ └── pull_request_template.md
|
||||||
|
│
|
||||||
|
├── docs/ # Дополнительная документация
|
||||||
|
│ └── TDLIB_INTEGRATION.md
|
||||||
|
│
|
||||||
|
├── src/ # Исходный код
|
||||||
|
│ ├── app/ # Состояние приложения
|
||||||
|
│ │ ├── mod.rs
|
||||||
|
│ │ └── state.rs
|
||||||
|
│ ├── input/ # Обработка пользовательского ввода
|
||||||
|
│ │ ├── mod.rs
|
||||||
|
│ │ ├── auth.rs
|
||||||
|
│ │ └── main_input.rs
|
||||||
|
│ ├── tdlib/ # TDLib интеграция
|
||||||
|
│ │ ├── mod.rs
|
||||||
|
│ │ └── client.rs
|
||||||
|
│ ├── ui/ # Рендеринг интерфейса
|
||||||
|
│ │ ├── mod.rs
|
||||||
|
│ │ ├── auth.rs
|
||||||
|
│ │ ├── chat_list.rs
|
||||||
|
│ │ ├── footer.rs
|
||||||
|
│ │ ├── loading.rs
|
||||||
|
│ │ ├── main_screen.rs
|
||||||
|
│ │ └── messages.rs
|
||||||
|
│ ├── config.rs # Конфигурация приложения
|
||||||
|
│ ├── main.rs # Точка входа
|
||||||
|
│ └── utils.rs # Утилиты
|
||||||
|
│
|
||||||
|
├── tdlib_data/ # TDLib сессия (НЕ коммитится)
|
||||||
|
├── target/ # Артефакты сборки (НЕ коммитится)
|
||||||
|
│
|
||||||
|
├── .editorconfig # EditorConfig для IDE
|
||||||
|
├── .gitignore # Git ignore правила
|
||||||
|
├── Cargo.lock # Зависимости (точные версии)
|
||||||
|
├── Cargo.toml # Манифест проекта
|
||||||
|
├── rustfmt.toml # Конфигурация форматирования
|
||||||
|
│
|
||||||
|
├── config.toml.example # Пример конфигурации
|
||||||
|
├── credentials.example # Пример credentials
|
||||||
|
│
|
||||||
|
├── CHANGELOG.md # История изменений
|
||||||
|
├── CLAUDE.md # Инструкции для Claude AI
|
||||||
|
├── CONTRIBUTING.md # Гайд по контрибуции
|
||||||
|
├── CONTEXT.md # Текущий статус разработки
|
||||||
|
├── DEVELOPMENT.md # Правила разработки
|
||||||
|
├── FAQ.md # Часто задаваемые вопросы
|
||||||
|
├── HOTKEYS.md # Список горячих клавиш
|
||||||
|
├── INSTALL.md # Инструкция по установке
|
||||||
|
├── LICENSE # MIT лицензия
|
||||||
|
├── PROJECT_STRUCTURE.md # Этот файл
|
||||||
|
├── README.md # Главная документация
|
||||||
|
├── REQUIREMENTS.md # Функциональные требования
|
||||||
|
├── ROADMAP.md # План развития
|
||||||
|
└── SECURITY.md # Политика безопасности
|
||||||
|
```
|
||||||
|
|
||||||
|
## Исходный код (src/)
|
||||||
|
|
||||||
|
### main.rs
|
||||||
|
**Точка входа приложения**
|
||||||
|
- Инициализация TDLib клиента
|
||||||
|
- Event loop (60 FPS)
|
||||||
|
- Обработка Ctrl+C (graceful shutdown)
|
||||||
|
- Координация между UI, input и TDLib
|
||||||
|
|
||||||
|
### config.rs
|
||||||
|
**Конфигурация приложения**
|
||||||
|
- Загрузка/сохранение TOML конфига
|
||||||
|
- Парсинг timezone и цветов
|
||||||
|
- Загрузка credentials (приоритетная система)
|
||||||
|
- XDG directory support
|
||||||
|
|
||||||
|
### utils.rs
|
||||||
|
**Утилитарные функции**
|
||||||
|
- `disable_tdlib_logs()` — отключение TDLib логов через FFI
|
||||||
|
- `format_timestamp_with_tz()` — форматирование времени с учётом timezone
|
||||||
|
- `format_date()` — форматирование дат для разделителей
|
||||||
|
- `format_datetime()` — полное форматирование даты и времени
|
||||||
|
- `format_was_online()` — "был(а) X мин. назад"
|
||||||
|
|
||||||
|
### app/ — Состояние приложения
|
||||||
|
|
||||||
|
#### mod.rs
|
||||||
|
- `App` struct — главная структура состояния
|
||||||
|
- `needs_redraw` — флаг для оптимизации рендеринга
|
||||||
|
- Состояние модалок (delete confirm, reaction picker, profile)
|
||||||
|
- Состояние поиска и черновиков
|
||||||
|
- Методы для работы с UI state
|
||||||
|
|
||||||
|
#### state.rs
|
||||||
|
- `AppScreen` enum — текущий экран (Loading, Auth, Main)
|
||||||
|
|
||||||
|
### tdlib/ — Telegram интеграция
|
||||||
|
|
||||||
|
#### client.rs
|
||||||
|
- `TdClient` — обёртка над TDLib
|
||||||
|
- Авторизация (телефон, код, 2FA)
|
||||||
|
- Загрузка чатов и сообщений
|
||||||
|
- Отправка/редактирование/удаление сообщений
|
||||||
|
- Reply, Forward
|
||||||
|
- Реакции (`ReactionInfo`)
|
||||||
|
- LRU кеши (users, statuses)
|
||||||
|
- `NetworkState` enum
|
||||||
|
|
||||||
|
#### mod.rs
|
||||||
|
- Экспорт публичных типов
|
||||||
|
|
||||||
|
### ui/ — Рендеринг интерфейса
|
||||||
|
|
||||||
|
#### mod.rs
|
||||||
|
- `render()` — роутинг по экранам
|
||||||
|
- Проверка минимального размера терминала (80x20)
|
||||||
|
|
||||||
|
#### loading.rs
|
||||||
|
- Экран "Loading..."
|
||||||
|
|
||||||
|
#### auth.rs
|
||||||
|
- Экран авторизации (ввод телефона, кода, пароля)
|
||||||
|
|
||||||
|
#### main_screen.rs
|
||||||
|
- Главный экран
|
||||||
|
- Отображение папок сверху
|
||||||
|
|
||||||
|
#### chat_list.rs
|
||||||
|
- Список чатов
|
||||||
|
- Индикаторы: 📌, 🔇, @, (N)
|
||||||
|
- Онлайн-статус (●)
|
||||||
|
- Поиск по чатам
|
||||||
|
|
||||||
|
#### messages.rs
|
||||||
|
- Область сообщений
|
||||||
|
- Группировка по дате и отправителю
|
||||||
|
- Markdown форматирование
|
||||||
|
- Реакции под сообщениями
|
||||||
|
- Emoji picker modal
|
||||||
|
- Profile modal
|
||||||
|
- Delete confirmation modal
|
||||||
|
- Pinned message
|
||||||
|
- Динамический инпут
|
||||||
|
- Блочный курсор
|
||||||
|
|
||||||
|
#### footer.rs
|
||||||
|
- Футер с командами
|
||||||
|
- Индикатор состояния сети
|
||||||
|
|
||||||
|
### input/ — Обработка ввода
|
||||||
|
|
||||||
|
#### mod.rs
|
||||||
|
- Роутинг ввода по экранам
|
||||||
|
|
||||||
|
#### auth.rs
|
||||||
|
- Обработка ввода на экране авторизации
|
||||||
|
|
||||||
|
#### main_input.rs
|
||||||
|
- Обработка ввода на главном экране
|
||||||
|
- **Важно**: порядок обработчиков имеет значение!
|
||||||
|
1. Reaction picker (Enter/Esc)
|
||||||
|
2. Delete confirmation
|
||||||
|
3. Profile modal
|
||||||
|
4. Search в чате
|
||||||
|
5. Forward mode
|
||||||
|
6. Edit/Reply mode
|
||||||
|
7. Message selection
|
||||||
|
8. Chat list
|
||||||
|
- Поддержка русской раскладки
|
||||||
|
|
||||||
|
## Конфигурационные файлы
|
||||||
|
|
||||||
|
### Cargo.toml
|
||||||
|
Манифест проекта:
|
||||||
|
- Metadata (name, version, authors, license)
|
||||||
|
- Dependencies
|
||||||
|
- Build dependencies (tdlib-rs)
|
||||||
|
|
||||||
|
### rustfmt.toml
|
||||||
|
Конфигурация `cargo fmt`:
|
||||||
|
- max_width = 100
|
||||||
|
- imports_granularity = "Crate"
|
||||||
|
- Стиль комментариев
|
||||||
|
|
||||||
|
### .editorconfig
|
||||||
|
Универсальные настройки для IDE:
|
||||||
|
- Unix line endings (LF)
|
||||||
|
- UTF-8 encoding
|
||||||
|
- Отступы (4 spaces для Rust)
|
||||||
|
|
||||||
|
## Рантайм файлы
|
||||||
|
|
||||||
|
### tdlib_data/
|
||||||
|
Создаётся автоматически TDLib:
|
||||||
|
- Токены авторизации
|
||||||
|
- Кеш сообщений и файлов
|
||||||
|
- **НЕ коммитится** (в .gitignore)
|
||||||
|
- **НЕ делиться** (содержит чувствительные данные)
|
||||||
|
|
||||||
|
### ~/.config/tele-tui/
|
||||||
|
XDG config directory:
|
||||||
|
- `config.toml` — пользовательская конфигурация
|
||||||
|
- `credentials` — API_ID и API_HASH
|
||||||
|
|
||||||
|
## Документация
|
||||||
|
|
||||||
|
### Пользовательская
|
||||||
|
- **README.md** — главная страница, overview
|
||||||
|
- **INSTALL.md** — установка и настройка
|
||||||
|
- **HOTKEYS.md** — все горячие клавиши
|
||||||
|
- **FAQ.md** — часто задаваемые вопросы
|
||||||
|
|
||||||
|
### Разработчика
|
||||||
|
- **CONTRIBUTING.md** — как внести вклад
|
||||||
|
- **DEVELOPMENT.md** — правила разработки
|
||||||
|
- **PROJECT_STRUCTURE.md** — этот файл
|
||||||
|
- **ROADMAP.md** — план развития
|
||||||
|
- **CONTEXT.md** — текущий статус, архитектурные решения
|
||||||
|
|
||||||
|
### Спецификации
|
||||||
|
- **REQUIREMENTS.md** — функциональные требования
|
||||||
|
- **CHANGELOG.md** — история изменений
|
||||||
|
- **SECURITY.md** — политика безопасности
|
||||||
|
|
||||||
|
### Внутренняя
|
||||||
|
- **CLAUDE.md** — инструкции для AI ассистента
|
||||||
|
- **docs/TDLIB_INTEGRATION.md** — детали интеграции TDLib
|
||||||
|
|
||||||
|
## Ключевые концепции
|
||||||
|
|
||||||
|
### Архитектура
|
||||||
|
- **Event-driven**: TDLib updates → mpsc channel → main loop
|
||||||
|
- **Unidirectional data flow**: TDLib → App state → UI rendering
|
||||||
|
- **Modal stacking**: приоритет обработки ввода для модалок
|
||||||
|
|
||||||
|
### Оптимизации
|
||||||
|
- **needs_redraw**: рендеринг только при изменениях
|
||||||
|
- **LRU caches**: user_names, user_statuses (500 записей)
|
||||||
|
- **Limits**: 500 messages/chat, 200 chats
|
||||||
|
- **Lazy loading**: users загружаются батчами (5 за цикл)
|
||||||
|
|
||||||
|
### Состояние
|
||||||
|
```
|
||||||
|
App {
|
||||||
|
screen: AppScreen,
|
||||||
|
config: Config,
|
||||||
|
needs_redraw: bool,
|
||||||
|
|
||||||
|
// TDLib state
|
||||||
|
chats: Vec<Chat>,
|
||||||
|
folders: Vec<Folder>,
|
||||||
|
|
||||||
|
// UI state
|
||||||
|
selected_chat_id: Option<i64>,
|
||||||
|
input_text: String,
|
||||||
|
cursor_position: usize,
|
||||||
|
|
||||||
|
// Modals
|
||||||
|
is_delete_confirmation: bool,
|
||||||
|
is_reaction_picker_mode: bool,
|
||||||
|
profile_info: Option<ProfileInfo>,
|
||||||
|
|
||||||
|
// Search
|
||||||
|
search_query: String,
|
||||||
|
search_results: Vec<i64>,
|
||||||
|
|
||||||
|
// Drafts
|
||||||
|
drafts: HashMap<i64, String>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Потоки выполнения
|
||||||
|
|
||||||
|
### Main thread
|
||||||
|
- Event loop (16ms tick для 60 FPS)
|
||||||
|
- UI rendering
|
||||||
|
- Input handling
|
||||||
|
- App state updates
|
||||||
|
|
||||||
|
### TDLib thread
|
||||||
|
- `td_client.receive()` в отдельном Tokio task
|
||||||
|
- Updates отправляются через `mpsc::channel`
|
||||||
|
- Неблокирующий для main thread
|
||||||
|
|
||||||
|
### Blocking operations
|
||||||
|
- Загрузка конфига (при запуске)
|
||||||
|
- Авторизация (блокирует до ввода кода)
|
||||||
|
- Graceful shutdown (2 sec timeout)
|
||||||
|
|
||||||
|
## Зависимости
|
||||||
|
|
||||||
|
### UI
|
||||||
|
- `ratatui` 0.29 — TUI framework
|
||||||
|
- `crossterm` 0.28 — terminal control
|
||||||
|
|
||||||
|
### Telegram
|
||||||
|
- `tdlib-rs` 1.1 — TDLib bindings
|
||||||
|
- `tokio` 1.x — async runtime
|
||||||
|
|
||||||
|
### Data
|
||||||
|
- `serde` + `serde_json` 1.0 — serialization
|
||||||
|
- `toml` 0.8 — config parsing
|
||||||
|
- `chrono` 0.4 — date/time
|
||||||
|
|
||||||
|
### System
|
||||||
|
- `dirs` 5.0 — XDG directories
|
||||||
|
- `arboard` 3.4 — clipboard
|
||||||
|
- `open` 5.0 — открытие URL/файлов
|
||||||
|
- `dotenvy` 0.15 — .env файлы
|
||||||
|
|
||||||
|
## Workflow разработки
|
||||||
|
|
||||||
|
1. Изучить [ROADMAP.md](ROADMAP.md) — понять текущую фазу
|
||||||
|
2. Прочитать [DEVELOPMENT.md](DEVELOPMENT.md) — правила работы
|
||||||
|
3. Изучить [CONTEXT.md](CONTEXT.md) — архитектурные решения
|
||||||
|
4. Найти issue или создать новую фичу
|
||||||
|
5. Создать feature branch
|
||||||
|
6. Внести изменения
|
||||||
|
7. `cargo fmt` + `cargo clippy`
|
||||||
|
8. Протестировать вручную
|
||||||
|
9. Создать PR с описанием
|
||||||
|
|
||||||
|
## CI/CD
|
||||||
|
|
||||||
|
### GitHub Actions (.github/workflows/ci.yml)
|
||||||
|
- **Check**: `cargo check`
|
||||||
|
- **Format**: `cargo fmt --check`
|
||||||
|
- **Clippy**: `cargo clippy`
|
||||||
|
- **Build**: для Ubuntu, macOS, Windows
|
||||||
|
|
||||||
|
Запускается на:
|
||||||
|
- Push в `main` или `develop`
|
||||||
|
- Pull requests
|
||||||
|
|
||||||
|
## Безопасность
|
||||||
|
|
||||||
|
### Чувствительные файлы (в .gitignore)
|
||||||
|
- `.env`
|
||||||
|
- `credentials`
|
||||||
|
- `config.toml` (если в корне проекта)
|
||||||
|
- `tdlib_data/`
|
||||||
|
- `target/`
|
||||||
|
|
||||||
|
### Рекомендации
|
||||||
|
- Credentials в `~/.config/tele-tui/credentials`
|
||||||
|
- Права доступа: `chmod 600 ~/.config/tele-tui/credentials`
|
||||||
|
- Никогда не коммитить `tdlib_data/`
|
||||||
163
README.md
Normal file
163
README.md
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
# tele-tui
|
||||||
|
|
||||||
|
[](https://opensource.org/licenses/MIT)
|
||||||
|
[](https://www.rust-lang.org/)
|
||||||
|
|
||||||
|
Консольный Telegram клиент с Vim-style навигацией.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Возможности
|
||||||
|
|
||||||
|
- **Полная интеграция с 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
|
||||||
131
REQUIREMENTS.md
Normal file
131
REQUIREMENTS.md
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
# TTUI - Требование к приложению
|
||||||
|
|
||||||
|
## Описание приложения
|
||||||
|
|
||||||
|
Терминальный интерфейс для telegram
|
||||||
|
|
||||||
|
## Функциональные требования
|
||||||
|
|
||||||
|
### Интерфейс
|
||||||
|
|
||||||
|
┌─ TTUI ───────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ 1:All │ 2:Personal │ 3:Work │ 4:Bots │
|
||||||
|
├──────────────────────┬───────────────────────────────────────────────────────┤
|
||||||
|
│ 🔍 Search... │ 👤 Mom (online) │
|
||||||
|
├──────────────────────┼───────────────────────────────────────────────────────┤
|
||||||
|
│ 📌 Saved Messages │ Today, Dec 21│
|
||||||
|
│ ▌ Mom (2)│ │
|
||||||
|
│ Boss │ Mom ────────────────────────────────────────── 14:20 │
|
||||||
|
│ Rust Community │ Привет! Ты покормил кота? │
|
||||||
|
│ Durov │ │
|
||||||
|
│ News Channel │ You ─────────────────────────────────────── 14:22 ✓✓ │
|
||||||
|
│ Spam Bot │ Да, конечно. Купил ему корм. │
|
||||||
|
│ Wife │ Скоро буду дома. │
|
||||||
|
│ Team Lead │ │
|
||||||
|
│ DevOps Chat (9)│ Mom ────────────────────────────────────────── 14:23 │
|
||||||
|
│ Server Alerts │ Отлично, захвати хлеба. │
|
||||||
|
│ Gym Bro │ │
|
||||||
|
│ Design Team │ You ─────────────────────────────────────── 14:25 ✓ │
|
||||||
|
│ Project X │ Ок. │
|
||||||
|
│ HR │ │
|
||||||
|
│ Mom's Friend │ │
|
||||||
|
│ Taxi Bot │ │
|
||||||
|
│ │ │
|
||||||
|
│ │ │
|
||||||
|
│ │ │
|
||||||
|
│ │ │
|
||||||
|
│ │ │
|
||||||
|
├──────────────────────┼───────────────────────────────────────────────────────┤
|
||||||
|
│ [User: Online] │ > **message** │
|
||||||
|
└──────────────────────┴───────────────────────────────────────────────────────┘
|
||||||
|
**commands**
|
||||||
|
|
||||||
|
|
||||||
|
### Список желаемого
|
||||||
|
1) футер - список папок в телеграме
|
||||||
|
2) список с чатами - лички и группы, сверху инпут для поиска чата
|
||||||
|
3) основной контент - открытый чат с сообщениями из чата, если никакой чат не открыт, то контент пустой, ничего не показываем. Снизу - инпут для ввода сообщения в чат, который открыт
|
||||||
|
4) снизу списка чата статус онлайн или нет сам пользователь приложения
|
||||||
|
5) при открытии чата должна загружаться история чата, а так же подгружаться новые сообщения от собеседника.
|
||||||
|
6) выделяем сообщения собеседника его никнеймом, группируем его сообщения и разделяем наши сообщения и сообщения собеседника, как на интерфейсе сверху
|
||||||
|
7) отображаем наше сообщение символом `✓`, если телеграм подтвердил, что сообщение отправлено, и выделяем `✓✓` если собеседник прочитал его
|
||||||
|
8) при навигации в чате выделяем строку курсивом, при выборе чата (то есть его открытии) ставим в начало символ ▌
|
||||||
|
9) `(2)` — счетчик непрочитанных (можно красить в красный/зеленый).
|
||||||
|
10) `muted` — статус чата (серый цвет).
|
||||||
|
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 управление
|
||||||
|
3) стрелки - управление, так же как и "h j k l"
|
||||||
|
4) "command + 1", "command + 2" и так далее - переключение между папками, которые созданы в телеграме
|
||||||
|
5) из интерфейса "**message**" - это инпут для ввода сообщения в открытый чат
|
||||||
|
6) ctrl + s - фокус в инпут поиска чата
|
||||||
|
7) Esc - закрытие открытого чата
|
||||||
|
8) command + стрелка вверх (или ctrl + k) - выделяем самый верхний чат (без открытия)
|
||||||
|
9) поддержка русской раскладки: "р о л д" соответствует "h j k l"
|
||||||
|
10) Ctrl+R - обновить список чатов
|
||||||
|
|
||||||
|
### Реализованные команды
|
||||||
|
|
||||||
|
#### Навигация
|
||||||
|
```
|
||||||
|
↑/↓ или 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 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 файлов
|
||||||
|
|
||||||
|
## Нефункциональные требования
|
||||||
|
|
||||||
|
### Производительность
|
||||||
|
1) программа должна выдавать 60 фпс
|
||||||
|
2) интерфейс не должен мерцать
|
||||||
|
3) минимальное разрешение - 600 символов, максимального нет, не ограничиваем
|
||||||
145
ROADMAP.md
Normal file
145
ROADMAP.md
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
# Roadmap
|
||||||
|
|
||||||
|
## Фаза 1: Базовая инфраструктура [DONE]
|
||||||
|
|
||||||
|
- [x] Настройка проекта (Cargo.toml)
|
||||||
|
- [x] TUI фреймворк (ratatui + crossterm)
|
||||||
|
- [x] Базовый layout (папки, список чатов, область сообщений)
|
||||||
|
- [x] Vim-style навигация (hjkl, стрелки)
|
||||||
|
- [x] Русская раскладка (ролд)
|
||||||
|
|
||||||
|
## Фаза 2: TDLib интеграция [DONE]
|
||||||
|
|
||||||
|
- [x] Подключение tdlib-rs
|
||||||
|
- [x] Авторизация (телефон + код + 2FA)
|
||||||
|
- [x] Сохранение сессии
|
||||||
|
- [x] Загрузка списка чатов
|
||||||
|
- [x] Загрузка истории сообщений
|
||||||
|
- [x] Отключение логов TDLib
|
||||||
|
|
||||||
|
## Фаза 3: Улучшение UX [DONE]
|
||||||
|
|
||||||
|
- [x] Отправка сообщений
|
||||||
|
- [x] Фильтрация чатов (только Main, без архива)
|
||||||
|
- [x] Поиск по чатам (Ctrl+S)
|
||||||
|
- [x] Скролл истории сообщений
|
||||||
|
- [x] Загрузка имён пользователей (вместо User_ID)
|
||||||
|
- [x] Отметка сообщений как прочитанные
|
||||||
|
- [x] Реальное время: новые сообщения
|
||||||
|
|
||||||
|
## Фаза 4: Папки и фильтрация [DONE]
|
||||||
|
|
||||||
|
- [x] Загрузка папок из Telegram
|
||||||
|
- [x] Переключение между папками (1-9)
|
||||||
|
- [x] Фильтрация чатов по папке
|
||||||
|
|
||||||
|
## Фаза 5: Расширенный функционал [DONE]
|
||||||
|
|
||||||
|
- [x] Отображение онлайн-статуса (зелёная точка ●)
|
||||||
|
- [x] Статус доставки/прочтения (✓, ✓✓)
|
||||||
|
- [x] Поддержка медиа-заглушек (фото, видео, голосовые, стикеры и др.)
|
||||||
|
- [x] Mentions (@) — индикатор непрочитанных упоминаний
|
||||||
|
- [x] Muted чаты (иконка 🔇)
|
||||||
|
|
||||||
|
## Фаза 6: Полировка [DONE]
|
||||||
|
|
||||||
|
- [x] Оптимизация использования памяти (базовая)
|
||||||
|
- Очистка сообщений при закрытии чата
|
||||||
|
- Лимит кэша пользователей (500)
|
||||||
|
- Периодическая очистка неактивных записей
|
||||||
|
- [x] Оптимизация 60 FPS
|
||||||
|
- Poll таймаут 16ms
|
||||||
|
- Флаг `needs_redraw` — рендеринг только при изменениях
|
||||||
|
- Обработка Event::Resize для перерисовки при изменении размера
|
||||||
|
- [x] Минимальное разрешение (80x20)
|
||||||
|
- Предупреждение если терминал слишком мал
|
||||||
|
- [x] Обработка ошибок сети
|
||||||
|
- NetworkState enum (WaitingForNetwork, Connecting, etc.)
|
||||||
|
- Индикатор в футере с цветовой индикацией
|
||||||
|
- [x] Graceful shutdown
|
||||||
|
- AtomicBool флаг для остановки polling
|
||||||
|
- Корректное закрытие TDLib клиента
|
||||||
|
- Таймаут ожидания завершения задач
|
||||||
|
- [x] Динамический инпут
|
||||||
|
- Автоматическое расширение до 10 строк
|
||||||
|
- Wrap для длинного текста
|
||||||
|
- [x] Перенос длинных сообщений
|
||||||
|
- Автоматический wrap на несколько строк
|
||||||
|
- Правильное выравнивание для исходящих/входящих
|
||||||
|
|
||||||
|
## Фаза 7: Глубокий рефакторинг памяти [DONE]
|
||||||
|
|
||||||
|
- [x] Удалить дублирование current_messages между App и TdClient
|
||||||
|
- [x] Использовать единый источник данных для сообщений
|
||||||
|
- [x] Реализовать LRU-кэш для user_names/user_statuses вместо простого лимита
|
||||||
|
- [x] Lazy loading для имён пользователей (батчевая загрузка последних 5 за цикл)
|
||||||
|
- [x] Лимиты памяти:
|
||||||
|
- MAX_MESSAGES_IN_CHAT = 500
|
||||||
|
- MAX_CHATS = 200
|
||||||
|
- MAX_CHAT_USER_IDS = 500
|
||||||
|
- MAX_USER_CACHE_SIZE = 500 (LRU)
|
||||||
|
|
||||||
|
## Фаза 8: Дополнительные фичи [DONE]
|
||||||
|
|
||||||
|
- [x] Markdown форматирование в сообщениях
|
||||||
|
- Bold, Italic, Underline, Strikethrough
|
||||||
|
- Code (inline, Pre, PreCode)
|
||||||
|
- Spoiler (скрытый текст)
|
||||||
|
- URLs, упоминания (@)
|
||||||
|
- [x] Редактирование сообщений
|
||||||
|
- ↑ при пустом инпуте → выбор сообщения
|
||||||
|
- Enter для начала редактирования
|
||||||
|
- Подсветка выбранного сообщения (▶)
|
||||||
|
- Esc для отмены
|
||||||
|
- [x] Удаление сообщений
|
||||||
|
- d / в / Delete в режиме выбора
|
||||||
|
- Модалка подтверждения (y/n)
|
||||||
|
- Удаление для всех если возможно
|
||||||
|
- [x] Индикатор редактирования (✎)
|
||||||
|
- Отображается рядом с временем для отредактированных сообщений
|
||||||
|
- [x] Блочный курсор в поле ввода
|
||||||
|
- Vim-style курсор █
|
||||||
|
- Перемещение ←/→, Home/End
|
||||||
|
- Редактирование в любой позиции
|
||||||
|
- [x] Reply на сообщения
|
||||||
|
- `r` / `к` в режиме выбора → режим ответа
|
||||||
|
- Превью сообщения в поле ввода
|
||||||
|
- Esc для отмены
|
||||||
|
- [x] Forward сообщений
|
||||||
|
- `f` / `а` в режиме выбора → режим пересылки
|
||||||
|
- Превью сообщения в поле ввода
|
||||||
|
- Выбор чата стрелками, Enter для пересылки
|
||||||
|
- Esc для отмены
|
||||||
|
- Отображение "↪ Переслано от" для пересланных сообщений
|
||||||
|
|
||||||
|
## Фаза 9: Расширенные возможности [DONE]
|
||||||
|
|
||||||
|
- [x] Typing indicator ("печатает...")
|
||||||
|
- Показывать когда собеседник печатает
|
||||||
|
- Отправлять свой статус печати при наборе текста
|
||||||
|
- [x] Закреплённые сообщения (Pinned)
|
||||||
|
- Отображать pinned message вверху открытого чата
|
||||||
|
- Клик/хоткей для перехода к закреплённому сообщению
|
||||||
|
- [x] Поиск по сообщениям в чате
|
||||||
|
- `Ctrl+F` — поиск текста внутри открытого чата
|
||||||
|
- Навигация по результатам (n/N или стрелки)
|
||||||
|
- Подсветка найденных совпадений
|
||||||
|
- [x] Черновики
|
||||||
|
- Сохранять набранный текст при переключении между чатами
|
||||||
|
- Индикатор черновика в списке чатов
|
||||||
|
- Восстановление текста при возврате в чат
|
||||||
|
- [x] Профиль пользователя/чата
|
||||||
|
- `i` — открыть информацию о чате/собеседнике
|
||||||
|
- Для личных чатов: имя, username, телефон, био
|
||||||
|
- Для групп: название, описание, количество участников
|
||||||
|
- [x] Копирование сообщений
|
||||||
|
- `y` / `н` в режиме выбора — скопировать текст в системный буфер обмена
|
||||||
|
- Использовать clipboard crate для кроссплатформенности
|
||||||
|
- [x] Реакции
|
||||||
|
- Отображение реакций под сообщениями
|
||||||
|
- `e` в режиме выбора — добавить реакцию (emoji picker)
|
||||||
|
- Список доступных реакций чата
|
||||||
|
- [x] Конфигурационный файл
|
||||||
|
- `~/.config/tele-tui/config.toml`
|
||||||
|
- Настройки: цветовая схема, часовой пояс, хоткеи
|
||||||
|
- Загрузка конфига при старте
|
||||||
64
SECURITY.md
Normal file
64
SECURITY.md
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
# Security Policy
|
||||||
|
|
||||||
|
## Поддерживаемые версии
|
||||||
|
|
||||||
|
| Версия | Поддержка |
|
||||||
|
| ------ | ------------------ |
|
||||||
|
| 0.1.x | :white_check_mark: |
|
||||||
|
|
||||||
|
## Сообщить об уязвимости
|
||||||
|
|
||||||
|
Если вы обнаружили уязвимость в безопасности, пожалуйста:
|
||||||
|
|
||||||
|
1. **НЕ создавайте публичный issue**
|
||||||
|
2. Отправьте email на: [security@example.com]
|
||||||
|
3. Опишите:
|
||||||
|
- Тип уязвимости
|
||||||
|
- Шаги для воспроизведения
|
||||||
|
- Потенциальное влияние
|
||||||
|
- Предложения по исправлению (если есть)
|
||||||
|
|
||||||
|
## Безопасность credentials
|
||||||
|
|
||||||
|
### Важно
|
||||||
|
|
||||||
|
- **НИКОГДА** не коммитьте файлы с credentials в git
|
||||||
|
- Файлы `credentials` и `.env` уже добавлены в `.gitignore`
|
||||||
|
- TDLib сессия в `tdlib_data/` содержит токены авторизации — НЕ делитесь этой папкой
|
||||||
|
|
||||||
|
### Рекомендации
|
||||||
|
|
||||||
|
1. **Используйте XDG config directory**:
|
||||||
|
- Храните credentials в `~/.config/tele-tui/credentials`
|
||||||
|
- Установите права доступа: `chmod 600 ~/.config/tele-tui/credentials`
|
||||||
|
|
||||||
|
2. **Защита сессии**:
|
||||||
|
- Папка `tdlib_data/` содержит вашу авторизованную сессию
|
||||||
|
- Не копируйте её на другие машины
|
||||||
|
- При компрометации — удалите папку и авторизуйтесь заново
|
||||||
|
|
||||||
|
3. **Двухфакторная аутентификация**:
|
||||||
|
- Настоятельно рекомендуется включить 2FA в Telegram
|
||||||
|
- Это защитит ваш аккаунт даже при утечке API credentials
|
||||||
|
|
||||||
|
## Известные ограничения
|
||||||
|
|
||||||
|
### TDLib
|
||||||
|
- Приложение использует официальную библиотеку TDLib от Telegram
|
||||||
|
- Безопасность зависит от актуальности TDLib (автообновление через tdlib-rs)
|
||||||
|
|
||||||
|
### Конфигурация
|
||||||
|
- Конфигурационный файл `config.toml` НЕ содержит чувствительных данных
|
||||||
|
- Credentials хранятся отдельно в файле `credentials`
|
||||||
|
|
||||||
|
## Обновления безопасности
|
||||||
|
|
||||||
|
Мы оперативно реагируем на сообщения об уязвимостях и выпускаем патчи в течение:
|
||||||
|
- **Критические**: 24-48 часов
|
||||||
|
- **Высокие**: 3-7 дней
|
||||||
|
- **Средние**: 2-4 недели
|
||||||
|
- **Низкие**: включаются в следующий релиз
|
||||||
|
|
||||||
|
## Спасибо
|
||||||
|
|
||||||
|
Мы ценим ваш вклад в безопасность проекта!
|
||||||
31
config.toml.example
Normal file
31
config.toml.example
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# tele-tui configuration file example
|
||||||
|
#
|
||||||
|
# Этот файл автоматически создаётся при первом запуске в ~/.config/tele-tui/config.toml
|
||||||
|
# Скопируйте его туда и настройте по своему усмотрению
|
||||||
|
|
||||||
|
[general]
|
||||||
|
# Часовой пояс в формате "+03:00" или "-05:00"
|
||||||
|
# Применяется к отображению времени сообщений
|
||||||
|
timezone = "+03:00"
|
||||||
|
|
||||||
|
[colors]
|
||||||
|
# Цветовая схема интерфейса
|
||||||
|
# Поддерживаемые цвета:
|
||||||
|
# - Основные: black, red, green, yellow, blue, magenta, cyan, gray, white
|
||||||
|
# - Светлые: lightred, lightgreen, lightyellow, lightblue, lightmagenta, lightcyan
|
||||||
|
# - Тёмные: darkgray
|
||||||
|
|
||||||
|
# Цвет входящих сообщений
|
||||||
|
incoming_message = "white"
|
||||||
|
|
||||||
|
# Цвет исходящих сообщений
|
||||||
|
outgoing_message = "green"
|
||||||
|
|
||||||
|
# Цвет выбранного сообщения
|
||||||
|
selected_message = "yellow"
|
||||||
|
|
||||||
|
# Цвет своих реакций (отображаются в рамках [👍])
|
||||||
|
reaction_chosen = "yellow"
|
||||||
|
|
||||||
|
# Цвет чужих реакций
|
||||||
|
reaction_other = "gray"
|
||||||
10
credentials.example
Normal file
10
credentials.example
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# Telegram API Credentials
|
||||||
|
#
|
||||||
|
# Получите эти данные на https://my.telegram.org/apps
|
||||||
|
# Создайте приложение и скопируйте api_id и api_hash
|
||||||
|
#
|
||||||
|
# Этот файл должен быть размещён в ~/.config/tele-tui/credentials
|
||||||
|
# Альтернативно можно использовать .env файл в директории проекта
|
||||||
|
|
||||||
|
API_ID=your_api_id_here
|
||||||
|
API_HASH=your_api_hash_here
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
Что нужно сделать - telegram TUI, то есть terminal user interface для телеграма
|
|
||||||
Ограничения технологий - используем rust-lang, TUI делаем на ratatui, используем telegram api для клиентских приложений
|
|
||||||
|
|
||||||
Интерфейс -
|
|
||||||
|
|
||||||
┌─ Telegram TUI ───────────────────────────────────────────────────────────────┐
|
|
||||||
│ 1:All │ 2:Personal │ 3:Work │ 4:Bots │
|
|
||||||
├──────────────────────┬───────────────────────────────────────────────────────┤
|
|
||||||
│ 🔍 Search... │ 👤 Mom (online) │
|
|
||||||
├──────────────────────┼───────────────────────────────────────────────────────┤
|
|
||||||
│ 📌 Saved Messages │ Today, Dec 21│
|
|
||||||
│ ▌ Mom (2)│ │
|
|
||||||
│ Boss │ Mom ────────────────────────────────────────── 14:20 │
|
|
||||||
│ Rust Community │ Привет! Ты покормил кота? │
|
|
||||||
│ Durov │ │
|
|
||||||
│ News Channel │ You ─────────────────────────────────────── 14:22 ✓✓ │
|
|
||||||
│ Spam Bot │ Да, конечно. Купил ему корм. │
|
|
||||||
│ Wife │ Скоро буду дома. │
|
|
||||||
│ Team Lead │ │
|
|
||||||
│ DevOps Chat (9)│ Mom ────────────────────────────────────────── 14:23 │
|
|
||||||
│ Server Alerts │ Отлично, захвати хлеба. │
|
|
||||||
│ Gym Bro │ │
|
|
||||||
│ Design Team │ You ─────────────────────────────────────── 14:25 ✓ │
|
|
||||||
│ Project X │ Ок. │
|
|
||||||
│ HR │ │
|
|
||||||
│ Mom's Friend │ │
|
|
||||||
│ Taxi Bot │ │
|
|
||||||
│ │ │
|
|
||||||
│ │ │
|
|
||||||
│ │ │
|
|
||||||
│ │ │
|
|
||||||
│ │ │
|
|
||||||
├──────────────────────┼───────────────────────────────────────────────────────┤
|
|
||||||
│ [User: Online] │ > Ок, скоро буд_ │
|
|
||||||
└──────────────────────┴───────────────────────────────────────────────────────┘
|
|
||||||
Esc: Back | Enter: Open | ^R: Reply | ^E: Edit | ^D: Delete
|
|
||||||
|
|
||||||
|
|
||||||
Так же еще добавляем:
|
|
||||||
1) Авторизацию через Telegram
|
|
||||||
636
docs/TDLIB_INTEGRATION.md
Normal file
636
docs/TDLIB_INTEGRATION.md
Normal file
@@ -0,0 +1,636 @@
|
|||||||
|
# Интеграция TDLib в Telegram TUI
|
||||||
|
|
||||||
|
## Обзор
|
||||||
|
|
||||||
|
TDLib (Telegram Database Library) — это официальная кроссплатформенная библиотека для создания Telegram клиентов. Она предоставляет полный функционал Telegram API с автоматическим управлением сессиями, кэшированием и синхронизацией.
|
||||||
|
|
||||||
|
## Выбор библиотеки для Rust
|
||||||
|
|
||||||
|
Существует несколько Rust-оберток для TDLib:
|
||||||
|
|
||||||
|
### 1. rust-tdlib
|
||||||
|
- **GitHub**: [antonio-antuan/rust-tdlib](https://github.com/antonio-antuan/rust-tdlib)
|
||||||
|
- **docs.rs**: https://docs.rs/rust-tdlib
|
||||||
|
- **Особенности**:
|
||||||
|
- Async/await с tokio
|
||||||
|
- Client/Worker архитектура
|
||||||
|
- Требует предварительной сборки TDLib
|
||||||
|
|
||||||
|
### 2. tdlib-rs (Рекомендуется)
|
||||||
|
- **GitHub**: [FedericoBruzzone/tdlib-rs](https://github.com/FedericoBruzzone/tdlib-rs)
|
||||||
|
- **crates.io**: https://crates.io/crates/tdlib-rs
|
||||||
|
- **docs.rs**: https://docs.rs/tdlib/latest/tdlib/
|
||||||
|
- **Преимущества**:
|
||||||
|
- ✅ Не требует предварительной установки TDLib
|
||||||
|
- ✅ Кроссплатформенность (Windows, Linux, macOS)
|
||||||
|
- ✅ Автоматическая загрузка прекомпилированных бинарников
|
||||||
|
- ✅ Поддержка TDLib v1.8.29
|
||||||
|
- ✅ Автогенерация типов из TL схемы
|
||||||
|
|
||||||
|
## Установка tdlib-rs
|
||||||
|
|
||||||
|
### Вариант 1: Автоматическая загрузка (Рекомендуется)
|
||||||
|
|
||||||
|
**Cargo.toml:**
|
||||||
|
```toml
|
||||||
|
[dependencies]
|
||||||
|
tdlib-rs = { version = "0.3", features = ["download-tdlib"] }
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tdlib-rs = { version = "0.3", features = ["download-tdlib"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
**build.rs:**
|
||||||
|
```rust
|
||||||
|
fn main() {
|
||||||
|
tdlib_rs::build::build(None);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Вариант 2: Локальная установка TDLib
|
||||||
|
|
||||||
|
Если TDLib уже установлен (версия 1.8.29):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export LOCAL_TDLIB_PATH=$HOME/lib/tdlib
|
||||||
|
```
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[dependencies]
|
||||||
|
tdlib-rs = { version = "0.3", features = ["local-tdlib"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Вариант 3: Через pkg-config
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export PKG_CONFIG_PATH=$HOME/lib/tdlib/lib/pkgconfig/:$PKG_CONFIG_PATH
|
||||||
|
export LD_LIBRARY_PATH=$HOME/lib/tdlib/lib/:$LD_LIBRARY_PATH
|
||||||
|
```
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[dependencies]
|
||||||
|
tdlib-rs = { version = "0.3", features = ["pkg-config"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Архитектура TDLib
|
||||||
|
|
||||||
|
### Основные компоненты
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ Your Application │
|
||||||
|
├─────────────────────────────────────────────────────────┤
|
||||||
|
│ ┌────────────┐ ┌──────────────┐ ┌────────────────┐ │
|
||||||
|
│ │ Client │ │ Update Stream │ │ API Requests │ │
|
||||||
|
│ └────────────┘ └──────────────┘ └────────────────┘ │
|
||||||
|
├─────────────────────────────────────────────────────────┤
|
||||||
|
│ TDLib Client │
|
||||||
|
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||||
|
│ │ Auth State │ │ Local Cache │ │ API Handler │ │
|
||||||
|
│ └──────────────┘ └──────────────┘ └──────────────┘ │
|
||||||
|
├─────────────────────────────────────────────────────────┤
|
||||||
|
│ Telegram Servers │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Поток работы
|
||||||
|
|
||||||
|
1. **Инициализация** → TDLib запускается с параметрами
|
||||||
|
2. **Авторизация** → Проход через стейт-машину авторизации
|
||||||
|
3. **Синхронизация** → Загрузка базовых данных (чаты, контакты)
|
||||||
|
4. **Updates Stream** → Постоянный поток обновлений от сервера
|
||||||
|
5. **API Requests** → Запросы на получение данных / отправку сообщений
|
||||||
|
|
||||||
|
## Процесс авторизации
|
||||||
|
|
||||||
|
### Стейт-машина авторизации
|
||||||
|
|
||||||
|
TDLib работает через систему состояний. Приложение получает обновления `updateAuthorizationState` и реагирует на них:
|
||||||
|
|
||||||
|
```
|
||||||
|
authorizationStateWaitTdlibParameters
|
||||||
|
↓ (вызываем setTdlibParameters)
|
||||||
|
authorizationStateWaitPhoneNumber
|
||||||
|
↓ (вызываем setAuthenticationPhoneNumber)
|
||||||
|
authorizationStateWaitCode
|
||||||
|
↓ (вызываем checkAuthenticationCode)
|
||||||
|
authorizationStateWaitPassword (опционально, если 2FA)
|
||||||
|
↓ (вызываем checkAuthenticationPassword)
|
||||||
|
authorizationStateReady ✓
|
||||||
|
```
|
||||||
|
|
||||||
|
### Шаг 1: Получение API ключей
|
||||||
|
|
||||||
|
Перед началом работы нужно:
|
||||||
|
1. Зайти на https://my.telegram.org
|
||||||
|
2. Войти с номером телефона
|
||||||
|
3. Перейти в "API development tools"
|
||||||
|
4. Создать приложение и получить `api_id` и `api_hash`
|
||||||
|
|
||||||
|
### Шаг 2: Инициализация TDLib
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use tdlib::{functions, types};
|
||||||
|
|
||||||
|
async fn init_tdlib() {
|
||||||
|
// Параметры инициализации
|
||||||
|
let params = types::TdlibParameters {
|
||||||
|
database_directory: "./tdlib_db".to_string(),
|
||||||
|
use_message_database: true,
|
||||||
|
use_secret_chats: true,
|
||||||
|
api_id: env::var("API_ID").unwrap().parse().unwrap(),
|
||||||
|
api_hash: env::var("API_HASH").unwrap(),
|
||||||
|
system_language_code: "en".to_string(),
|
||||||
|
device_model: "Desktop".to_string(),
|
||||||
|
system_version: "Unknown".to_string(),
|
||||||
|
application_version: "0.1.0".to_string(),
|
||||||
|
enable_storage_optimizer: true,
|
||||||
|
ignore_file_names: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Отправляем параметры
|
||||||
|
functions::set_tdlib_parameters(params, &client).await?;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Шаг 3: Ввод номера телефона
|
||||||
|
|
||||||
|
```rust
|
||||||
|
async fn authenticate_with_phone(phone: String, client: &Client) {
|
||||||
|
let phone_number = types::SetAuthenticationPhoneNumber {
|
||||||
|
phone_number: phone,
|
||||||
|
settings: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
functions::set_authentication_phone_number(phone_number, client).await?;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Шаг 4: Ввод кода подтверждения
|
||||||
|
|
||||||
|
```rust
|
||||||
|
async fn verify_code(code: String, client: &Client) {
|
||||||
|
let check_code = types::CheckAuthenticationCode {
|
||||||
|
code,
|
||||||
|
};
|
||||||
|
|
||||||
|
functions::check_authentication_code(check_code, client).await?;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Шаг 5: Ввод пароля 2FA (если включен)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
async fn verify_password(password: String, client: &Client) {
|
||||||
|
let check_password = types::CheckAuthenticationPassword {
|
||||||
|
password,
|
||||||
|
};
|
||||||
|
|
||||||
|
functions::check_authentication_password(check_password, client).await?;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Получение списка чатов
|
||||||
|
|
||||||
|
### Концепция чатов в TDLib
|
||||||
|
|
||||||
|
TDLib автоматически кэширует чаты локально. Приложение должно:
|
||||||
|
1. Подписаться на обновления `updateNewChat`
|
||||||
|
2. Вызвать `loadChats()` для загрузки чатов
|
||||||
|
3. Поддерживать локальный кэш с сортировкой
|
||||||
|
|
||||||
|
### Типы списков чатов
|
||||||
|
|
||||||
|
- **Main** — основные чаты
|
||||||
|
- **Archive** — архивные чаты
|
||||||
|
- **Folder** — пользовательские папки
|
||||||
|
|
||||||
|
### Загрузка чатов
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use tdlib::{functions, types};
|
||||||
|
|
||||||
|
async fn load_chats(client: &Client) -> Result<Vec<Chat>> {
|
||||||
|
// Указываем тип списка (Main, Archive, или конкретная папка)
|
||||||
|
let chat_list = types::ChatList::Main;
|
||||||
|
|
||||||
|
// Загружаем чаты
|
||||||
|
// limit - количество чатов для загрузки
|
||||||
|
functions::load_chats(
|
||||||
|
types::LoadChats {
|
||||||
|
chat_list: Some(chat_list),
|
||||||
|
limit: 50,
|
||||||
|
},
|
||||||
|
client
|
||||||
|
).await?;
|
||||||
|
|
||||||
|
// После вызова loadChats, чаты будут приходить через updateNewChat
|
||||||
|
Ok(vec![])
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Получение информации о чате
|
||||||
|
|
||||||
|
```rust
|
||||||
|
async fn get_chat_info(chat_id: i64, client: &Client) -> Result<types::Chat> {
|
||||||
|
let chat = functions::get_chat(
|
||||||
|
types::GetChat { chat_id },
|
||||||
|
client
|
||||||
|
).await?;
|
||||||
|
|
||||||
|
Ok(chat)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Сортировка чатов
|
||||||
|
|
||||||
|
Чаты нужно сортировать по паре `(position.order, chat.id)` в порядке убывания:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
chats.sort_by(|a, b| {
|
||||||
|
let order_a = a.positions.get(0).map(|p| p.order).unwrap_or(0);
|
||||||
|
let order_b = b.positions.get(0).map(|p| p.order).unwrap_or(0);
|
||||||
|
|
||||||
|
order_b.cmp(&order_a)
|
||||||
|
.then_with(|| b.id.cmp(&a.id))
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Получение истории сообщений
|
||||||
|
|
||||||
|
### Загрузка сообщений из чата
|
||||||
|
|
||||||
|
```rust
|
||||||
|
async fn get_chat_history(
|
||||||
|
chat_id: i64,
|
||||||
|
from_message_id: i64,
|
||||||
|
limit: i32,
|
||||||
|
client: &Client
|
||||||
|
) -> Result<Vec<types::Message>> {
|
||||||
|
let history = functions::get_chat_history(
|
||||||
|
types::GetChatHistory {
|
||||||
|
chat_id,
|
||||||
|
from_message_id, // 0 для последних сообщений
|
||||||
|
offset: 0,
|
||||||
|
limit,
|
||||||
|
only_local: false,
|
||||||
|
},
|
||||||
|
client
|
||||||
|
).await?;
|
||||||
|
|
||||||
|
Ok(history.messages.unwrap_or_default())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Пагинация сообщений
|
||||||
|
|
||||||
|
Сообщения возвращаются в обратном хронологическом порядке (новые → старые).
|
||||||
|
|
||||||
|
Для загрузки следующей страницы:
|
||||||
|
```rust
|
||||||
|
// Первая загрузка (последние сообщения)
|
||||||
|
let messages = get_chat_history(chat_id, 0, 50, &client).await?;
|
||||||
|
|
||||||
|
// Загрузка более старых сообщений
|
||||||
|
if let Some(oldest_msg) = messages.last() {
|
||||||
|
let older_messages = get_chat_history(
|
||||||
|
chat_id,
|
||||||
|
oldest_msg.id,
|
||||||
|
50,
|
||||||
|
&client
|
||||||
|
).await?;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Обработка обновлений (Updates Stream)
|
||||||
|
|
||||||
|
### Типы обновлений
|
||||||
|
|
||||||
|
TDLib отправляет обновления через `Update` enum:
|
||||||
|
|
||||||
|
- `UpdateNewMessage` — новое сообщение
|
||||||
|
- `UpdateMessageContent` — изменение контента сообщения
|
||||||
|
- `UpdateMessageSendSucceeded` — сообщение успешно отправлено
|
||||||
|
- `UpdateMessageSendFailed` — ошибка отправки
|
||||||
|
- `UpdateChatLastMessage` — изменилось последнее сообщение чата
|
||||||
|
- `UpdateChatPosition` — изменилась позиция чата в списке
|
||||||
|
- `UpdateNewChat` — новый чат добавлен
|
||||||
|
- `UpdateUser` — обновилась информация о пользователе
|
||||||
|
- `UpdateUserStatus` — изменился статус пользователя (онлайн/оффлайн)
|
||||||
|
- `UpdateChatReadInbox` — прочитаны входящие сообщения
|
||||||
|
- `UpdateChatReadOutbox` — прочитаны исходящие сообщения
|
||||||
|
|
||||||
|
### Слушатель обновлений
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use tdlib::types::Update;
|
||||||
|
|
||||||
|
async fn handle_updates(client: Client) {
|
||||||
|
loop {
|
||||||
|
match client.receive() {
|
||||||
|
Some(Update::NewMessage(update)) => {
|
||||||
|
println!("New message in chat {}: {}",
|
||||||
|
update.message.chat_id,
|
||||||
|
update.message.content
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Some(Update::MessageSendSucceeded(update)) => {
|
||||||
|
println!("Message sent successfully: {}", update.message.id);
|
||||||
|
}
|
||||||
|
Some(Update::UserStatus(update)) => {
|
||||||
|
println!("User {} is now {:?}",
|
||||||
|
update.user_id,
|
||||||
|
update.status
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Some(Update::NewChat(update)) => {
|
||||||
|
println!("New chat added: {}", update.chat.title);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Отправка сообщений
|
||||||
|
|
||||||
|
### Отправка текстового сообщения
|
||||||
|
|
||||||
|
```rust
|
||||||
|
async fn send_message(
|
||||||
|
chat_id: i64,
|
||||||
|
text: String,
|
||||||
|
client: &Client
|
||||||
|
) -> Result<types::Message> {
|
||||||
|
let input_content = types::InputMessageContent::InputMessageText(
|
||||||
|
types::InputMessageText {
|
||||||
|
text: types::FormattedText {
|
||||||
|
text,
|
||||||
|
entities: vec![],
|
||||||
|
},
|
||||||
|
disable_web_page_preview: false,
|
||||||
|
clear_draft: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
let message = functions::send_message(
|
||||||
|
types::SendMessage {
|
||||||
|
chat_id,
|
||||||
|
message_thread_id: 0,
|
||||||
|
reply_to: None,
|
||||||
|
options: None,
|
||||||
|
reply_markup: None,
|
||||||
|
input_message_content: input_content,
|
||||||
|
},
|
||||||
|
client
|
||||||
|
).await?;
|
||||||
|
|
||||||
|
Ok(message)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Статусы доставки и прочтения
|
||||||
|
|
||||||
|
Для отображения ✓ и ✓✓:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn get_message_status(message: &types::Message) -> &str {
|
||||||
|
if message.is_outgoing {
|
||||||
|
match &message.sending_state {
|
||||||
|
Some(types::MessageSendingState::Pending) => "", // отправляется
|
||||||
|
Some(types::MessageSendingState::Failed(_)) => "✗", // ошибка
|
||||||
|
None => {
|
||||||
|
// Отправлено успешно
|
||||||
|
if message.chat_id > 0 { // личный чат
|
||||||
|
// Проверяем, прочитано ли
|
||||||
|
// (нужно следить за UpdateChatReadOutbox)
|
||||||
|
"✓✓" // или "✓" если не прочитано
|
||||||
|
} else {
|
||||||
|
"✓" // групповой чат
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
"" // входящее сообщение
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Работа с папками (Folders)
|
||||||
|
|
||||||
|
### Получение списка папок
|
||||||
|
|
||||||
|
```rust
|
||||||
|
async fn get_chat_folders(client: &Client) -> Result<Vec<types::ChatFolderInfo>> {
|
||||||
|
let folders = functions::get_chat_folders(
|
||||||
|
types::GetChatFolders {},
|
||||||
|
client
|
||||||
|
).await?;
|
||||||
|
|
||||||
|
Ok(folders.chat_folders)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Фильтрация чатов по папке
|
||||||
|
|
||||||
|
```rust
|
||||||
|
async fn get_chats_in_folder(folder_id: i32, client: &Client) {
|
||||||
|
let chat_list = types::ChatList::Folder {
|
||||||
|
chat_folder_id: folder_id
|
||||||
|
};
|
||||||
|
|
||||||
|
functions::load_chats(
|
||||||
|
types::LoadChats {
|
||||||
|
chat_list: Some(chat_list),
|
||||||
|
limit: 50,
|
||||||
|
},
|
||||||
|
client
|
||||||
|
).await?;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Архитектура приложения
|
||||||
|
|
||||||
|
### Рекомендуемая структура
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── main.rs # Entry point, UI loop
|
||||||
|
├── tdlib/
|
||||||
|
│ ├── mod.rs # TDLib module
|
||||||
|
│ ├── client.rs # Client wrapper
|
||||||
|
│ ├── auth.rs # Authentication logic
|
||||||
|
│ └── updates.rs # Update handlers
|
||||||
|
├── ui/
|
||||||
|
│ ├── mod.rs
|
||||||
|
│ ├── app.rs # App state
|
||||||
|
│ ├── layout.rs # UI layout
|
||||||
|
│ └── components/ # UI components
|
||||||
|
└── models/
|
||||||
|
├── chat.rs # Chat models
|
||||||
|
└── message.rs # Message models
|
||||||
|
```
|
||||||
|
|
||||||
|
### Разделение ответственности
|
||||||
|
|
||||||
|
1. **TDLib Client** — управление клиентом, запросы к API
|
||||||
|
2. **Update Handler** — обработка обновлений в фоне
|
||||||
|
3. **App State** — состояние приложения (чаты, сообщения, UI)
|
||||||
|
4. **UI Layer** — отрисовка интерфейса (ratatui)
|
||||||
|
|
||||||
|
### Коммуникация между слоями
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Используем каналы для коммуникации
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
enum AppEvent {
|
||||||
|
NewMessage(Message),
|
||||||
|
ChatUpdated(Chat),
|
||||||
|
UserStatusChanged(i64, UserStatus),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
// Канал для событий от TDLib
|
||||||
|
let (tx, mut rx) = mpsc::channel::<AppEvent>(100);
|
||||||
|
|
||||||
|
// Запускаем TDLib в отдельной задаче
|
||||||
|
tokio::spawn(async move {
|
||||||
|
run_tdlib_client(tx).await;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Основной UI loop
|
||||||
|
loop {
|
||||||
|
// Проверяем события
|
||||||
|
while let Ok(event) = rx.try_recv() {
|
||||||
|
match event {
|
||||||
|
AppEvent::NewMessage(msg) => {
|
||||||
|
// Обновляем UI
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отрисовываем UI
|
||||||
|
terminal.draw(|f| ui(f, &app))?;
|
||||||
|
|
||||||
|
// Обрабатываем ввод пользователя
|
||||||
|
handle_input()?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Пример: Минимальный клиент
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use tdlib::{Client, ClientState, functions, types};
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
// 1. Создаем клиент
|
||||||
|
let (sender, mut receiver) = mpsc::channel(100);
|
||||||
|
let client = Client::new(sender);
|
||||||
|
|
||||||
|
// 2. Запускаем клиент
|
||||||
|
tokio::spawn(async move {
|
||||||
|
client.start().await;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Ждем авторизации
|
||||||
|
let mut authorized = false;
|
||||||
|
|
||||||
|
while let Some(update) = receiver.recv().await {
|
||||||
|
match update {
|
||||||
|
types::Update::AuthorizationState(state) => {
|
||||||
|
match state.authorization_state {
|
||||||
|
types::AuthorizationState::WaitTdlibParameters => {
|
||||||
|
// Отправляем параметры
|
||||||
|
init_tdlib(&client).await?;
|
||||||
|
}
|
||||||
|
types::AuthorizationState::WaitPhoneNumber => {
|
||||||
|
// Запрашиваем номер у пользователя
|
||||||
|
let phone = read_phone_from_user();
|
||||||
|
authenticate_with_phone(phone, &client).await?;
|
||||||
|
}
|
||||||
|
types::AuthorizationState::WaitCode(_) => {
|
||||||
|
// Запрашиваем код
|
||||||
|
let code = read_code_from_user();
|
||||||
|
verify_code(code, &client).await?;
|
||||||
|
}
|
||||||
|
types::AuthorizationState::Ready => {
|
||||||
|
authorized = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Загружаем чаты
|
||||||
|
if authorized {
|
||||||
|
load_chats(&client).await?;
|
||||||
|
|
||||||
|
// 5. Слушаем обновления
|
||||||
|
while let Some(update) = receiver.recv().await {
|
||||||
|
handle_update(update);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### 1. Кэширование
|
||||||
|
- Всегда включай `use_message_database: true`
|
||||||
|
- Храни кэш чатов и сообщений в памяти
|
||||||
|
- Используй `only_local: true` для быстрого доступа
|
||||||
|
|
||||||
|
### 2. Обработка ошибок
|
||||||
|
- Все TDLib функции возвращают `Result`
|
||||||
|
- Обрабатывай потерю соединения
|
||||||
|
- Переподключайся при ошибках сети
|
||||||
|
|
||||||
|
### 3. Производительность
|
||||||
|
- Не загружай все чаты сразу (используй пагинацию)
|
||||||
|
- Лимитируй количество сообщений в истории
|
||||||
|
- Используй `offset` для ленивой загрузки
|
||||||
|
|
||||||
|
### 4. UI/UX
|
||||||
|
- Показывай индикаторы загрузки
|
||||||
|
- Кэшируй отрисованные элементы
|
||||||
|
- Обновляй UI только при изменениях
|
||||||
|
|
||||||
|
## Полезные ссылки
|
||||||
|
|
||||||
|
### Официальная документация
|
||||||
|
- [TDLib Getting Started](https://core.telegram.org/tdlib/getting-started)
|
||||||
|
- [TDLib Documentation](https://core.telegram.org/tdlib/docs/)
|
||||||
|
|
||||||
|
### Rust библиотеки
|
||||||
|
- [rust-tdlib GitHub](https://github.com/antonio-antuan/rust-tdlib)
|
||||||
|
- [rust-tdlib docs.rs](https://docs.rs/rust-tdlib)
|
||||||
|
- [tdlib-rs GitHub](https://github.com/FedericoBruzzone/tdlib-rs)
|
||||||
|
- [tdlib-rs docs.rs](https://docs.rs/tdlib/latest/tdlib/)
|
||||||
|
|
||||||
|
### API Reference
|
||||||
|
- [tdlib::functions](https://docs.rs/tdlib/latest/tdlib/functions/index.html)
|
||||||
|
- [tdlib::types](https://docs.rs/tdlib-types/latest/tdlib_types/types/index.html)
|
||||||
|
|
||||||
|
## Следующие шаги
|
||||||
|
|
||||||
|
1. ✅ Изучить документацию TDLib
|
||||||
|
2. ⬜ Добавить зависимость tdlib-rs в проект
|
||||||
|
3. ⬜ Реализовать модуль авторизации
|
||||||
|
4. ⬜ Реализовать загрузку чатов
|
||||||
|
5. ⬜ Реализовать загрузку сообщений
|
||||||
|
6. ⬜ Интегрировать с существующим UI
|
||||||
|
7. ⬜ Добавить отправку сообщений
|
||||||
|
8. ⬜ Реализовать обработку обновлений в реальном времени
|
||||||
21
rustfmt.toml
Normal file
21
rustfmt.toml
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# Rustfmt configuration for tele-tui
|
||||||
|
# https://rust-lang.github.io/rustfmt/
|
||||||
|
|
||||||
|
edition = "2021"
|
||||||
|
max_width = 100
|
||||||
|
tab_spaces = 4
|
||||||
|
newline_style = "Unix"
|
||||||
|
|
||||||
|
# Imports
|
||||||
|
imports_granularity = "Crate"
|
||||||
|
group_imports = "StdExternalCrate"
|
||||||
|
|
||||||
|
# Comments
|
||||||
|
wrap_comments = true
|
||||||
|
comment_width = 80
|
||||||
|
normalize_comments = true
|
||||||
|
|
||||||
|
# Formatting
|
||||||
|
use_small_heuristics = "Default"
|
||||||
|
fn_call_width = 80
|
||||||
|
struct_lit_width = 50
|
||||||
820
src/app/mod.rs
820
src/app/mod.rs
@@ -1,201 +1,665 @@
|
|||||||
use crate::telegram::{Chat, Message};
|
mod state;
|
||||||
|
|
||||||
|
pub use state::AppScreen;
|
||||||
|
|
||||||
|
use ratatui::widgets::ListState;
|
||||||
|
use crate::tdlib::client::ChatInfo;
|
||||||
|
use crate::tdlib::TdClient;
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct App {
|
pub struct App {
|
||||||
pub tabs: Vec<String>,
|
pub config: crate::config::Config,
|
||||||
pub selected_tab: usize,
|
pub screen: AppScreen,
|
||||||
pub chats: Vec<Chat>,
|
pub td_client: TdClient,
|
||||||
pub selected_chat: Option<usize>,
|
// Auth state
|
||||||
pub messages: Vec<Message>,
|
pub phone_input: String,
|
||||||
pub input: String,
|
pub code_input: String,
|
||||||
|
pub password_input: String,
|
||||||
|
pub error_message: Option<String>,
|
||||||
|
pub status_message: Option<String>,
|
||||||
|
// Main app state
|
||||||
|
pub chats: Vec<ChatInfo>,
|
||||||
|
pub chat_list_state: ListState,
|
||||||
|
pub selected_chat_id: Option<i64>,
|
||||||
|
pub message_input: String,
|
||||||
|
/// Позиция курсора в message_input (в символах)
|
||||||
|
pub cursor_position: usize,
|
||||||
|
pub message_scroll_offset: usize,
|
||||||
|
/// None = All (основной список), Some(id) = папка с id
|
||||||
|
pub selected_folder_id: Option<i32>,
|
||||||
|
pub is_loading: bool,
|
||||||
|
// Search state
|
||||||
|
pub is_searching: bool,
|
||||||
pub search_query: String,
|
pub search_query: String,
|
||||||
|
/// Флаг для оптимизации рендеринга - перерисовывать только при изменениях
|
||||||
|
pub needs_redraw: bool,
|
||||||
|
// Edit message state
|
||||||
|
/// ID сообщения, которое редактируется (None = режим отправки нового)
|
||||||
|
pub editing_message_id: Option<i64>,
|
||||||
|
/// Индекс выбранного сообщения для навигации (снизу вверх, 0 = последнее)
|
||||||
|
pub selected_message_index: Option<usize>,
|
||||||
|
// Delete confirmation
|
||||||
|
/// ID сообщения для подтверждения удаления (показывает модалку)
|
||||||
|
pub confirm_delete_message_id: Option<i64>,
|
||||||
|
// Reply state
|
||||||
|
/// ID сообщения, на которое отвечаем (None = обычная отправка)
|
||||||
|
pub replying_to_message_id: Option<i64>,
|
||||||
|
// Forward state
|
||||||
|
/// ID сообщения для пересылки
|
||||||
|
pub forwarding_message_id: Option<i64>,
|
||||||
|
/// Режим выбора чата для пересылки
|
||||||
|
pub is_selecting_forward_chat: bool,
|
||||||
|
// Typing indicator
|
||||||
|
/// Время последней отправки typing status (для throttling)
|
||||||
|
pub last_typing_sent: Option<std::time::Instant>,
|
||||||
|
// Pinned messages mode
|
||||||
|
/// Режим просмотра закреплённых сообщений
|
||||||
|
pub is_pinned_mode: bool,
|
||||||
|
/// Список закреплённых сообщений
|
||||||
|
pub pinned_messages: Vec<crate::tdlib::client::MessageInfo>,
|
||||||
|
/// Индекс выбранного pinned сообщения
|
||||||
|
pub selected_pinned_index: usize,
|
||||||
|
// Message search mode
|
||||||
|
/// Режим поиска по сообщениям
|
||||||
|
pub is_message_search_mode: bool,
|
||||||
|
/// Поисковый запрос
|
||||||
|
pub message_search_query: String,
|
||||||
|
/// Результаты поиска
|
||||||
|
pub message_search_results: Vec<crate::tdlib::client::MessageInfo>,
|
||||||
|
/// Индекс выбранного результата
|
||||||
|
pub selected_search_result_index: usize,
|
||||||
|
// Profile mode
|
||||||
|
/// Режим просмотра профиля
|
||||||
|
pub is_profile_mode: bool,
|
||||||
|
/// Индекс выбранного действия в профиле
|
||||||
|
pub selected_profile_action: usize,
|
||||||
|
/// Шаг подтверждения выхода из группы (0 = не показано, 1 = первое, 2 = второе)
|
||||||
|
pub leave_group_confirmation_step: u8,
|
||||||
|
/// Информация профиля для отображения
|
||||||
|
pub profile_info: Option<crate::tdlib::ProfileInfo>,
|
||||||
|
// Reaction picker mode
|
||||||
|
/// Режим выбора реакции
|
||||||
|
pub is_reaction_picker_mode: bool,
|
||||||
|
/// ID сообщения для добавления реакции
|
||||||
|
pub selected_message_for_reaction: Option<i64>,
|
||||||
|
/// Список доступных реакций
|
||||||
|
pub available_reactions: Vec<String>,
|
||||||
|
/// Индекс выбранной реакции в picker
|
||||||
|
pub selected_reaction_index: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
pub fn new() -> Self {
|
pub fn new(config: crate::config::Config) -> App {
|
||||||
Self {
|
let mut state = ListState::default();
|
||||||
tabs: vec![
|
state.select(Some(0));
|
||||||
"All".to_string(),
|
|
||||||
"Personal".to_string(),
|
|
||||||
"Work".to_string(),
|
|
||||||
"Bots".to_string(),
|
|
||||||
],
|
|
||||||
selected_tab: 0,
|
|
||||||
chats: Self::mock_chats(),
|
|
||||||
selected_chat: Some(0),
|
|
||||||
messages: Self::mock_messages(),
|
|
||||||
input: String::new(),
|
|
||||||
search_query: String::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn select_tab(&mut self, index: usize) {
|
App {
|
||||||
if index < self.tabs.len() {
|
config,
|
||||||
self.selected_tab = index;
|
screen: AppScreen::Loading,
|
||||||
|
td_client: TdClient::new(),
|
||||||
|
phone_input: String::new(),
|
||||||
|
code_input: String::new(),
|
||||||
|
password_input: String::new(),
|
||||||
|
error_message: None,
|
||||||
|
status_message: Some("Инициализация TDLib...".to_string()),
|
||||||
|
chats: Vec::new(),
|
||||||
|
chat_list_state: state,
|
||||||
|
selected_chat_id: None,
|
||||||
|
message_input: String::new(),
|
||||||
|
cursor_position: 0,
|
||||||
|
message_scroll_offset: 0,
|
||||||
|
selected_folder_id: None, // None = All
|
||||||
|
is_loading: true,
|
||||||
|
is_searching: false,
|
||||||
|
search_query: String::new(),
|
||||||
|
needs_redraw: true,
|
||||||
|
editing_message_id: None,
|
||||||
|
selected_message_index: None,
|
||||||
|
confirm_delete_message_id: None,
|
||||||
|
replying_to_message_id: None,
|
||||||
|
forwarding_message_id: None,
|
||||||
|
is_selecting_forward_chat: false,
|
||||||
|
last_typing_sent: None,
|
||||||
|
is_pinned_mode: false,
|
||||||
|
pinned_messages: Vec::new(),
|
||||||
|
selected_pinned_index: 0,
|
||||||
|
is_message_search_mode: false,
|
||||||
|
message_search_query: String::new(),
|
||||||
|
message_search_results: Vec::new(),
|
||||||
|
selected_search_result_index: 0,
|
||||||
|
is_profile_mode: false,
|
||||||
|
selected_profile_action: 0,
|
||||||
|
leave_group_confirmation_step: 0,
|
||||||
|
profile_info: None,
|
||||||
|
is_reaction_picker_mode: false,
|
||||||
|
selected_message_for_reaction: None,
|
||||||
|
available_reactions: Vec::new(),
|
||||||
|
selected_reaction_index: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn next_chat(&mut self) {
|
pub fn next_chat(&mut self) {
|
||||||
if !self.chats.is_empty() {
|
let filtered = self.get_filtered_chats();
|
||||||
self.selected_chat = Some(
|
if filtered.is_empty() {
|
||||||
self.selected_chat
|
return;
|
||||||
.map(|i| (i + 1) % self.chats.len())
|
|
||||||
.unwrap_or(0),
|
|
||||||
);
|
|
||||||
self.load_messages();
|
|
||||||
}
|
}
|
||||||
|
let i = match self.chat_list_state.selected() {
|
||||||
|
Some(i) => {
|
||||||
|
if i >= filtered.len() - 1 {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
i + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => 0,
|
||||||
|
};
|
||||||
|
self.chat_list_state.select(Some(i));
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn previous_chat(&mut self) {
|
pub fn previous_chat(&mut self) {
|
||||||
if !self.chats.is_empty() {
|
let filtered = self.get_filtered_chats();
|
||||||
self.selected_chat = Some(
|
if filtered.is_empty() {
|
||||||
self.selected_chat
|
return;
|
||||||
.map(|i| if i == 0 { self.chats.len() - 1 } else { i - 1 })
|
|
||||||
.unwrap_or(0),
|
|
||||||
);
|
|
||||||
self.load_messages();
|
|
||||||
}
|
}
|
||||||
}
|
let i = match self.chat_list_state.selected() {
|
||||||
|
Some(i) => {
|
||||||
pub fn open_chat(&mut self) {
|
if i == 0 {
|
||||||
self.load_messages();
|
filtered.len() - 1
|
||||||
}
|
|
||||||
|
|
||||||
fn load_messages(&mut self) {
|
|
||||||
self.messages = Self::mock_messages();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn mock_chats() -> Vec<Chat> {
|
|
||||||
vec![
|
|
||||||
Chat {
|
|
||||||
name: "Saved Messages".to_string(),
|
|
||||||
last_message: "My notes...".to_string(),
|
|
||||||
unread_count: 0,
|
|
||||||
is_pinned: true,
|
|
||||||
is_online: false,
|
|
||||||
},
|
|
||||||
Chat {
|
|
||||||
name: "Mom".to_string(),
|
|
||||||
last_message: "Отлично, захвати хлеба.".to_string(),
|
|
||||||
unread_count: 2,
|
|
||||||
is_pinned: false,
|
|
||||||
is_online: true,
|
|
||||||
},
|
|
||||||
Chat {
|
|
||||||
name: "Boss".to_string(),
|
|
||||||
last_message: "Meeting at 3pm".to_string(),
|
|
||||||
unread_count: 0,
|
|
||||||
is_pinned: false,
|
|
||||||
is_online: false,
|
|
||||||
},
|
|
||||||
Chat {
|
|
||||||
name: "Rust Community".to_string(),
|
|
||||||
last_message: "Check out this crate...".to_string(),
|
|
||||||
unread_count: 0,
|
|
||||||
is_pinned: false,
|
|
||||||
is_online: false,
|
|
||||||
},
|
|
||||||
Chat {
|
|
||||||
name: "Durov".to_string(),
|
|
||||||
last_message: "Privacy matters".to_string(),
|
|
||||||
unread_count: 0,
|
|
||||||
is_pinned: false,
|
|
||||||
is_online: false,
|
|
||||||
},
|
|
||||||
Chat {
|
|
||||||
name: "News Channel".to_string(),
|
|
||||||
last_message: "Breaking news...".to_string(),
|
|
||||||
unread_count: 0,
|
|
||||||
is_pinned: false,
|
|
||||||
is_online: false,
|
|
||||||
},
|
|
||||||
Chat {
|
|
||||||
name: "Spam Bot".to_string(),
|
|
||||||
last_message: "Click here!!!".to_string(),
|
|
||||||
unread_count: 0,
|
|
||||||
is_pinned: false,
|
|
||||||
is_online: false,
|
|
||||||
},
|
|
||||||
Chat {
|
|
||||||
name: "Wife".to_string(),
|
|
||||||
last_message: "Don't forget the milk".to_string(),
|
|
||||||
unread_count: 0,
|
|
||||||
is_pinned: false,
|
|
||||||
is_online: false,
|
|
||||||
},
|
|
||||||
Chat {
|
|
||||||
name: "Team Lead".to_string(),
|
|
||||||
last_message: "Code review please".to_string(),
|
|
||||||
unread_count: 0,
|
|
||||||
is_pinned: false,
|
|
||||||
is_online: false,
|
|
||||||
},
|
|
||||||
Chat {
|
|
||||||
name: "DevOps Chat".to_string(),
|
|
||||||
last_message: "Server is down!".to_string(),
|
|
||||||
unread_count: 9,
|
|
||||||
is_pinned: false,
|
|
||||||
is_online: false,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
fn mock_messages() -> Vec<Message> {
|
|
||||||
vec![
|
|
||||||
Message {
|
|
||||||
sender: "Mom".to_string(),
|
|
||||||
text: "Привет! Ты покормил кота?".to_string(),
|
|
||||||
time: "14:20".to_string(),
|
|
||||||
is_outgoing: false,
|
|
||||||
read_status: 0,
|
|
||||||
},
|
|
||||||
Message {
|
|
||||||
sender: "You".to_string(),
|
|
||||||
text: "Да, конечно. Купил ему корм.".to_string(),
|
|
||||||
time: "14:22".to_string(),
|
|
||||||
is_outgoing: true,
|
|
||||||
read_status: 2,
|
|
||||||
},
|
|
||||||
Message {
|
|
||||||
sender: "You".to_string(),
|
|
||||||
text: "Скоро буду дома.".to_string(),
|
|
||||||
time: "14:22".to_string(),
|
|
||||||
is_outgoing: true,
|
|
||||||
read_status: 2,
|
|
||||||
},
|
|
||||||
Message {
|
|
||||||
sender: "Mom".to_string(),
|
|
||||||
text: "Отлично, захвати хлеба.".to_string(),
|
|
||||||
time: "14:23".to_string(),
|
|
||||||
is_outgoing: false,
|
|
||||||
read_status: 0,
|
|
||||||
},
|
|
||||||
Message {
|
|
||||||
sender: "You".to_string(),
|
|
||||||
text: "Ок.".to_string(),
|
|
||||||
time: "14:25".to_string(),
|
|
||||||
is_outgoing: true,
|
|
||||||
read_status: 1,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_current_chat_name(&self) -> String {
|
|
||||||
self.selected_chat
|
|
||||||
.and_then(|i| self.chats.get(i))
|
|
||||||
.map(|chat| {
|
|
||||||
if chat.is_online {
|
|
||||||
format!("👤 {} (online)", chat.name)
|
|
||||||
} else {
|
} else {
|
||||||
format!("👤 {}", chat.name)
|
i - 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => 0,
|
||||||
|
};
|
||||||
|
self.chat_list_state.select(Some(i));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn select_current_chat(&mut self) {
|
||||||
|
let filtered = self.get_filtered_chats();
|
||||||
|
if let Some(i) = self.chat_list_state.selected() {
|
||||||
|
if let Some(chat) = filtered.get(i) {
|
||||||
|
self.selected_chat_id = Some(chat.id);
|
||||||
}
|
}
|
||||||
})
|
|
||||||
.unwrap_or_default()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for App {
|
pub fn close_chat(&mut self) {
|
||||||
fn default() -> Self {
|
self.selected_chat_id = None;
|
||||||
Self::new()
|
self.message_input.clear();
|
||||||
|
self.cursor_position = 0;
|
||||||
|
self.message_scroll_offset = 0;
|
||||||
|
self.editing_message_id = None;
|
||||||
|
self.selected_message_index = None;
|
||||||
|
self.replying_to_message_id = None;
|
||||||
|
self.last_typing_sent = None;
|
||||||
|
// Сбрасываем pinned режим
|
||||||
|
self.is_pinned_mode = false;
|
||||||
|
self.pinned_messages.clear();
|
||||||
|
self.selected_pinned_index = 0;
|
||||||
|
// Очищаем данные в TdClient
|
||||||
|
self.td_client.current_chat_id = None;
|
||||||
|
self.td_client.current_chat_messages.clear();
|
||||||
|
self.td_client.typing_status = None;
|
||||||
|
self.td_client.current_pinned_message = None;
|
||||||
|
// Сбрасываем режим поиска
|
||||||
|
self.is_message_search_mode = false;
|
||||||
|
self.message_search_query.clear();
|
||||||
|
self.message_search_results.clear();
|
||||||
|
self.selected_search_result_index = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Начать выбор сообщения для редактирования (при стрелке вверх в пустом инпуте)
|
||||||
|
pub fn start_message_selection(&mut self) {
|
||||||
|
if self.td_client.current_chat_messages.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Начинаем с последнего сообщения (индекс 0 = самое новое снизу)
|
||||||
|
self.selected_message_index = Some(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Выбрать предыдущее сообщение (вверх по списку = увеличить индекс)
|
||||||
|
pub fn select_previous_message(&mut self) {
|
||||||
|
let total = self.td_client.current_chat_messages.len();
|
||||||
|
if total == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.selected_message_index = Some(
|
||||||
|
self.selected_message_index
|
||||||
|
.map(|i| (i + 1).min(total - 1))
|
||||||
|
.unwrap_or(0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Выбрать следующее сообщение (вниз по списку = уменьшить индекс)
|
||||||
|
pub fn select_next_message(&mut self) {
|
||||||
|
self.selected_message_index = self.selected_message_index
|
||||||
|
.map(|i| if i > 0 { Some(i - 1) } else { None })
|
||||||
|
.flatten();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Получить выбранное сообщение
|
||||||
|
pub fn get_selected_message(&self) -> Option<&crate::tdlib::client::MessageInfo> {
|
||||||
|
self.selected_message_index.and_then(|idx| {
|
||||||
|
let total = self.td_client.current_chat_messages.len();
|
||||||
|
if total == 0 || idx >= total {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// idx=0 это последнее сообщение (total-1), idx=1 это предпоследнее (total-2), и т.д.
|
||||||
|
self.td_client.current_chat_messages.get(total - 1 - idx)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Начать редактирование выбранного сообщения
|
||||||
|
pub fn start_editing_selected(&mut self) -> bool {
|
||||||
|
// Сначала извлекаем данные из сообщения
|
||||||
|
let msg_data = self.get_selected_message().and_then(|msg| {
|
||||||
|
if msg.can_be_edited && msg.is_outgoing {
|
||||||
|
Some((msg.id, msg.content.clone()))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Затем присваиваем
|
||||||
|
if let Some((id, content)) = msg_data {
|
||||||
|
self.editing_message_id = Some(id);
|
||||||
|
self.cursor_position = content.chars().count();
|
||||||
|
self.message_input = content;
|
||||||
|
self.selected_message_index = None;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Отменить редактирование
|
||||||
|
pub fn cancel_editing(&mut self) {
|
||||||
|
self.editing_message_id = None;
|
||||||
|
self.selected_message_index = None;
|
||||||
|
self.message_input.clear();
|
||||||
|
self.cursor_position = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Проверить, находимся ли в режиме редактирования
|
||||||
|
pub fn is_editing(&self) -> bool {
|
||||||
|
self.editing_message_id.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Проверить, находимся ли в режиме выбора сообщения
|
||||||
|
pub fn is_selecting_message(&self) -> bool {
|
||||||
|
self.selected_message_index.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_selected_chat_id(&self) -> Option<i64> {
|
||||||
|
self.selected_chat_id
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_selected_chat(&self) -> Option<&ChatInfo> {
|
||||||
|
self.selected_chat_id
|
||||||
|
.and_then(|id| self.chats.iter().find(|c| c.id == id))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn start_search(&mut self) {
|
||||||
|
self.is_searching = true;
|
||||||
|
self.search_query.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cancel_search(&mut self) {
|
||||||
|
self.is_searching = false;
|
||||||
|
self.search_query.clear();
|
||||||
|
self.chat_list_state.select(Some(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_filtered_chats(&self) -> Vec<&ChatInfo> {
|
||||||
|
let folder_filtered: Vec<&ChatInfo> = match self.selected_folder_id {
|
||||||
|
None => self.chats.iter().collect(), // All - показываем все
|
||||||
|
Some(folder_id) => self.chats
|
||||||
|
.iter()
|
||||||
|
.filter(|c| c.folder_ids.contains(&folder_id))
|
||||||
|
.collect(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if self.search_query.is_empty() {
|
||||||
|
folder_filtered
|
||||||
|
} else {
|
||||||
|
let query = self.search_query.to_lowercase();
|
||||||
|
folder_filtered
|
||||||
|
.into_iter()
|
||||||
|
.filter(|c| {
|
||||||
|
// Поиск по названию чата
|
||||||
|
c.title.to_lowercase().contains(&query) ||
|
||||||
|
// Поиск по username (@...)
|
||||||
|
c.username.as_ref()
|
||||||
|
.map(|u| u.to_lowercase().contains(&query))
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn next_filtered_chat(&mut self) {
|
||||||
|
let filtered = self.get_filtered_chats();
|
||||||
|
if filtered.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let i = match self.chat_list_state.selected() {
|
||||||
|
Some(i) => {
|
||||||
|
if i >= filtered.len() - 1 {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
i + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => 0,
|
||||||
|
};
|
||||||
|
self.chat_list_state.select(Some(i));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn previous_filtered_chat(&mut self) {
|
||||||
|
let filtered = self.get_filtered_chats();
|
||||||
|
if filtered.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let i = match self.chat_list_state.selected() {
|
||||||
|
Some(i) => {
|
||||||
|
if i == 0 {
|
||||||
|
filtered.len() - 1
|
||||||
|
} else {
|
||||||
|
i - 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => 0,
|
||||||
|
};
|
||||||
|
self.chat_list_state.select(Some(i));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn select_filtered_chat(&mut self) {
|
||||||
|
let filtered = self.get_filtered_chats();
|
||||||
|
if let Some(i) = self.chat_list_state.selected() {
|
||||||
|
if let Some(chat) = filtered.get(i) {
|
||||||
|
self.selected_chat_id = Some(chat.id);
|
||||||
|
self.cancel_search();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Проверить, показывается ли модалка подтверждения удаления
|
||||||
|
pub fn is_confirm_delete_shown(&self) -> bool {
|
||||||
|
self.confirm_delete_message_id.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Начать режим ответа на выбранное сообщение
|
||||||
|
pub fn start_reply_to_selected(&mut self) -> bool {
|
||||||
|
if let Some(msg) = self.get_selected_message() {
|
||||||
|
self.replying_to_message_id = Some(msg.id);
|
||||||
|
self.selected_message_index = None;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Отменить режим ответа
|
||||||
|
pub fn cancel_reply(&mut self) {
|
||||||
|
self.replying_to_message_id = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Проверить, находимся ли в режиме ответа
|
||||||
|
pub fn is_replying(&self) -> bool {
|
||||||
|
self.replying_to_message_id.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Получить сообщение, на которое отвечаем
|
||||||
|
pub fn get_replying_to_message(&self) -> Option<&crate::tdlib::client::MessageInfo> {
|
||||||
|
self.replying_to_message_id.and_then(|id| {
|
||||||
|
self.td_client.current_chat_messages.iter().find(|m| m.id == id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Начать режим пересылки выбранного сообщения
|
||||||
|
pub fn start_forward_selected(&mut self) -> bool {
|
||||||
|
if let Some(msg) = self.get_selected_message() {
|
||||||
|
self.forwarding_message_id = Some(msg.id);
|
||||||
|
self.selected_message_index = None;
|
||||||
|
self.is_selecting_forward_chat = true;
|
||||||
|
// Сбрасываем выбор чата на первый
|
||||||
|
self.chat_list_state.select(Some(0));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Отменить режим пересылки
|
||||||
|
pub fn cancel_forward(&mut self) {
|
||||||
|
self.forwarding_message_id = None;
|
||||||
|
self.is_selecting_forward_chat = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Проверить, находимся ли в режиме выбора чата для пересылки
|
||||||
|
pub fn is_forwarding(&self) -> bool {
|
||||||
|
self.is_selecting_forward_chat && self.forwarding_message_id.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Получить сообщение для пересылки
|
||||||
|
pub fn get_forwarding_message(&self) -> Option<&crate::tdlib::client::MessageInfo> {
|
||||||
|
self.forwarding_message_id.and_then(|id| {
|
||||||
|
self.td_client.current_chat_messages.iter().find(|m| m.id == id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Pinned messages mode ===
|
||||||
|
|
||||||
|
/// Проверка режима pinned
|
||||||
|
pub fn is_pinned_mode(&self) -> bool {
|
||||||
|
self.is_pinned_mode
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Войти в режим pinned (вызывается после загрузки pinned сообщений)
|
||||||
|
pub fn enter_pinned_mode(&mut self, messages: Vec<crate::tdlib::client::MessageInfo>) {
|
||||||
|
if !messages.is_empty() {
|
||||||
|
self.pinned_messages = messages;
|
||||||
|
self.selected_pinned_index = 0;
|
||||||
|
self.is_pinned_mode = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Выйти из режима pinned
|
||||||
|
pub fn exit_pinned_mode(&mut self) {
|
||||||
|
self.is_pinned_mode = false;
|
||||||
|
self.pinned_messages.clear();
|
||||||
|
self.selected_pinned_index = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Выбрать предыдущий pinned (вверх = более старый)
|
||||||
|
pub fn select_previous_pinned(&mut self) {
|
||||||
|
if !self.pinned_messages.is_empty() && self.selected_pinned_index < self.pinned_messages.len() - 1 {
|
||||||
|
self.selected_pinned_index += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Выбрать следующий pinned (вниз = более новый)
|
||||||
|
pub fn select_next_pinned(&mut self) {
|
||||||
|
if self.selected_pinned_index > 0 {
|
||||||
|
self.selected_pinned_index -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Получить текущее выбранное pinned сообщение
|
||||||
|
pub fn get_selected_pinned(&self) -> Option<&crate::tdlib::client::MessageInfo> {
|
||||||
|
self.pinned_messages.get(self.selected_pinned_index)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Получить ID текущего pinned для перехода в историю
|
||||||
|
pub fn get_selected_pinned_id(&self) -> Option<i64> {
|
||||||
|
self.get_selected_pinned().map(|m| m.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Message Search Mode ===
|
||||||
|
|
||||||
|
/// Проверить, активен ли режим поиска по сообщениям
|
||||||
|
pub fn is_message_search_mode(&self) -> bool {
|
||||||
|
self.is_message_search_mode
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Войти в режим поиска по сообщениям
|
||||||
|
pub fn enter_message_search_mode(&mut self) {
|
||||||
|
self.is_message_search_mode = true;
|
||||||
|
self.message_search_query.clear();
|
||||||
|
self.message_search_results.clear();
|
||||||
|
self.selected_search_result_index = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Выйти из режима поиска
|
||||||
|
pub fn exit_message_search_mode(&mut self) {
|
||||||
|
self.is_message_search_mode = false;
|
||||||
|
self.message_search_query.clear();
|
||||||
|
self.message_search_results.clear();
|
||||||
|
self.selected_search_result_index = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Установить результаты поиска
|
||||||
|
pub fn set_search_results(&mut self, results: Vec<crate::tdlib::client::MessageInfo>) {
|
||||||
|
self.message_search_results = results;
|
||||||
|
self.selected_search_result_index = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Выбрать предыдущий результат (вверх)
|
||||||
|
pub fn select_previous_search_result(&mut self) {
|
||||||
|
if self.selected_search_result_index > 0 {
|
||||||
|
self.selected_search_result_index -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Выбрать следующий результат (вниз)
|
||||||
|
pub fn select_next_search_result(&mut self) {
|
||||||
|
if !self.message_search_results.is_empty()
|
||||||
|
&& self.selected_search_result_index < self.message_search_results.len() - 1
|
||||||
|
{
|
||||||
|
self.selected_search_result_index += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Получить текущий выбранный результат
|
||||||
|
pub fn get_selected_search_result(&self) -> Option<&crate::tdlib::client::MessageInfo> {
|
||||||
|
self.message_search_results.get(self.selected_search_result_index)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Получить ID выбранного результата для перехода
|
||||||
|
pub fn get_selected_search_result_id(&self) -> Option<i64> {
|
||||||
|
self.get_selected_search_result().map(|m| m.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Draft Management ===
|
||||||
|
|
||||||
|
/// Получить черновик для текущего чата
|
||||||
|
pub fn get_current_draft(&self) -> Option<String> {
|
||||||
|
self.selected_chat_id.and_then(|chat_id| {
|
||||||
|
self.chats
|
||||||
|
.iter()
|
||||||
|
.find(|c| c.id == chat_id)
|
||||||
|
.and_then(|c| c.draft_text.clone())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Загрузить черновик в message_input (вызывается при открытии чата)
|
||||||
|
pub fn load_draft(&mut self) {
|
||||||
|
if let Some(draft) = self.get_current_draft() {
|
||||||
|
self.message_input = draft;
|
||||||
|
self.cursor_position = self.message_input.chars().count();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Profile Mode ===
|
||||||
|
|
||||||
|
/// Проверить, активен ли режим профиля
|
||||||
|
pub fn is_profile_mode(&self) -> bool {
|
||||||
|
self.is_profile_mode
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Войти в режим профиля
|
||||||
|
pub fn enter_profile_mode(&mut self) {
|
||||||
|
self.is_profile_mode = true;
|
||||||
|
self.selected_profile_action = 0;
|
||||||
|
self.leave_group_confirmation_step = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Выйти из режима профиля
|
||||||
|
pub fn exit_profile_mode(&mut self) {
|
||||||
|
self.is_profile_mode = false;
|
||||||
|
self.selected_profile_action = 0;
|
||||||
|
self.leave_group_confirmation_step = 0;
|
||||||
|
self.profile_info = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Выбрать предыдущее действие
|
||||||
|
pub fn select_previous_profile_action(&mut self) {
|
||||||
|
if self.selected_profile_action > 0 {
|
||||||
|
self.selected_profile_action -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Выбрать следующее действие
|
||||||
|
pub fn select_next_profile_action(&mut self, max_actions: usize) {
|
||||||
|
if self.selected_profile_action < max_actions.saturating_sub(1) {
|
||||||
|
self.selected_profile_action += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Показать первое подтверждение выхода из группы
|
||||||
|
pub fn show_leave_group_confirmation(&mut self) {
|
||||||
|
self.leave_group_confirmation_step = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Показать второе подтверждение выхода из группы
|
||||||
|
pub fn show_leave_group_final_confirmation(&mut self) {
|
||||||
|
self.leave_group_confirmation_step = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Отменить подтверждение выхода из группы
|
||||||
|
pub fn cancel_leave_group(&mut self) {
|
||||||
|
self.leave_group_confirmation_step = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Получить текущий шаг подтверждения
|
||||||
|
pub fn get_leave_group_confirmation_step(&self) -> u8 {
|
||||||
|
self.leave_group_confirmation_step
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Reaction Picker ==========
|
||||||
|
|
||||||
|
pub fn is_reaction_picker_mode(&self) -> bool {
|
||||||
|
self.is_reaction_picker_mode
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn enter_reaction_picker_mode(&mut self, message_id: i64, available_reactions: Vec<String>) {
|
||||||
|
self.is_reaction_picker_mode = true;
|
||||||
|
self.selected_message_for_reaction = Some(message_id);
|
||||||
|
self.available_reactions = available_reactions;
|
||||||
|
self.selected_reaction_index = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn exit_reaction_picker_mode(&mut self) {
|
||||||
|
self.is_reaction_picker_mode = false;
|
||||||
|
self.selected_message_for_reaction = None;
|
||||||
|
self.available_reactions.clear();
|
||||||
|
self.selected_reaction_index = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn select_previous_reaction(&mut self) {
|
||||||
|
if !self.available_reactions.is_empty() && self.selected_reaction_index > 0 {
|
||||||
|
self.selected_reaction_index -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn select_next_reaction(&mut self) {
|
||||||
|
if self.selected_reaction_index + 1 < self.available_reactions.len() {
|
||||||
|
self.selected_reaction_index += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_selected_reaction(&self) -> Option<&String> {
|
||||||
|
self.available_reactions.get(self.selected_reaction_index)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_selected_message_for_reaction(&self) -> Option<i64> {
|
||||||
|
self.selected_message_for_reaction
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
6
src/app/state.rs
Normal file
6
src/app/state.rs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
#[derive(PartialEq, Clone)]
|
||||||
|
pub enum AppScreen {
|
||||||
|
Loading,
|
||||||
|
Auth,
|
||||||
|
Main,
|
||||||
|
}
|
||||||
265
src/config.rs
Normal file
265
src/config.rs
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Config {
|
||||||
|
#[serde(default)]
|
||||||
|
pub general: GeneralConfig,
|
||||||
|
#[serde(default)]
|
||||||
|
pub colors: ColorsConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct GeneralConfig {
|
||||||
|
/// Часовой пояс в формате "+03:00" или "-05:00"
|
||||||
|
#[serde(default = "default_timezone")]
|
||||||
|
pub timezone: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ColorsConfig {
|
||||||
|
/// Цвет входящих сообщений (white, gray, cyan и т.д.)
|
||||||
|
#[serde(default = "default_incoming_color")]
|
||||||
|
pub incoming_message: String,
|
||||||
|
|
||||||
|
/// Цвет исходящих сообщений
|
||||||
|
#[serde(default = "default_outgoing_color")]
|
||||||
|
pub outgoing_message: String,
|
||||||
|
|
||||||
|
/// Цвет выбранного сообщения
|
||||||
|
#[serde(default = "default_selected_color")]
|
||||||
|
pub selected_message: String,
|
||||||
|
|
||||||
|
/// Цвет своих реакций
|
||||||
|
#[serde(default = "default_reaction_chosen_color")]
|
||||||
|
pub reaction_chosen: String,
|
||||||
|
|
||||||
|
/// Цвет чужих реакций
|
||||||
|
#[serde(default = "default_reaction_other_color")]
|
||||||
|
pub reaction_other: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Дефолтные значения
|
||||||
|
fn default_timezone() -> String {
|
||||||
|
"+03:00".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_incoming_color() -> String {
|
||||||
|
"white".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_outgoing_color() -> String {
|
||||||
|
"green".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_selected_color() -> String {
|
||||||
|
"yellow".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_reaction_chosen_color() -> String {
|
||||||
|
"yellow".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_reaction_other_color() -> String {
|
||||||
|
"gray".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for GeneralConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
timezone: default_timezone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ColorsConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
incoming_message: default_incoming_color(),
|
||||||
|
outgoing_message: default_outgoing_color(),
|
||||||
|
selected_message: default_selected_color(),
|
||||||
|
reaction_chosen: default_reaction_chosen_color(),
|
||||||
|
reaction_other: default_reaction_other_color(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Config {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
general: GeneralConfig::default(),
|
||||||
|
colors: ColorsConfig::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
/// Путь к конфигурационному файлу
|
||||||
|
pub fn config_path() -> Option<PathBuf> {
|
||||||
|
dirs::config_dir().map(|mut path| {
|
||||||
|
path.push("tele-tui");
|
||||||
|
path.push("config.toml");
|
||||||
|
path
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Путь к директории конфигурации
|
||||||
|
pub fn config_dir() -> Option<PathBuf> {
|
||||||
|
dirs::config_dir().map(|mut path| {
|
||||||
|
path.push("tele-tui");
|
||||||
|
path
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Загрузить конфигурацию из файла
|
||||||
|
pub fn load() -> Self {
|
||||||
|
let config_path = match Self::config_path() {
|
||||||
|
Some(path) => path,
|
||||||
|
None => {
|
||||||
|
eprintln!("Warning: Could not determine config directory, using defaults");
|
||||||
|
return Self::default();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if !config_path.exists() {
|
||||||
|
// Создаём дефолтный конфиг при первом запуске
|
||||||
|
let default_config = Self::default();
|
||||||
|
if let Err(e) = default_config.save() {
|
||||||
|
eprintln!("Warning: Could not create default config: {}", e);
|
||||||
|
}
|
||||||
|
return default_config;
|
||||||
|
}
|
||||||
|
|
||||||
|
match fs::read_to_string(&config_path) {
|
||||||
|
Ok(content) => {
|
||||||
|
match toml::from_str::<Config>(&content) {
|
||||||
|
Ok(config) => config,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Warning: Could not parse config file: {}", e);
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Warning: Could not read config file: {}", e);
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Сохранить конфигурацию в файл
|
||||||
|
pub fn save(&self) -> Result<(), String> {
|
||||||
|
let config_dir = Self::config_dir()
|
||||||
|
.ok_or_else(|| "Could not determine config directory".to_string())?;
|
||||||
|
|
||||||
|
// Создаём директорию если её нет
|
||||||
|
fs::create_dir_all(&config_dir)
|
||||||
|
.map_err(|e| format!("Could not create config directory: {}", e))?;
|
||||||
|
|
||||||
|
let config_path = config_dir.join("config.toml");
|
||||||
|
|
||||||
|
let toml_string = toml::to_string_pretty(self)
|
||||||
|
.map_err(|e| format!("Could not serialize config: {}", e))?;
|
||||||
|
|
||||||
|
fs::write(&config_path, toml_string)
|
||||||
|
.map_err(|e| format!("Could not write config file: {}", e))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Парсит строку цвета в ratatui::style::Color
|
||||||
|
pub fn parse_color(&self, color_str: &str) -> ratatui::style::Color {
|
||||||
|
use ratatui::style::Color;
|
||||||
|
|
||||||
|
match color_str.to_lowercase().as_str() {
|
||||||
|
"black" => Color::Black,
|
||||||
|
"red" => Color::Red,
|
||||||
|
"green" => Color::Green,
|
||||||
|
"yellow" => Color::Yellow,
|
||||||
|
"blue" => Color::Blue,
|
||||||
|
"magenta" => Color::Magenta,
|
||||||
|
"cyan" => Color::Cyan,
|
||||||
|
"gray" | "grey" => Color::Gray,
|
||||||
|
"white" => Color::White,
|
||||||
|
"darkgray" | "darkgrey" => Color::DarkGray,
|
||||||
|
"lightred" => Color::LightRed,
|
||||||
|
"lightgreen" => Color::LightGreen,
|
||||||
|
"lightyellow" => Color::LightYellow,
|
||||||
|
"lightblue" => Color::LightBlue,
|
||||||
|
"lightmagenta" => Color::LightMagenta,
|
||||||
|
"lightcyan" => Color::LightCyan,
|
||||||
|
_ => Color::White, // fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Путь к файлу credentials
|
||||||
|
pub fn credentials_path() -> Option<PathBuf> {
|
||||||
|
Self::config_dir().map(|dir| dir.join("credentials"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Загружает API_ID и API_HASH из credentials файла или .env
|
||||||
|
/// Возвращает (api_id, api_hash) или ошибку с инструкциями
|
||||||
|
pub fn load_credentials() -> Result<(i32, String), String> {
|
||||||
|
use std::env;
|
||||||
|
|
||||||
|
// 1. Пробуем загрузить из ~/.config/tele-tui/credentials
|
||||||
|
if let Some(cred_path) = Self::credentials_path() {
|
||||||
|
if cred_path.exists() {
|
||||||
|
if let Ok(content) = fs::read_to_string(&cred_path) {
|
||||||
|
let mut api_id: Option<i32> = None;
|
||||||
|
let mut api_hash: Option<String> = None;
|
||||||
|
|
||||||
|
for line in content.lines() {
|
||||||
|
let line = line.trim();
|
||||||
|
if line.is_empty() || line.starts_with('#') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some((key, value)) = line.split_once('=') {
|
||||||
|
let key = key.trim();
|
||||||
|
let value = value.trim();
|
||||||
|
|
||||||
|
match key {
|
||||||
|
"API_ID" => {
|
||||||
|
api_id = value.parse().ok();
|
||||||
|
}
|
||||||
|
"API_HASH" => {
|
||||||
|
api_hash = Some(value.to_string());
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let (Some(id), Some(hash)) = (api_id, api_hash) {
|
||||||
|
return Ok((id, hash));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Пробуем загрузить из переменных окружения (.env)
|
||||||
|
if let (Ok(api_id_str), Ok(api_hash)) = (env::var("API_ID"), env::var("API_HASH")) {
|
||||||
|
if let Ok(api_id) = api_id_str.parse::<i32>() {
|
||||||
|
return Ok((api_id, api_hash));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Не нашли credentials - возвращаем инструкции
|
||||||
|
let credentials_path = Self::credentials_path()
|
||||||
|
.map(|p| p.display().to_string())
|
||||||
|
.unwrap_or_else(|| "~/.config/tele-tui/credentials".to_string());
|
||||||
|
|
||||||
|
Err(format!(
|
||||||
|
"Telegram API credentials not found!\n\n\
|
||||||
|
Please create a file at:\n {}\n\n\
|
||||||
|
With the following content:\n\
|
||||||
|
API_ID=your_api_id\n\
|
||||||
|
API_HASH=your_api_hash\n\n\
|
||||||
|
You can get API credentials at: https://my.telegram.org/apps\n\n\
|
||||||
|
Alternatively, you can create a .env file in the current directory.",
|
||||||
|
credentials_path
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
101
src/input/auth.rs
Normal file
101
src/input/auth.rs
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
use crossterm::event::KeyCode;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::time::timeout;
|
||||||
|
use crate::app::App;
|
||||||
|
use crate::tdlib::client::AuthState;
|
||||||
|
|
||||||
|
pub async fn handle(app: &mut App, key_code: KeyCode) {
|
||||||
|
match &app.td_client.auth_state {
|
||||||
|
AuthState::WaitPhoneNumber => match key_code {
|
||||||
|
KeyCode::Char(c) => {
|
||||||
|
app.phone_input.push(c);
|
||||||
|
app.error_message = None;
|
||||||
|
}
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
app.phone_input.pop();
|
||||||
|
app.error_message = None;
|
||||||
|
}
|
||||||
|
KeyCode::Enter => {
|
||||||
|
if !app.phone_input.is_empty() {
|
||||||
|
app.status_message = Some("Отправка номера...".to_string());
|
||||||
|
match timeout(Duration::from_secs(10), app.td_client.send_phone_number(app.phone_input.clone())).await {
|
||||||
|
Ok(Ok(_)) => {
|
||||||
|
app.error_message = None;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
AuthState::WaitCode => match key_code {
|
||||||
|
KeyCode::Char(c) if c.is_numeric() => {
|
||||||
|
app.code_input.push(c);
|
||||||
|
app.error_message = None;
|
||||||
|
}
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
app.code_input.pop();
|
||||||
|
app.error_message = None;
|
||||||
|
}
|
||||||
|
KeyCode::Enter => {
|
||||||
|
if !app.code_input.is_empty() {
|
||||||
|
app.status_message = Some("Проверка кода...".to_string());
|
||||||
|
match timeout(Duration::from_secs(10), app.td_client.send_code(app.code_input.clone())).await {
|
||||||
|
Ok(Ok(_)) => {
|
||||||
|
app.error_message = None;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
AuthState::WaitPassword => match key_code {
|
||||||
|
KeyCode::Char(c) => {
|
||||||
|
app.password_input.push(c);
|
||||||
|
app.error_message = None;
|
||||||
|
}
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
app.password_input.pop();
|
||||||
|
app.error_message = None;
|
||||||
|
}
|
||||||
|
KeyCode::Enter => {
|
||||||
|
if !app.password_input.is_empty() {
|
||||||
|
app.status_message = Some("Проверка пароля...".to_string());
|
||||||
|
match timeout(Duration::from_secs(10), app.td_client.send_password(app.password_input.clone())).await {
|
||||||
|
Ok(Ok(_)) => {
|
||||||
|
app.error_message = None;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
965
src/input/main_input.rs
Normal file
965
src/input/main_input.rs
Normal file
@@ -0,0 +1,965 @@
|
|||||||
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
use tokio::time::timeout;
|
||||||
|
use crate::app::App;
|
||||||
|
use crate::tdlib::ChatAction;
|
||||||
|
|
||||||
|
pub async fn handle(app: &mut App, key: KeyEvent) {
|
||||||
|
let has_ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
||||||
|
|
||||||
|
// Глобальные команды (работают всегда)
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Char('r') if has_ctrl => {
|
||||||
|
app.status_message = Some("Обновление чатов...".to_string());
|
||||||
|
let _ = timeout(Duration::from_secs(5), app.td_client.load_chats(50)).await;
|
||||||
|
app.status_message = None;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
KeyCode::Char('s') if has_ctrl => {
|
||||||
|
// Ctrl+S - начать поиск (только если чат не открыт)
|
||||||
|
if app.selected_chat_id.is_none() {
|
||||||
|
app.start_search();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
KeyCode::Char('p') if has_ctrl => {
|
||||||
|
// Ctrl+P - режим просмотра закреплённых сообщений
|
||||||
|
if app.selected_chat_id.is_some() && !app.is_pinned_mode() {
|
||||||
|
if let Some(chat_id) = app.get_selected_chat_id() {
|
||||||
|
app.status_message = Some("Загрузка закреплённых...".to_string());
|
||||||
|
match timeout(Duration::from_secs(5), app.td_client.get_pinned_messages(chat_id)).await {
|
||||||
|
Ok(Ok(messages)) => {
|
||||||
|
if messages.is_empty() {
|
||||||
|
app.status_message = Some("Нет закреплённых сообщений".to_string());
|
||||||
|
} else {
|
||||||
|
app.enter_pinned_mode(messages);
|
||||||
|
app.status_message = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
app.error_message = Some(e);
|
||||||
|
app.status_message = None;
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
app.error_message = Some("Таймаут загрузки".to_string());
|
||||||
|
app.status_message = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
KeyCode::Char('f') if has_ctrl => {
|
||||||
|
// Ctrl+F - поиск по сообщениям в открытом чате
|
||||||
|
if app.selected_chat_id.is_some() && !app.is_pinned_mode() && !app.is_message_search_mode() {
|
||||||
|
app.enter_message_search_mode();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Режим профиля
|
||||||
|
if app.is_profile_mode() {
|
||||||
|
// Обработка подтверждения выхода из группы
|
||||||
|
let confirmation_step = app.get_leave_group_confirmation_step();
|
||||||
|
if confirmation_step > 0 {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Char('y') | KeyCode::Char('н') | KeyCode::Enter => {
|
||||||
|
if confirmation_step == 1 {
|
||||||
|
// Первое подтверждение - показываем второе
|
||||||
|
app.show_leave_group_final_confirmation();
|
||||||
|
} else if confirmation_step == 2 {
|
||||||
|
// Второе подтверждение - выходим из группы
|
||||||
|
if let Some(chat_id) = app.selected_chat_id {
|
||||||
|
let leave_result = app.td_client.leave_chat(chat_id).await;
|
||||||
|
match leave_result {
|
||||||
|
Ok(_) => {
|
||||||
|
app.status_message = Some("Вы вышли из группы".to_string());
|
||||||
|
app.exit_profile_mode();
|
||||||
|
app.close_chat();
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
app.error_message = Some(e);
|
||||||
|
app.cancel_leave_group();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Char('n') | KeyCode::Char('т') | KeyCode::Esc => {
|
||||||
|
// Отмена
|
||||||
|
app.cancel_leave_group();
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обычная навигация по профилю
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Esc => {
|
||||||
|
app.exit_profile_mode();
|
||||||
|
}
|
||||||
|
KeyCode::Up => {
|
||||||
|
app.select_previous_profile_action();
|
||||||
|
}
|
||||||
|
KeyCode::Down => {
|
||||||
|
if let Some(profile) = &app.profile_info {
|
||||||
|
let max_actions = get_available_actions_count(profile);
|
||||||
|
app.select_next_profile_action(max_actions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Enter => {
|
||||||
|
// Выполнить выбранное действие
|
||||||
|
if let Some(profile) = &app.profile_info {
|
||||||
|
let actions = get_available_actions_count(profile);
|
||||||
|
let action_index = app.selected_profile_action;
|
||||||
|
|
||||||
|
if action_index < actions {
|
||||||
|
// Определяем какое действие выбрано
|
||||||
|
let mut current_idx = 0;
|
||||||
|
|
||||||
|
// Действие: Открыть в браузере
|
||||||
|
if profile.username.is_some() {
|
||||||
|
if action_index == current_idx {
|
||||||
|
if let Some(username) = &profile.username {
|
||||||
|
let url = format!("https://t.me/{}", username.trim_start_matches('@'));
|
||||||
|
match open::that(&url) {
|
||||||
|
Ok(_) => {
|
||||||
|
app.status_message = Some(format!("Открыто: {}", url));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
app.error_message = Some(format!("Ошибка открытия браузера: {}", e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
current_idx += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Действие: Скопировать ID
|
||||||
|
if action_index == current_idx {
|
||||||
|
app.status_message = Some(format!("ID скопирован: {}", profile.chat_id));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
current_idx += 1;
|
||||||
|
|
||||||
|
// Действие: Покинуть группу
|
||||||
|
if profile.is_group && action_index == current_idx {
|
||||||
|
app.show_leave_group_confirmation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Режим поиска по сообщениям
|
||||||
|
if app.is_message_search_mode() {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Esc => {
|
||||||
|
app.exit_message_search_mode();
|
||||||
|
}
|
||||||
|
KeyCode::Up | KeyCode::Char('N') => {
|
||||||
|
app.select_previous_search_result();
|
||||||
|
}
|
||||||
|
KeyCode::Down | KeyCode::Char('n') => {
|
||||||
|
app.select_next_search_result();
|
||||||
|
}
|
||||||
|
KeyCode::Enter => {
|
||||||
|
// Перейти к выбранному сообщению
|
||||||
|
if let Some(msg_id) = app.get_selected_search_result_id() {
|
||||||
|
let msg_index = app.td_client.current_chat_messages
|
||||||
|
.iter()
|
||||||
|
.position(|m| m.id == msg_id);
|
||||||
|
|
||||||
|
if let Some(idx) = msg_index {
|
||||||
|
let total = app.td_client.current_chat_messages.len();
|
||||||
|
app.message_scroll_offset = total.saturating_sub(idx + 5);
|
||||||
|
}
|
||||||
|
app.exit_message_search_mode();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
app.message_search_query.pop();
|
||||||
|
// Выполняем поиск при изменении запроса
|
||||||
|
if let Some(chat_id) = app.get_selected_chat_id() {
|
||||||
|
if !app.message_search_query.is_empty() {
|
||||||
|
if let Ok(Ok(results)) = timeout(
|
||||||
|
Duration::from_secs(3),
|
||||||
|
app.td_client.search_messages(chat_id, &app.message_search_query)
|
||||||
|
).await {
|
||||||
|
app.set_search_results(results);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
app.set_search_results(Vec::new());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Char(c) => {
|
||||||
|
app.message_search_query.push(c);
|
||||||
|
// Выполняем поиск при изменении запроса
|
||||||
|
if let Some(chat_id) = app.get_selected_chat_id() {
|
||||||
|
if let Ok(Ok(results)) = timeout(
|
||||||
|
Duration::from_secs(3),
|
||||||
|
app.td_client.search_messages(chat_id, &app.message_search_query)
|
||||||
|
).await {
|
||||||
|
app.set_search_results(results);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Режим просмотра закреплённых сообщений
|
||||||
|
if app.is_pinned_mode() {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Esc => {
|
||||||
|
app.exit_pinned_mode();
|
||||||
|
}
|
||||||
|
KeyCode::Up => {
|
||||||
|
app.select_previous_pinned();
|
||||||
|
}
|
||||||
|
KeyCode::Down => {
|
||||||
|
app.select_next_pinned();
|
||||||
|
}
|
||||||
|
KeyCode::Enter => {
|
||||||
|
// Перейти к сообщению в истории
|
||||||
|
if let Some(msg_id) = app.get_selected_pinned_id() {
|
||||||
|
// Ищем индекс сообщения в текущей истории
|
||||||
|
let msg_index = app.td_client.current_chat_messages
|
||||||
|
.iter()
|
||||||
|
.position(|m| m.id == msg_id);
|
||||||
|
|
||||||
|
if let Some(idx) = msg_index {
|
||||||
|
// Вычисляем scroll offset чтобы показать сообщение
|
||||||
|
let total = app.td_client.current_chat_messages.len();
|
||||||
|
app.message_scroll_offset = total.saturating_sub(idx + 5);
|
||||||
|
}
|
||||||
|
app.exit_pinned_mode();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обработка ввода в режиме выбора реакции
|
||||||
|
if app.is_reaction_picker_mode() {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Left => {
|
||||||
|
app.select_previous_reaction();
|
||||||
|
app.needs_redraw = true;
|
||||||
|
}
|
||||||
|
KeyCode::Right => {
|
||||||
|
app.select_next_reaction();
|
||||||
|
app.needs_redraw = true;
|
||||||
|
}
|
||||||
|
KeyCode::Up => {
|
||||||
|
// Переход на ряд выше (8 эмодзи в ряду)
|
||||||
|
if app.selected_reaction_index >= 8 {
|
||||||
|
app.selected_reaction_index = app.selected_reaction_index.saturating_sub(8);
|
||||||
|
app.needs_redraw = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Down => {
|
||||||
|
// Переход на ряд ниже (8 эмодзи в ряду)
|
||||||
|
let new_index = app.selected_reaction_index + 8;
|
||||||
|
if new_index < app.available_reactions.len() {
|
||||||
|
app.selected_reaction_index = new_index;
|
||||||
|
app.needs_redraw = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Enter => {
|
||||||
|
// Добавить/убрать реакцию
|
||||||
|
if let Some(emoji) = app.get_selected_reaction().cloned() {
|
||||||
|
if let Some(message_id) = app.get_selected_message_for_reaction() {
|
||||||
|
if let Some(chat_id) = app.selected_chat_id {
|
||||||
|
app.status_message = Some("Отправка реакции...".to_string());
|
||||||
|
app.needs_redraw = true;
|
||||||
|
|
||||||
|
match timeout(
|
||||||
|
Duration::from_secs(5),
|
||||||
|
app.td_client.toggle_reaction(chat_id, message_id, emoji.clone())
|
||||||
|
).await {
|
||||||
|
Ok(Ok(_)) => {
|
||||||
|
app.status_message = Some(format!("Реакция {} добавлена", emoji));
|
||||||
|
app.exit_reaction_picker_mode();
|
||||||
|
app.needs_redraw = true;
|
||||||
|
}
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
app.error_message = Some(format!("Ошибка: {}", e));
|
||||||
|
app.status_message = None;
|
||||||
|
app.needs_redraw = true;
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
app.error_message = Some("Таймаут отправки реакции".to_string());
|
||||||
|
app.status_message = None;
|
||||||
|
app.needs_redraw = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Esc => {
|
||||||
|
app.exit_reaction_picker_mode();
|
||||||
|
app.needs_redraw = true;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Модалка подтверждения удаления
|
||||||
|
if app.is_confirm_delete_shown() {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Char('y') | KeyCode::Char('н') | KeyCode::Enter => {
|
||||||
|
// Подтверждение удаления
|
||||||
|
if let Some(msg_id) = app.confirm_delete_message_id {
|
||||||
|
if let Some(chat_id) = app.get_selected_chat_id() {
|
||||||
|
// Находим сообщение для проверки can_be_deleted_for_all_users
|
||||||
|
let can_delete_for_all = app.td_client.current_chat_messages
|
||||||
|
.iter()
|
||||||
|
.find(|m| m.id == msg_id)
|
||||||
|
.map(|m| m.can_be_deleted_for_all_users)
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
match timeout(
|
||||||
|
Duration::from_secs(5),
|
||||||
|
app.td_client.delete_messages(chat_id, vec![msg_id], can_delete_for_all)
|
||||||
|
).await {
|
||||||
|
Ok(Ok(_)) => {
|
||||||
|
// Удаляем из локального списка
|
||||||
|
app.td_client.current_chat_messages.retain(|m| m.id != msg_id);
|
||||||
|
app.selected_message_index = None;
|
||||||
|
}
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
app.error_message = Some(e);
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
app.error_message = Some("Таймаут удаления".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
app.confirm_delete_message_id = None;
|
||||||
|
}
|
||||||
|
KeyCode::Char('n') | KeyCode::Char('т') | KeyCode::Esc => {
|
||||||
|
// Отмена удаления
|
||||||
|
app.confirm_delete_message_id = None;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Режим выбора чата для пересылки
|
||||||
|
if app.is_forwarding() {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Esc => {
|
||||||
|
app.cancel_forward();
|
||||||
|
}
|
||||||
|
KeyCode::Enter => {
|
||||||
|
// Выбираем чат и пересылаем сообщение
|
||||||
|
let filtered = app.get_filtered_chats();
|
||||||
|
if let Some(i) = app.chat_list_state.selected() {
|
||||||
|
if let Some(chat) = filtered.get(i) {
|
||||||
|
let to_chat_id = chat.id;
|
||||||
|
if let Some(msg_id) = app.forwarding_message_id {
|
||||||
|
if let Some(from_chat_id) = app.get_selected_chat_id() {
|
||||||
|
match timeout(
|
||||||
|
Duration::from_secs(5),
|
||||||
|
app.td_client.forward_messages(to_chat_id, from_chat_id, vec![msg_id])
|
||||||
|
).await {
|
||||||
|
Ok(Ok(_)) => {
|
||||||
|
app.status_message = Some("Сообщение переслано".to_string());
|
||||||
|
}
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
app.error_message = Some(e);
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
app.error_message = Some("Таймаут пересылки".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
app.cancel_forward();
|
||||||
|
}
|
||||||
|
KeyCode::Down => {
|
||||||
|
app.next_chat();
|
||||||
|
}
|
||||||
|
KeyCode::Up => {
|
||||||
|
app.previous_chat();
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Режим поиска
|
||||||
|
if app.is_searching {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Esc => {
|
||||||
|
app.cancel_search();
|
||||||
|
}
|
||||||
|
KeyCode::Enter => {
|
||||||
|
// Выбрать чат из отфильтрованного списка
|
||||||
|
app.select_filtered_chat();
|
||||||
|
if let Some(chat_id) = app.get_selected_chat_id() {
|
||||||
|
app.status_message = Some("Загрузка сообщений...".to_string());
|
||||||
|
app.message_scroll_offset = 0;
|
||||||
|
match timeout(Duration::from_secs(10), app.td_client.get_chat_history(chat_id, 100)).await {
|
||||||
|
Ok(Ok(_)) => {
|
||||||
|
// Загружаем недостающие reply info
|
||||||
|
let _ = timeout(Duration::from_secs(5), app.td_client.fetch_missing_reply_info()).await;
|
||||||
|
// Загружаем последнее закреплённое сообщение
|
||||||
|
let _ = timeout(Duration::from_secs(2), app.td_client.load_current_pinned_message(chat_id)).await;
|
||||||
|
// Загружаем черновик
|
||||||
|
app.load_draft();
|
||||||
|
app.status_message = None;
|
||||||
|
}
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
app.error_message = Some(e);
|
||||||
|
app.status_message = None;
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
app.error_message = Some("Таймаут загрузки сообщений".to_string());
|
||||||
|
app.status_message = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
app.search_query.pop();
|
||||||
|
// Сбрасываем выделение при изменении запроса
|
||||||
|
app.chat_list_state.select(Some(0));
|
||||||
|
}
|
||||||
|
KeyCode::Down => {
|
||||||
|
app.next_filtered_chat();
|
||||||
|
}
|
||||||
|
KeyCode::Up => {
|
||||||
|
app.previous_filtered_chat();
|
||||||
|
}
|
||||||
|
KeyCode::Char(c) => {
|
||||||
|
app.search_query.push(c);
|
||||||
|
// Сбрасываем выделение при изменении запроса
|
||||||
|
app.chat_list_state.select(Some(0));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Enter - открыть чат, отправить сообщение или редактировать
|
||||||
|
if key.code == KeyCode::Enter {
|
||||||
|
if app.selected_chat_id.is_some() {
|
||||||
|
// Режим выбора сообщения
|
||||||
|
if app.is_selecting_message() {
|
||||||
|
// Начать редактирование выбранного сообщения
|
||||||
|
if app.start_editing_selected() {
|
||||||
|
// Редактирование начато
|
||||||
|
} else {
|
||||||
|
// Нельзя редактировать это сообщение
|
||||||
|
app.selected_message_index = None;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отправка или редактирование сообщения
|
||||||
|
if !app.message_input.is_empty() {
|
||||||
|
if let Some(chat_id) = app.get_selected_chat_id() {
|
||||||
|
let text = app.message_input.clone();
|
||||||
|
|
||||||
|
if let Some(msg_id) = app.editing_message_id {
|
||||||
|
// Режим редактирования
|
||||||
|
app.message_input.clear();
|
||||||
|
app.cursor_position = 0;
|
||||||
|
app.editing_message_id = None;
|
||||||
|
|
||||||
|
match timeout(Duration::from_secs(5), app.td_client.edit_message(chat_id, msg_id, text)).await {
|
||||||
|
Ok(Ok(edited_msg)) => {
|
||||||
|
// Обновляем сообщение в списке
|
||||||
|
if let Some(msg) = app.td_client.current_chat_messages.iter_mut().find(|m| m.id == msg_id) {
|
||||||
|
msg.content = edited_msg.content;
|
||||||
|
msg.entities = edited_msg.entities;
|
||||||
|
msg.edit_date = edited_msg.edit_date;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
app.error_message = Some(e);
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
app.error_message = Some("Таймаут редактирования".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Обычная отправка (или reply)
|
||||||
|
let reply_to_id = app.replying_to_message_id;
|
||||||
|
// Создаём ReplyInfo ДО отправки, пока сообщение точно доступно
|
||||||
|
let reply_info = app.get_replying_to_message().map(|m| {
|
||||||
|
crate::tdlib::client::ReplyInfo {
|
||||||
|
message_id: m.id,
|
||||||
|
sender_name: m.sender_name.clone(),
|
||||||
|
text: m.content.clone(),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
app.message_input.clear();
|
||||||
|
app.cursor_position = 0;
|
||||||
|
app.replying_to_message_id = None;
|
||||||
|
app.last_typing_sent = None;
|
||||||
|
|
||||||
|
// Отменяем typing status
|
||||||
|
app.td_client.send_chat_action(chat_id, ChatAction::Cancel).await;
|
||||||
|
|
||||||
|
match timeout(Duration::from_secs(5), app.td_client.send_message(chat_id, text, reply_to_id, reply_info)).await {
|
||||||
|
Ok(Ok(sent_msg)) => {
|
||||||
|
// Добавляем отправленное сообщение в список (с лимитом)
|
||||||
|
app.td_client.push_message(sent_msg);
|
||||||
|
// Сбрасываем скролл чтобы видеть новое сообщение
|
||||||
|
app.message_scroll_offset = 0;
|
||||||
|
}
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
app.error_message = Some(e);
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
app.error_message = Some("Таймаут отправки".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Открываем чат
|
||||||
|
let prev_selected = app.selected_chat_id;
|
||||||
|
app.select_current_chat();
|
||||||
|
|
||||||
|
if app.selected_chat_id != prev_selected {
|
||||||
|
if let Some(chat_id) = app.get_selected_chat_id() {
|
||||||
|
app.status_message = Some("Загрузка сообщений...".to_string());
|
||||||
|
app.message_scroll_offset = 0;
|
||||||
|
match timeout(Duration::from_secs(10), app.td_client.get_chat_history(chat_id, 100)).await {
|
||||||
|
Ok(Ok(_)) => {
|
||||||
|
// Загружаем недостающие reply info
|
||||||
|
let _ = timeout(Duration::from_secs(5), app.td_client.fetch_missing_reply_info()).await;
|
||||||
|
// Загружаем последнее закреплённое сообщение
|
||||||
|
let _ = timeout(Duration::from_secs(2), app.td_client.load_current_pinned_message(chat_id)).await;
|
||||||
|
// Загружаем черновик
|
||||||
|
app.load_draft();
|
||||||
|
app.status_message = None;
|
||||||
|
}
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
app.error_message = Some(e);
|
||||||
|
app.status_message = None;
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
app.error_message = Some("Таймаут загрузки сообщений".to_string());
|
||||||
|
app.status_message = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Esc - отменить выбор/редактирование/reply или закрыть чат
|
||||||
|
if key.code == KeyCode::Esc {
|
||||||
|
if app.is_selecting_message() {
|
||||||
|
// Отменить выбор сообщения
|
||||||
|
app.selected_message_index = None;
|
||||||
|
} else if app.is_editing() {
|
||||||
|
// Отменить редактирование
|
||||||
|
app.cancel_editing();
|
||||||
|
} else if app.is_replying() {
|
||||||
|
// Отменить режим ответа
|
||||||
|
app.cancel_reply();
|
||||||
|
} else if app.selected_chat_id.is_some() {
|
||||||
|
// Сохраняем черновик если есть текст в инпуте
|
||||||
|
if let Some(chat_id) = app.selected_chat_id {
|
||||||
|
if !app.message_input.is_empty() && !app.is_editing() && !app.is_replying() {
|
||||||
|
let draft_text = app.message_input.clone();
|
||||||
|
let _ = app.td_client.set_draft_message(chat_id, draft_text).await;
|
||||||
|
} else if app.message_input.is_empty() {
|
||||||
|
// Очищаем черновик если инпут пустой
|
||||||
|
let _ = app.td_client.set_draft_message(chat_id, String::new()).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
app.close_chat();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Режим открытого чата
|
||||||
|
if app.selected_chat_id.is_some() {
|
||||||
|
// Режим выбора сообщения для редактирования/удаления
|
||||||
|
if app.is_selecting_message() {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Up => {
|
||||||
|
app.select_previous_message();
|
||||||
|
}
|
||||||
|
KeyCode::Down => {
|
||||||
|
app.select_next_message();
|
||||||
|
// Если вышли из режима выбора (индекс стал None), ничего не делаем
|
||||||
|
}
|
||||||
|
KeyCode::Char('d') | KeyCode::Char('в') | KeyCode::Delete => {
|
||||||
|
// Показать модалку подтверждения удаления
|
||||||
|
if let Some(msg) = app.get_selected_message() {
|
||||||
|
let can_delete = msg.can_be_deleted_only_for_self || msg.can_be_deleted_for_all_users;
|
||||||
|
if can_delete {
|
||||||
|
app.confirm_delete_message_id = Some(msg.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Char('r') | KeyCode::Char('к') => {
|
||||||
|
// Начать режим ответа на выбранное сообщение
|
||||||
|
app.start_reply_to_selected();
|
||||||
|
}
|
||||||
|
KeyCode::Char('f') | KeyCode::Char('а') => {
|
||||||
|
// Начать режим пересылки
|
||||||
|
app.start_forward_selected();
|
||||||
|
}
|
||||||
|
KeyCode::Char('y') | KeyCode::Char('н') => {
|
||||||
|
// Копировать сообщение
|
||||||
|
if let Some(msg) = app.get_selected_message() {
|
||||||
|
let text = format_message_for_clipboard(msg);
|
||||||
|
match copy_to_clipboard(&text) {
|
||||||
|
Ok(_) => {
|
||||||
|
app.status_message = Some("Сообщение скопировано".to_string());
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
app.error_message = Some(format!("Ошибка копирования: {}", e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Char('e') | KeyCode::Char('у') => {
|
||||||
|
// Открыть emoji picker для добавления реакции
|
||||||
|
if let Some(msg) = app.get_selected_message() {
|
||||||
|
let chat_id = app.selected_chat_id.unwrap();
|
||||||
|
let message_id = msg.id;
|
||||||
|
|
||||||
|
app.status_message = Some("Загрузка реакций...".to_string());
|
||||||
|
app.needs_redraw = true;
|
||||||
|
|
||||||
|
// Запрашиваем доступные реакции
|
||||||
|
match timeout(
|
||||||
|
Duration::from_secs(5),
|
||||||
|
app.td_client.get_message_available_reactions(chat_id, message_id)
|
||||||
|
).await {
|
||||||
|
Ok(Ok(reactions)) => {
|
||||||
|
if reactions.is_empty() {
|
||||||
|
app.error_message = Some("Реакции недоступны для этого сообщения".to_string());
|
||||||
|
app.status_message = None;
|
||||||
|
app.needs_redraw = true;
|
||||||
|
} else {
|
||||||
|
app.enter_reaction_picker_mode(message_id, reactions);
|
||||||
|
app.status_message = None;
|
||||||
|
app.needs_redraw = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
app.error_message = Some(format!("Ошибка загрузки реакций: {}", e));
|
||||||
|
app.status_message = None;
|
||||||
|
app.needs_redraw = true;
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
app.error_message = Some("Таймаут загрузки реакций".to_string());
|
||||||
|
app.status_message = None;
|
||||||
|
app.needs_redraw = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+U для профиля
|
||||||
|
if key.code == KeyCode::Char('u') && has_ctrl {
|
||||||
|
if let Some(chat_id) = app.selected_chat_id {
|
||||||
|
app.status_message = Some("Загрузка профиля...".to_string());
|
||||||
|
match timeout(Duration::from_secs(5), app.td_client.get_profile_info(chat_id)).await {
|
||||||
|
Ok(Ok(profile)) => {
|
||||||
|
app.profile_info = Some(profile);
|
||||||
|
app.enter_profile_mode();
|
||||||
|
app.status_message = None;
|
||||||
|
}
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
app.error_message = Some(e);
|
||||||
|
app.status_message = None;
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
app.error_message = Some("Таймаут загрузки профиля".to_string());
|
||||||
|
app.status_message = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
// Удаляем символ слева от курсора
|
||||||
|
if app.cursor_position > 0 {
|
||||||
|
let chars: Vec<char> = app.message_input.chars().collect();
|
||||||
|
let mut new_input = String::new();
|
||||||
|
for (i, ch) in chars.iter().enumerate() {
|
||||||
|
if i != app.cursor_position - 1 {
|
||||||
|
new_input.push(*ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
app.message_input = new_input;
|
||||||
|
app.cursor_position -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Delete => {
|
||||||
|
// Удаляем символ справа от курсора
|
||||||
|
let len = app.message_input.chars().count();
|
||||||
|
if app.cursor_position < len {
|
||||||
|
let chars: Vec<char> = app.message_input.chars().collect();
|
||||||
|
let mut new_input = String::new();
|
||||||
|
for (i, ch) in chars.iter().enumerate() {
|
||||||
|
if i != app.cursor_position {
|
||||||
|
new_input.push(*ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
app.message_input = new_input;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Char(c) => {
|
||||||
|
// Вставляем символ в позицию курсора
|
||||||
|
let chars: Vec<char> = app.message_input.chars().collect();
|
||||||
|
let mut new_input = String::new();
|
||||||
|
for (i, ch) in chars.iter().enumerate() {
|
||||||
|
if i == app.cursor_position {
|
||||||
|
new_input.push(c);
|
||||||
|
}
|
||||||
|
new_input.push(*ch);
|
||||||
|
}
|
||||||
|
if app.cursor_position >= chars.len() {
|
||||||
|
new_input.push(c);
|
||||||
|
}
|
||||||
|
app.message_input = new_input;
|
||||||
|
app.cursor_position += 1;
|
||||||
|
|
||||||
|
// Отправляем typing status с throttling (не чаще 1 раза в 5 сек)
|
||||||
|
let should_send_typing = app.last_typing_sent
|
||||||
|
.map(|t| t.elapsed().as_secs() >= 5)
|
||||||
|
.unwrap_or(true);
|
||||||
|
if should_send_typing {
|
||||||
|
if let Some(chat_id) = app.get_selected_chat_id() {
|
||||||
|
app.td_client.send_chat_action(chat_id, ChatAction::Typing).await;
|
||||||
|
app.last_typing_sent = Some(Instant::now());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Left => {
|
||||||
|
// Курсор влево
|
||||||
|
if app.cursor_position > 0 {
|
||||||
|
app.cursor_position -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Right => {
|
||||||
|
// Курсор вправо
|
||||||
|
let len = app.message_input.chars().count();
|
||||||
|
if app.cursor_position < len {
|
||||||
|
app.cursor_position += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Home => {
|
||||||
|
// Курсор в начало
|
||||||
|
app.cursor_position = 0;
|
||||||
|
}
|
||||||
|
KeyCode::End => {
|
||||||
|
// Курсор в конец
|
||||||
|
app.cursor_position = app.message_input.chars().count();
|
||||||
|
}
|
||||||
|
// Стрелки вверх/вниз - скролл сообщений или начало выбора
|
||||||
|
KeyCode::Down => {
|
||||||
|
// Скролл вниз (к новым сообщениям)
|
||||||
|
if app.message_scroll_offset > 0 {
|
||||||
|
app.message_scroll_offset = app.message_scroll_offset.saturating_sub(3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Up => {
|
||||||
|
// Если инпут пустой и не в режиме редактирования — начать выбор сообщения
|
||||||
|
if app.message_input.is_empty() && !app.is_editing() {
|
||||||
|
app.start_message_selection();
|
||||||
|
} else {
|
||||||
|
// Скролл вверх (к старым сообщениям)
|
||||||
|
app.message_scroll_offset += 3;
|
||||||
|
|
||||||
|
// Проверяем, нужно ли подгрузить старые сообщения
|
||||||
|
if !app.td_client.current_chat_messages.is_empty() {
|
||||||
|
let oldest_msg_id = app.td_client.current_chat_messages.first().map(|m| m.id).unwrap_or(0);
|
||||||
|
if let Some(chat_id) = app.get_selected_chat_id() {
|
||||||
|
// Подгружаем больше сообщений если скролл близко к верху
|
||||||
|
if app.message_scroll_offset > app.td_client.current_chat_messages.len().saturating_sub(10) {
|
||||||
|
if let Ok(Ok(older)) = timeout(
|
||||||
|
Duration::from_secs(3),
|
||||||
|
app.td_client.load_older_messages(chat_id, oldest_msg_id, 20)
|
||||||
|
).await {
|
||||||
|
if !older.is_empty() {
|
||||||
|
// Добавляем старые сообщения в начало
|
||||||
|
let mut new_messages = older;
|
||||||
|
new_messages.extend(app.td_client.current_chat_messages.drain(..));
|
||||||
|
app.td_client.current_chat_messages = new_messages;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// В режиме списка чатов - навигация стрелками и переключение папок
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Down => {
|
||||||
|
app.next_chat();
|
||||||
|
}
|
||||||
|
KeyCode::Up => {
|
||||||
|
app.previous_chat();
|
||||||
|
}
|
||||||
|
// Цифры 1-9 - переключение папок
|
||||||
|
KeyCode::Char(c) if c >= '1' && c <= '9' => {
|
||||||
|
let folder_num = (c as usize) - ('1' as usize); // 0-based
|
||||||
|
if folder_num == 0 {
|
||||||
|
// 1 = All
|
||||||
|
app.selected_folder_id = None;
|
||||||
|
} else {
|
||||||
|
// 2, 3, 4... = папки из TDLib
|
||||||
|
if let Some(folder) = app.td_client.folders.get(folder_num - 1) {
|
||||||
|
let folder_id = folder.id;
|
||||||
|
app.selected_folder_id = Some(folder_id);
|
||||||
|
// Загружаем чаты папки
|
||||||
|
app.status_message = Some("Загрузка чатов папки...".to_string());
|
||||||
|
let _ = timeout(Duration::from_secs(5), app.td_client.load_folder_chats(folder_id, 50)).await;
|
||||||
|
app.status_message = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
app.chat_list_state.select(Some(0));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Подсчёт количества доступных действий в профиле
|
||||||
|
fn get_available_actions_count(profile: &crate::tdlib::ProfileInfo) -> usize {
|
||||||
|
let mut count = 0;
|
||||||
|
|
||||||
|
if profile.username.is_some() {
|
||||||
|
count += 1; // Открыть в браузере
|
||||||
|
}
|
||||||
|
|
||||||
|
count += 1; // Скопировать ID
|
||||||
|
|
||||||
|
if profile.is_group {
|
||||||
|
count += 1; // Покинуть группу
|
||||||
|
}
|
||||||
|
|
||||||
|
count
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Копирует текст в системный буфер обмена
|
||||||
|
fn copy_to_clipboard(text: &str) -> Result<(), String> {
|
||||||
|
use arboard::Clipboard;
|
||||||
|
|
||||||
|
let mut clipboard = Clipboard::new().map_err(|e| format!("Не удалось инициализировать буфер обмена: {}", e))?;
|
||||||
|
clipboard.set_text(text).map_err(|e| format!("Не удалось скопировать: {}", e))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Форматирует сообщение для копирования с контекстом
|
||||||
|
fn format_message_for_clipboard(msg: &crate::tdlib::client::MessageInfo) -> String {
|
||||||
|
let mut result = String::new();
|
||||||
|
|
||||||
|
// Добавляем forward контекст если есть
|
||||||
|
if let Some(forward) = &msg.forward_from {
|
||||||
|
result.push_str(&format!("↪ Переслано от {}\n", forward.sender_name));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавляем reply контекст если есть
|
||||||
|
if let Some(reply) = &msg.reply_to {
|
||||||
|
result.push_str(&format!("┌ {}: {}\n", reply.sender_name, reply.text));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавляем основной текст с markdown форматированием
|
||||||
|
result.push_str(&convert_entities_to_markdown(&msg.content, &msg.entities));
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Конвертирует текст с entities в markdown
|
||||||
|
fn convert_entities_to_markdown(text: &str, entities: &[tdlib_rs::types::TextEntity]) -> String {
|
||||||
|
use tdlib_rs::enums::TextEntityType;
|
||||||
|
|
||||||
|
if entities.is_empty() {
|
||||||
|
return text.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаём вектор символов для работы с unicode
|
||||||
|
let chars: Vec<char> = text.chars().collect();
|
||||||
|
let mut result = String::new();
|
||||||
|
let mut i = 0;
|
||||||
|
|
||||||
|
while i < chars.len() {
|
||||||
|
// Ищем entity, который начинается в текущей позиции
|
||||||
|
let mut entity_found = false;
|
||||||
|
|
||||||
|
for entity in entities {
|
||||||
|
if entity.offset as usize == i {
|
||||||
|
entity_found = true;
|
||||||
|
let end = (entity.offset + entity.length) as usize;
|
||||||
|
let entity_text: String = chars[i..end.min(chars.len())].iter().collect();
|
||||||
|
|
||||||
|
// Применяем форматирование в зависимости от типа
|
||||||
|
let formatted = match &entity.r#type {
|
||||||
|
TextEntityType::Bold => format!("**{}**", entity_text),
|
||||||
|
TextEntityType::Italic => format!("*{}*", entity_text),
|
||||||
|
TextEntityType::Underline => format!("__{}__", entity_text),
|
||||||
|
TextEntityType::Strikethrough => format!("~~{}~~", entity_text),
|
||||||
|
TextEntityType::Code | TextEntityType::Pre | TextEntityType::PreCode(_) => {
|
||||||
|
format!("`{}`", entity_text)
|
||||||
|
}
|
||||||
|
TextEntityType::TextUrl(url_info) => {
|
||||||
|
format!("[{}]({})", entity_text, url_info.url)
|
||||||
|
}
|
||||||
|
TextEntityType::Url => format!("<{}>", entity_text),
|
||||||
|
TextEntityType::Mention | TextEntityType::MentionName(_) => {
|
||||||
|
format!("@{}", entity_text.trim_start_matches('@'))
|
||||||
|
}
|
||||||
|
TextEntityType::Spoiler => format!("||{}||", entity_text),
|
||||||
|
_ => entity_text,
|
||||||
|
};
|
||||||
|
|
||||||
|
result.push_str(&formatted);
|
||||||
|
i = end;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !entity_found {
|
||||||
|
result.push(chars[i]);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
5
src/input/mod.rs
Normal file
5
src/input/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
mod auth;
|
||||||
|
mod main_input;
|
||||||
|
|
||||||
|
pub use auth::handle as handle_auth_input;
|
||||||
|
pub use main_input::handle as handle_main_input;
|
||||||
230
src/main.rs
230
src/main.rs
@@ -1,32 +1,50 @@
|
|||||||
mod app;
|
mod app;
|
||||||
mod telegram;
|
mod config;
|
||||||
|
mod input;
|
||||||
|
mod tdlib;
|
||||||
mod ui;
|
mod ui;
|
||||||
|
mod utils;
|
||||||
|
|
||||||
use anyhow::Result;
|
|
||||||
use crossterm::{
|
use crossterm::{
|
||||||
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode},
|
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers},
|
||||||
execute,
|
execute,
|
||||||
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||||
};
|
};
|
||||||
use ratatui::{
|
use ratatui::{backend::CrosstermBackend, Terminal};
|
||||||
backend::CrosstermBackend,
|
|
||||||
Terminal,
|
|
||||||
};
|
|
||||||
use std::io;
|
use std::io;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tdlib_rs::enums::Update;
|
||||||
|
|
||||||
use app::App;
|
use app::{App, AppScreen};
|
||||||
|
use input::{handle_auth_input, handle_main_input};
|
||||||
|
use tdlib::client::AuthState;
|
||||||
|
use utils::disable_tdlib_logs;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<()> {
|
async fn main() -> Result<(), io::Error> {
|
||||||
|
// Загружаем переменные окружения из .env
|
||||||
|
let _ = dotenvy::dotenv();
|
||||||
|
|
||||||
|
// Загружаем конфигурацию (создаёт дефолтный если отсутствует)
|
||||||
|
let config = config::Config::load();
|
||||||
|
|
||||||
|
// Отключаем логи TDLib ДО создания клиента
|
||||||
|
disable_tdlib_logs();
|
||||||
|
|
||||||
|
// Setup terminal
|
||||||
enable_raw_mode()?;
|
enable_raw_mode()?;
|
||||||
let mut stdout = io::stdout();
|
let mut stdout = io::stdout();
|
||||||
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
|
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
|
||||||
let backend = CrosstermBackend::new(stdout);
|
let backend = CrosstermBackend::new(stdout);
|
||||||
let mut terminal = Terminal::new(backend)?;
|
let mut terminal = Terminal::new(backend)?;
|
||||||
|
|
||||||
let mut app = App::new();
|
// Create app state
|
||||||
|
let mut app = App::new(config);
|
||||||
let res = run_app(&mut terminal, &mut app).await;
|
let res = run_app(&mut terminal, &mut app).await;
|
||||||
|
|
||||||
|
// Restore terminal
|
||||||
disable_raw_mode()?;
|
disable_raw_mode()?;
|
||||||
execute!(
|
execute!(
|
||||||
terminal.backend_mut(),
|
terminal.backend_mut(),
|
||||||
@@ -45,24 +63,188 @@ async fn main() -> Result<()> {
|
|||||||
async fn run_app<B: ratatui::backend::Backend>(
|
async fn run_app<B: ratatui::backend::Backend>(
|
||||||
terminal: &mut Terminal<B>,
|
terminal: &mut Terminal<B>,
|
||||||
app: &mut App,
|
app: &mut App,
|
||||||
) -> Result<()> {
|
) -> io::Result<()> {
|
||||||
loop {
|
// Флаг для остановки polling задачи
|
||||||
terminal.draw(|f| ui::draw(f, app))?;
|
let should_stop = Arc::new(AtomicBool::new(false));
|
||||||
|
let should_stop_clone = should_stop.clone();
|
||||||
|
|
||||||
if event::poll(std::time::Duration::from_millis(100))? {
|
// Канал для передачи updates из polling задачи в main loop
|
||||||
if let Event::Key(key) = event::read()? {
|
let (update_tx, mut update_rx) = tokio::sync::mpsc::unbounded_channel::<Update>();
|
||||||
match key.code {
|
|
||||||
KeyCode::Char('q') | KeyCode::Esc => return Ok(()),
|
// Запускаем polling TDLib receive() в отдельной задаче
|
||||||
KeyCode::Char('1') => app.select_tab(0),
|
let polling_handle = tokio::spawn(async move {
|
||||||
KeyCode::Char('2') => app.select_tab(1),
|
while !should_stop_clone.load(Ordering::Relaxed) {
|
||||||
KeyCode::Char('3') => app.select_tab(2),
|
// receive() с таймаутом 0.1 сек чтобы периодически проверять флаг
|
||||||
KeyCode::Char('4') => app.select_tab(3),
|
let result = tokio::task::spawn_blocking(|| tdlib_rs::receive()).await;
|
||||||
KeyCode::Up => app.previous_chat(),
|
if let Ok(Some((update, _client_id))) = result {
|
||||||
KeyCode::Down => app.next_chat(),
|
if update_tx.send(update).is_err() {
|
||||||
KeyCode::Enter => app.open_chat(),
|
break; // Канал закрыт, выходим
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Запускаем инициализацию TDLib в фоне
|
||||||
|
let client_id = app.td_client.client_id();
|
||||||
|
let api_id = app.td_client.api_id;
|
||||||
|
let api_hash = app.td_client.api_hash.clone();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _ = tdlib_rs::functions::set_tdlib_parameters(
|
||||||
|
false, // use_test_dc
|
||||||
|
"tdlib_data".to_string(), // database_directory
|
||||||
|
"".to_string(), // files_directory
|
||||||
|
"".to_string(), // database_encryption_key
|
||||||
|
true, // use_file_database
|
||||||
|
true, // use_chat_info_database
|
||||||
|
true, // use_message_database
|
||||||
|
false, // use_secret_chats
|
||||||
|
api_id,
|
||||||
|
api_hash,
|
||||||
|
"en".to_string(), // system_language_code
|
||||||
|
"Desktop".to_string(), // device_model
|
||||||
|
"".to_string(), // system_version
|
||||||
|
env!("CARGO_PKG_VERSION").to_string(), // application_version
|
||||||
|
client_id,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
});
|
||||||
|
|
||||||
|
loop {
|
||||||
|
// Обрабатываем updates от TDLib из канала (неблокирующе)
|
||||||
|
let mut had_updates = false;
|
||||||
|
while let Ok(update) = update_rx.try_recv() {
|
||||||
|
app.td_client.handle_update(update);
|
||||||
|
had_updates = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Помечаем UI как требующий перерисовки если были обновления
|
||||||
|
if had_updates {
|
||||||
|
app.needs_redraw = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Очищаем устаревший typing status
|
||||||
|
if app.td_client.clear_stale_typing_status() {
|
||||||
|
app.needs_redraw = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обрабатываем очередь сообщений для отметки как прочитанных
|
||||||
|
if !app.td_client.pending_view_messages.is_empty() {
|
||||||
|
app.td_client.process_pending_view_messages().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обрабатываем очередь user_id для загрузки имён
|
||||||
|
if !app.td_client.pending_user_ids.is_empty() {
|
||||||
|
app.td_client.process_pending_user_ids().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляем состояние экрана на основе auth_state
|
||||||
|
let screen_changed = update_screen_state(app).await;
|
||||||
|
if screen_changed {
|
||||||
|
app.needs_redraw = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Рендерим только если есть изменения
|
||||||
|
if app.needs_redraw {
|
||||||
|
terminal.draw(|f| ui::render(f, app))?;
|
||||||
|
app.needs_redraw = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Используем poll с коротким таймаутом для быстрой реакции на ввод
|
||||||
|
// 16ms ≈ 60 FPS потенциально, но рендерим только при изменениях
|
||||||
|
if event::poll(Duration::from_millis(16))? {
|
||||||
|
match event::read()? {
|
||||||
|
Event::Key(key) => {
|
||||||
|
// Global quit command
|
||||||
|
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||||
|
// Graceful shutdown
|
||||||
|
should_stop.store(true, Ordering::Relaxed);
|
||||||
|
|
||||||
|
// Закрываем TDLib клиент
|
||||||
|
let _ = tdlib_rs::functions::close(app.td_client.client_id()).await;
|
||||||
|
|
||||||
|
// Ждём завершения polling задачи (с таймаутом)
|
||||||
|
let _ = tokio::time::timeout(
|
||||||
|
Duration::from_secs(2),
|
||||||
|
polling_handle
|
||||||
|
).await;
|
||||||
|
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
match app.screen {
|
||||||
|
AppScreen::Loading => {
|
||||||
|
// В состоянии загрузки игнорируем ввод
|
||||||
|
}
|
||||||
|
AppScreen::Auth => handle_auth_input(app, key.code).await,
|
||||||
|
AppScreen::Main => handle_main_input(app, key).await,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Любой ввод требует перерисовки
|
||||||
|
app.needs_redraw = true;
|
||||||
|
}
|
||||||
|
Event::Resize(_, _) => {
|
||||||
|
// При изменении размера терминала нужна перерисовка
|
||||||
|
app.needs_redraw = true;
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Возвращает true если состояние изменилось и требуется перерисовка
|
||||||
|
async fn update_screen_state(app: &mut App) -> bool {
|
||||||
|
use tokio::time::timeout;
|
||||||
|
|
||||||
|
let prev_screen = app.screen.clone();
|
||||||
|
let prev_status = app.status_message.clone();
|
||||||
|
let prev_error = app.error_message.clone();
|
||||||
|
let prev_chats_len = app.chats.len();
|
||||||
|
|
||||||
|
match &app.td_client.auth_state {
|
||||||
|
AuthState::WaitTdlibParameters => {
|
||||||
|
app.screen = AppScreen::Loading;
|
||||||
|
app.status_message = Some("Инициализация TDLib...".to_string());
|
||||||
|
}
|
||||||
|
AuthState::WaitPhoneNumber | AuthState::WaitCode | AuthState::WaitPassword => {
|
||||||
|
app.screen = AppScreen::Auth;
|
||||||
|
app.is_loading = false;
|
||||||
|
}
|
||||||
|
AuthState::Ready => {
|
||||||
|
if prev_screen != AppScreen::Main {
|
||||||
|
app.screen = AppScreen::Main;
|
||||||
|
app.is_loading = true;
|
||||||
|
app.status_message = Some("Загрузка чатов...".to_string());
|
||||||
|
|
||||||
|
// Запрашиваем загрузку чатов с таймаутом
|
||||||
|
let _ = timeout(Duration::from_secs(5), app.td_client.load_chats(50)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Синхронизируем чаты из td_client в app
|
||||||
|
if !app.td_client.chats.is_empty() {
|
||||||
|
app.chats = app.td_client.chats.clone();
|
||||||
|
if app.chat_list_state.selected().is_none() && !app.chats.is_empty() {
|
||||||
|
app.chat_list_state.select(Some(0));
|
||||||
|
}
|
||||||
|
// Убираем статус загрузки когда чаты появились
|
||||||
|
if app.is_loading {
|
||||||
|
app.is_loading = false;
|
||||||
|
app.status_message = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AuthState::Closed => {
|
||||||
|
app.status_message = Some("Соединение закрыто".to_string());
|
||||||
|
}
|
||||||
|
AuthState::Error(e) => {
|
||||||
|
app.error_message = Some(e.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, изменилось ли что-то
|
||||||
|
app.screen != prev_screen
|
||||||
|
|| app.status_message != prev_status
|
||||||
|
|| app.error_message != prev_error
|
||||||
|
|| app.chats.len() != prev_chats_len
|
||||||
}
|
}
|
||||||
|
|||||||
1984
src/tdlib/client.rs
Normal file
1984
src/tdlib/client.rs
Normal file
File diff suppressed because it is too large
Load Diff
7
src/tdlib/mod.rs
Normal file
7
src/tdlib/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
pub mod client;
|
||||||
|
|
||||||
|
pub use client::TdClient;
|
||||||
|
pub use client::UserOnlineStatus;
|
||||||
|
pub use client::NetworkState;
|
||||||
|
pub use client::ProfileInfo;
|
||||||
|
pub use tdlib_rs::enums::ChatAction;
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct Chat {
|
|
||||||
pub name: String,
|
|
||||||
pub last_message: String,
|
|
||||||
pub unread_count: usize,
|
|
||||||
pub is_pinned: bool,
|
|
||||||
pub is_online: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct Message {
|
|
||||||
pub sender: String,
|
|
||||||
pub text: String,
|
|
||||||
pub time: String,
|
|
||||||
pub is_outgoing: bool,
|
|
||||||
pub read_status: u8,
|
|
||||||
}
|
|
||||||
136
src/ui/auth.rs
Normal file
136
src/ui/auth.rs
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
use ratatui::{
|
||||||
|
layout::{Alignment, Constraint, Direction, Layout},
|
||||||
|
style::{Color, Modifier, Style},
|
||||||
|
text::Line,
|
||||||
|
widgets::{Block, Borders, Paragraph},
|
||||||
|
Frame,
|
||||||
|
};
|
||||||
|
use crate::app::App;
|
||||||
|
use crate::tdlib::client::AuthState;
|
||||||
|
|
||||||
|
pub fn render(f: &mut Frame, app: &App) {
|
||||||
|
let area = f.area();
|
||||||
|
|
||||||
|
let vertical_chunks = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Percentage(30),
|
||||||
|
Constraint::Length(15),
|
||||||
|
Constraint::Percentage(30),
|
||||||
|
])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
let horizontal_chunks = Layout::default()
|
||||||
|
.direction(Direction::Horizontal)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Percentage(25),
|
||||||
|
Constraint::Percentage(50),
|
||||||
|
Constraint::Percentage(25),
|
||||||
|
])
|
||||||
|
.split(vertical_chunks[1]);
|
||||||
|
|
||||||
|
let auth_area = horizontal_chunks[1];
|
||||||
|
|
||||||
|
let auth_chunks = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Length(3), // Title
|
||||||
|
Constraint::Length(4), // Instructions
|
||||||
|
Constraint::Length(3), // Input
|
||||||
|
Constraint::Length(2), // Error/Status message
|
||||||
|
Constraint::Min(0), // Spacer
|
||||||
|
])
|
||||||
|
.split(auth_area);
|
||||||
|
|
||||||
|
// Title
|
||||||
|
let title = Paragraph::new("TTUI - Telegram Authentication")
|
||||||
|
.style(
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Cyan)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
)
|
||||||
|
.alignment(Alignment::Center)
|
||||||
|
.block(Block::default().borders(Borders::ALL));
|
||||||
|
f.render_widget(title, auth_chunks[0]);
|
||||||
|
|
||||||
|
// Instructions and Input based on auth state
|
||||||
|
match &app.td_client.auth_state {
|
||||||
|
AuthState::WaitPhoneNumber => {
|
||||||
|
let instructions = vec![
|
||||||
|
Line::from("Введите номер телефона в международном формате"),
|
||||||
|
Line::from("Пример: +79991111111"),
|
||||||
|
];
|
||||||
|
let instructions_widget = Paragraph::new(instructions)
|
||||||
|
.style(Style::default().fg(Color::Gray))
|
||||||
|
.alignment(Alignment::Center)
|
||||||
|
.block(Block::default().borders(Borders::NONE));
|
||||||
|
f.render_widget(instructions_widget, auth_chunks[1]);
|
||||||
|
|
||||||
|
let input_text = format!("📱 {}", app.phone_input);
|
||||||
|
let input = Paragraph::new(input_text)
|
||||||
|
.style(Style::default().fg(Color::Yellow))
|
||||||
|
.alignment(Alignment::Center)
|
||||||
|
.block(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.title(" Phone Number "),
|
||||||
|
);
|
||||||
|
f.render_widget(input, auth_chunks[2]);
|
||||||
|
}
|
||||||
|
AuthState::WaitCode => {
|
||||||
|
let instructions = vec![
|
||||||
|
Line::from("Введите код подтверждения из Telegram"),
|
||||||
|
Line::from("Код был отправлен на ваш номер"),
|
||||||
|
];
|
||||||
|
let instructions_widget = Paragraph::new(instructions)
|
||||||
|
.style(Style::default().fg(Color::Gray))
|
||||||
|
.alignment(Alignment::Center)
|
||||||
|
.block(Block::default().borders(Borders::NONE));
|
||||||
|
f.render_widget(instructions_widget, auth_chunks[1]);
|
||||||
|
|
||||||
|
let input_text = format!("🔐 {}", app.code_input);
|
||||||
|
let input = Paragraph::new(input_text)
|
||||||
|
.style(Style::default().fg(Color::Yellow))
|
||||||
|
.alignment(Alignment::Center)
|
||||||
|
.block(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.title(" Verification Code "),
|
||||||
|
);
|
||||||
|
f.render_widget(input, auth_chunks[2]);
|
||||||
|
}
|
||||||
|
AuthState::WaitPassword => {
|
||||||
|
let instructions = vec![
|
||||||
|
Line::from("Введите пароль двухфакторной аутентификации"),
|
||||||
|
Line::from(""),
|
||||||
|
];
|
||||||
|
let instructions_widget = Paragraph::new(instructions)
|
||||||
|
.style(Style::default().fg(Color::Gray))
|
||||||
|
.alignment(Alignment::Center)
|
||||||
|
.block(Block::default().borders(Borders::NONE));
|
||||||
|
f.render_widget(instructions_widget, auth_chunks[1]);
|
||||||
|
|
||||||
|
let masked_password = "*".repeat(app.password_input.len());
|
||||||
|
let input_text = format!("🔒 {}", masked_password);
|
||||||
|
let input = Paragraph::new(input_text)
|
||||||
|
.style(Style::default().fg(Color::Yellow))
|
||||||
|
.alignment(Alignment::Center)
|
||||||
|
.block(Block::default().borders(Borders::ALL).title(" Password "));
|
||||||
|
f.render_widget(input, auth_chunks[2]);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error or status message
|
||||||
|
if let Some(error) = &app.error_message {
|
||||||
|
let error_widget = Paragraph::new(error.as_str())
|
||||||
|
.style(Style::default().fg(Color::Red))
|
||||||
|
.alignment(Alignment::Center);
|
||||||
|
f.render_widget(error_widget, auth_chunks[3]);
|
||||||
|
} else if let Some(status) = &app.status_message {
|
||||||
|
let status_widget = Paragraph::new(status.as_str())
|
||||||
|
.style(Style::default().fg(Color::Yellow))
|
||||||
|
.alignment(Alignment::Center);
|
||||||
|
f.render_widget(status_widget, auth_chunks[3]);
|
||||||
|
}
|
||||||
|
}
|
||||||
161
src/ui/chat_list.rs
Normal file
161
src/ui/chat_list.rs
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
use ratatui::{
|
||||||
|
layout::{Constraint, Direction, Layout, Rect},
|
||||||
|
style::{Color, Modifier, Style},
|
||||||
|
widgets::{Block, Borders, List, ListItem, Paragraph},
|
||||||
|
Frame,
|
||||||
|
};
|
||||||
|
use crate::app::App;
|
||||||
|
use crate::tdlib::UserOnlineStatus;
|
||||||
|
|
||||||
|
pub fn render(f: &mut Frame, area: Rect, app: &mut App) {
|
||||||
|
let chat_chunks = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Length(3), // Search box
|
||||||
|
Constraint::Min(0), // Chat list
|
||||||
|
Constraint::Length(3), // User status
|
||||||
|
])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
// Search box
|
||||||
|
let search_text = if app.is_searching {
|
||||||
|
if app.search_query.is_empty() {
|
||||||
|
"🔍 Введите для поиска...".to_string()
|
||||||
|
} else {
|
||||||
|
format!("🔍 {}", app.search_query)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
"🔍 Ctrl+S для поиска".to_string()
|
||||||
|
};
|
||||||
|
let search_style = if app.is_searching {
|
||||||
|
Style::default().fg(Color::Yellow)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(Color::DarkGray)
|
||||||
|
};
|
||||||
|
let search = Paragraph::new(search_text)
|
||||||
|
.block(Block::default().borders(Borders::ALL))
|
||||||
|
.style(search_style);
|
||||||
|
f.render_widget(search, chat_chunks[0]);
|
||||||
|
|
||||||
|
// Chat list (filtered if searching)
|
||||||
|
let filtered_chats = app.get_filtered_chats();
|
||||||
|
let items: Vec<ListItem> = filtered_chats
|
||||||
|
.iter()
|
||||||
|
.map(|chat| {
|
||||||
|
let is_selected = app.selected_chat_id == Some(chat.id);
|
||||||
|
let pin_icon = if chat.is_pinned { "📌 " } else { "" };
|
||||||
|
let mute_icon = if chat.is_muted { "🔇 " } else { "" };
|
||||||
|
|
||||||
|
// Онлайн-статус (зелёная точка для онлайн)
|
||||||
|
let status_icon = match app.td_client.get_user_status_by_chat_id(chat.id) {
|
||||||
|
Some(UserOnlineStatus::Online) => "● ",
|
||||||
|
_ => " ",
|
||||||
|
};
|
||||||
|
|
||||||
|
let prefix = if is_selected { "▌" } else { " " };
|
||||||
|
|
||||||
|
let username_text = chat.username.as_ref()
|
||||||
|
.map(|u| format!(" {}", u))
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
// Индикатор упоминаний @
|
||||||
|
let mention_badge = if chat.unread_mention_count > 0 {
|
||||||
|
" @".to_string()
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Индикатор черновика ✎
|
||||||
|
let draft_badge = if chat.draft_text.is_some() {
|
||||||
|
" ✎".to_string()
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
let unread_badge = if chat.unread_count > 0 {
|
||||||
|
format!(" ({})", chat.unread_count)
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
let content = format!("{}{}{}{}{}{}{}{}{}", prefix, status_icon, pin_icon, mute_icon, chat.title, username_text, mention_badge, draft_badge, unread_badge);
|
||||||
|
|
||||||
|
// Цвет: онлайн — зелёные, остальные — белые
|
||||||
|
let style = match app.td_client.get_user_status_by_chat_id(chat.id) {
|
||||||
|
Some(UserOnlineStatus::Online) => Style::default().fg(Color::Green),
|
||||||
|
_ => Style::default().fg(Color::White),
|
||||||
|
};
|
||||||
|
|
||||||
|
ListItem::new(content).style(style)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Заголовок блока: обычный или режим пересылки
|
||||||
|
let block = if app.is_forwarding() {
|
||||||
|
Block::default()
|
||||||
|
.title(" ↪ Выберите чат ")
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(Style::default().fg(Color::Cyan))
|
||||||
|
} else {
|
||||||
|
Block::default().borders(Borders::ALL)
|
||||||
|
};
|
||||||
|
|
||||||
|
let chats_list = List::new(items)
|
||||||
|
.block(block)
|
||||||
|
.highlight_style(
|
||||||
|
Style::default()
|
||||||
|
.add_modifier(Modifier::ITALIC)
|
||||||
|
.fg(Color::Yellow),
|
||||||
|
);
|
||||||
|
|
||||||
|
f.render_stateful_widget(chats_list, chat_chunks[1], &mut app.chat_list_state);
|
||||||
|
|
||||||
|
// User status - показываем статус выбранного чата
|
||||||
|
let (status_text, status_color) = if let Some(chat_id) = app.selected_chat_id {
|
||||||
|
match app.td_client.get_user_status_by_chat_id(chat_id) {
|
||||||
|
Some(UserOnlineStatus::Online) => ("● онлайн".to_string(), Color::Green),
|
||||||
|
Some(UserOnlineStatus::Recently) => ("был(а) недавно".to_string(), Color::Yellow),
|
||||||
|
Some(UserOnlineStatus::Offline(was_online)) => {
|
||||||
|
let formatted = format_was_online(*was_online);
|
||||||
|
(formatted, Color::Gray)
|
||||||
|
}
|
||||||
|
Some(UserOnlineStatus::LastWeek) => ("был(а) на этой неделе".to_string(), Color::DarkGray),
|
||||||
|
Some(UserOnlineStatus::LastMonth) => ("был(а) в этом месяце".to_string(), Color::DarkGray),
|
||||||
|
Some(UserOnlineStatus::LongTimeAgo) => ("был(а) давно".to_string(), Color::DarkGray),
|
||||||
|
None => ("".to_string(), Color::DarkGray), // Для групп/каналов
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Показываем статус выделенного в списке чата
|
||||||
|
let filtered = app.get_filtered_chats();
|
||||||
|
if let Some(i) = app.chat_list_state.selected() {
|
||||||
|
if let Some(chat) = filtered.get(i) {
|
||||||
|
match app.td_client.get_user_status_by_chat_id(chat.id) {
|
||||||
|
Some(UserOnlineStatus::Online) => ("● онлайн".to_string(), Color::Green),
|
||||||
|
Some(UserOnlineStatus::Recently) => ("был(а) недавно".to_string(), Color::Yellow),
|
||||||
|
Some(UserOnlineStatus::Offline(was_online)) => {
|
||||||
|
let formatted = format_was_online(*was_online);
|
||||||
|
(formatted, Color::Gray)
|
||||||
|
}
|
||||||
|
Some(UserOnlineStatus::LastWeek) => ("был(а) на этой неделе".to_string(), Color::DarkGray),
|
||||||
|
Some(UserOnlineStatus::LastMonth) => ("был(а) в этом месяце".to_string(), Color::DarkGray),
|
||||||
|
Some(UserOnlineStatus::LongTimeAgo) => ("был(а) давно".to_string(), Color::DarkGray),
|
||||||
|
None => ("".to_string(), Color::DarkGray),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
("".to_string(), Color::DarkGray)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
("".to_string(), Color::DarkGray)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let status = Paragraph::new(status_text)
|
||||||
|
.block(Block::default().borders(Borders::ALL))
|
||||||
|
.style(Style::default().fg(status_color));
|
||||||
|
f.render_widget(status, chat_chunks[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Форматирование времени "был(а) в ..."
|
||||||
|
fn format_was_online(timestamp: i32) -> String {
|
||||||
|
crate::utils::format_was_online(timestamp)
|
||||||
|
}
|
||||||
46
src/ui/footer.rs
Normal file
46
src/ui/footer.rs
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
use ratatui::{
|
||||||
|
layout::Rect,
|
||||||
|
style::{Color, Style},
|
||||||
|
widgets::Paragraph,
|
||||||
|
Frame,
|
||||||
|
};
|
||||||
|
use crate::app::App;
|
||||||
|
use crate::tdlib::NetworkState;
|
||||||
|
|
||||||
|
pub fn render(f: &mut Frame, area: Rect, app: &App) {
|
||||||
|
// Индикатор состояния сети
|
||||||
|
let network_indicator = match app.td_client.network_state {
|
||||||
|
NetworkState::Ready => "",
|
||||||
|
NetworkState::WaitingForNetwork => "⚠ Нет сети | ",
|
||||||
|
NetworkState::ConnectingToProxy => "⏳ Прокси... | ",
|
||||||
|
NetworkState::Connecting => "⏳ Подключение... | ",
|
||||||
|
NetworkState::Updating => "⏳ Обновление... | ",
|
||||||
|
};
|
||||||
|
|
||||||
|
let status = if let Some(msg) = &app.status_message {
|
||||||
|
format!(" {}{} ", network_indicator, msg)
|
||||||
|
} else if let Some(err) = &app.error_message {
|
||||||
|
format!(" {}Error: {} ", network_indicator, err)
|
||||||
|
} else if app.is_searching {
|
||||||
|
format!(" {}↑/↓: Navigate | Enter: Select | Esc: Cancel ", network_indicator)
|
||||||
|
} else if app.selected_chat_id.is_some() {
|
||||||
|
format!(" {}↑/↓: Scroll | Ctrl+U: Profile | Enter: Send | Esc: Close | Ctrl+R: Refresh | Ctrl+C: Quit ", network_indicator)
|
||||||
|
} else {
|
||||||
|
format!(" {}↑/↓: Navigate | Enter: Open | Ctrl+S: Search | Ctrl+R: Refresh | Ctrl+C: Quit ", network_indicator)
|
||||||
|
};
|
||||||
|
|
||||||
|
let style = if matches!(app.td_client.network_state, NetworkState::WaitingForNetwork) {
|
||||||
|
Style::default().fg(Color::Red)
|
||||||
|
} else if !matches!(app.td_client.network_state, NetworkState::Ready) {
|
||||||
|
Style::default().fg(Color::Cyan)
|
||||||
|
} else if app.error_message.is_some() {
|
||||||
|
Style::default().fg(Color::Red)
|
||||||
|
} else if app.status_message.is_some() {
|
||||||
|
Style::default().fg(Color::Yellow)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(Color::DarkGray)
|
||||||
|
};
|
||||||
|
|
||||||
|
let footer = Paragraph::new(status).style(style);
|
||||||
|
f.render_widget(footer, area);
|
||||||
|
}
|
||||||
40
src/ui/loading.rs
Normal file
40
src/ui/loading.rs
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
use ratatui::{
|
||||||
|
layout::{Alignment, Constraint, Direction, Layout},
|
||||||
|
style::{Color, Modifier, Style},
|
||||||
|
widgets::{Block, Borders, Paragraph},
|
||||||
|
Frame,
|
||||||
|
};
|
||||||
|
use crate::app::App;
|
||||||
|
|
||||||
|
pub fn render(f: &mut Frame, app: &App) {
|
||||||
|
let area = f.area();
|
||||||
|
|
||||||
|
let chunks = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Percentage(40),
|
||||||
|
Constraint::Length(5),
|
||||||
|
Constraint::Percentage(40),
|
||||||
|
])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
let message = app
|
||||||
|
.status_message
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or("Загрузка...");
|
||||||
|
|
||||||
|
let loading = Paragraph::new(message)
|
||||||
|
.style(
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Cyan)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
)
|
||||||
|
.alignment(Alignment::Center)
|
||||||
|
.block(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.title(" TTUI "),
|
||||||
|
);
|
||||||
|
|
||||||
|
f.render_widget(loading, chunks[1]);
|
||||||
|
}
|
||||||
91
src/ui/main_screen.rs
Normal file
91
src/ui/main_screen.rs
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
use ratatui::{
|
||||||
|
layout::{Constraint, Direction, Layout, Rect},
|
||||||
|
style::{Color, Modifier, Style},
|
||||||
|
text::{Line, Span},
|
||||||
|
widgets::{Block, Borders, Paragraph},
|
||||||
|
Frame,
|
||||||
|
};
|
||||||
|
use crate::app::App;
|
||||||
|
use super::{chat_list, messages, footer};
|
||||||
|
|
||||||
|
/// Порог ширины для компактного режима (одна панель)
|
||||||
|
const COMPACT_WIDTH: u16 = 80;
|
||||||
|
|
||||||
|
pub fn render(f: &mut Frame, app: &mut App) {
|
||||||
|
let area = f.area();
|
||||||
|
let is_compact = area.width < COMPACT_WIDTH;
|
||||||
|
|
||||||
|
let chunks = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Length(3), // Folders/tabs
|
||||||
|
Constraint::Min(0), // Main content
|
||||||
|
Constraint::Length(1), // Commands footer
|
||||||
|
])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
render_folders(f, chunks[0], app);
|
||||||
|
|
||||||
|
if is_compact {
|
||||||
|
// Компактный режим: показываем либо список чатов, либо открытый чат
|
||||||
|
if app.selected_chat_id.is_some() {
|
||||||
|
// Чат открыт — показываем только сообщения
|
||||||
|
messages::render(f, chunks[1], app);
|
||||||
|
} else {
|
||||||
|
// Чат не открыт — показываем только список чатов
|
||||||
|
chat_list::render(f, chunks[1], app);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Обычный режим: две панели
|
||||||
|
let main_chunks = Layout::default()
|
||||||
|
.direction(Direction::Horizontal)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Percentage(30), // Chat list
|
||||||
|
Constraint::Percentage(70), // Messages area
|
||||||
|
])
|
||||||
|
.split(chunks[1]);
|
||||||
|
|
||||||
|
chat_list::render(f, main_chunks[0], app);
|
||||||
|
messages::render(f, main_chunks[1], app);
|
||||||
|
}
|
||||||
|
|
||||||
|
footer::render(f, chunks[2], app);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_folders(f: &mut Frame, area: Rect, app: &App) {
|
||||||
|
let mut spans = vec![];
|
||||||
|
|
||||||
|
// "All" всегда первая (клавиша 1)
|
||||||
|
let all_style = if app.selected_folder_id.is_none() {
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Yellow)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(Color::White)
|
||||||
|
};
|
||||||
|
spans.push(Span::styled(" 1:All ", all_style));
|
||||||
|
|
||||||
|
// Папки из TDLib (клавиши 2, 3, 4...)
|
||||||
|
for (i, folder) in app.td_client.folders.iter().enumerate() {
|
||||||
|
spans.push(Span::raw("│"));
|
||||||
|
|
||||||
|
let style = if app.selected_folder_id == Some(folder.id) {
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Yellow)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(Color::White)
|
||||||
|
};
|
||||||
|
|
||||||
|
spans.push(Span::styled(format!(" {}:{} ", i + 2, folder.name), style));
|
||||||
|
}
|
||||||
|
|
||||||
|
let folders_line = Line::from(spans);
|
||||||
|
let folders_widget = Paragraph::new(folders_line).block(
|
||||||
|
Block::default()
|
||||||
|
.title(" TTUI ")
|
||||||
|
.borders(Borders::ALL),
|
||||||
|
);
|
||||||
|
|
||||||
|
f.render_widget(folders_widget, area);
|
||||||
|
}
|
||||||
1270
src/ui/messages.rs
Normal file
1270
src/ui/messages.rs
Normal file
File diff suppressed because it is too large
Load Diff
197
src/ui/mod.rs
197
src/ui/mod.rs
@@ -1,170 +1,45 @@
|
|||||||
use crate::app::App;
|
mod loading;
|
||||||
use ratatui::{
|
mod auth;
|
||||||
layout::{Constraint, Direction, Layout, Rect},
|
mod main_screen;
|
||||||
style::{Color, Modifier, Style},
|
mod chat_list;
|
||||||
text::{Line, Span},
|
mod messages;
|
||||||
widgets::{Block, Borders, List, ListItem, Paragraph},
|
mod footer;
|
||||||
Frame,
|
pub mod profile;
|
||||||
};
|
|
||||||
|
|
||||||
pub fn draw(f: &mut Frame, app: &App) {
|
use ratatui::Frame;
|
||||||
let chunks = Layout::default()
|
use ratatui::layout::Alignment;
|
||||||
.direction(Direction::Vertical)
|
use ratatui::style::{Color, Modifier, Style};
|
||||||
.constraints([
|
use ratatui::widgets::Paragraph;
|
||||||
Constraint::Length(3),
|
use crate::app::{App, AppScreen};
|
||||||
Constraint::Min(0),
|
|
||||||
Constraint::Length(3),
|
|
||||||
])
|
|
||||||
.split(f.area());
|
|
||||||
|
|
||||||
draw_tabs(f, app, chunks[0]);
|
/// Минимальная высота терминала
|
||||||
|
const MIN_HEIGHT: u16 = 10;
|
||||||
|
/// Минимальная ширина терминала
|
||||||
|
const MIN_WIDTH: u16 = 40;
|
||||||
|
|
||||||
let main_chunks = Layout::default()
|
pub fn render(f: &mut Frame, app: &mut App) {
|
||||||
.direction(Direction::Horizontal)
|
let area = f.area();
|
||||||
.constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
|
|
||||||
.split(chunks[1]);
|
|
||||||
|
|
||||||
draw_chat_list(f, app, main_chunks[0]);
|
// Проверяем минимальный размер терминала
|
||||||
draw_messages(f, app, main_chunks[1]);
|
if area.width < MIN_WIDTH || area.height < MIN_HEIGHT {
|
||||||
draw_status_bar(f, app, chunks[2]);
|
render_size_warning(f, area.width, area.height);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_tabs(f: &mut Frame, app: &App, area: Rect) {
|
match app.screen {
|
||||||
let tabs: Vec<Span> = app
|
AppScreen::Loading => loading::render(f, app),
|
||||||
.tabs
|
AppScreen::Auth => auth::render(f, app),
|
||||||
.iter()
|
AppScreen::Main => main_screen::render(f, app),
|
||||||
.enumerate()
|
}
|
||||||
.map(|(i, t)| {
|
}
|
||||||
let style = if i == app.selected_tab {
|
|
||||||
Style::default()
|
|
||||||
.fg(Color::Yellow)
|
|
||||||
.add_modifier(Modifier::BOLD)
|
|
||||||
} else {
|
|
||||||
Style::default().fg(Color::White)
|
|
||||||
};
|
|
||||||
Span::styled(format!(" {}:{} ", i + 1, t), style)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let tabs_line = Line::from(tabs);
|
fn render_size_warning(f: &mut Frame, width: u16, height: u16) {
|
||||||
let tabs_paragraph = Paragraph::new(tabs_line).block(
|
let message = format!(
|
||||||
Block::default()
|
"{}x{}\nМинимум: {}x{}",
|
||||||
.borders(Borders::ALL)
|
width, height, MIN_WIDTH, MIN_HEIGHT
|
||||||
.title("Telegram TUI"),
|
|
||||||
);
|
);
|
||||||
|
let warning = Paragraph::new(message)
|
||||||
f.render_widget(tabs_paragraph, area);
|
.style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD))
|
||||||
}
|
.alignment(Alignment::Center);
|
||||||
|
f.render_widget(warning, f.area());
|
||||||
fn draw_chat_list(f: &mut Frame, app: &App, area: Rect) {
|
|
||||||
let chunks = Layout::default()
|
|
||||||
.direction(Direction::Vertical)
|
|
||||||
.constraints([Constraint::Length(3), Constraint::Min(0)])
|
|
||||||
.split(area);
|
|
||||||
|
|
||||||
let search = Paragraph::new(format!("🔍 {}", app.search_query))
|
|
||||||
.block(Block::default().borders(Borders::ALL));
|
|
||||||
f.render_widget(search, chunks[0]);
|
|
||||||
|
|
||||||
let items: Vec<ListItem> = app
|
|
||||||
.chats
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(i, chat)| {
|
|
||||||
let pin_icon = if chat.is_pinned { "📌 " } else { " " };
|
|
||||||
let unread_badge = if chat.unread_count > 0 {
|
|
||||||
format!(" ({})", chat.unread_count)
|
|
||||||
} else {
|
|
||||||
String::new()
|
|
||||||
};
|
|
||||||
|
|
||||||
let content = format!("{}{}{}", pin_icon, chat.name, unread_badge);
|
|
||||||
|
|
||||||
let style = if Some(i) == app.selected_chat {
|
|
||||||
Style::default()
|
|
||||||
.fg(Color::Yellow)
|
|
||||||
.add_modifier(Modifier::BOLD)
|
|
||||||
.add_modifier(Modifier::REVERSED)
|
|
||||||
} else if chat.unread_count > 0 {
|
|
||||||
Style::default().fg(Color::Cyan)
|
|
||||||
} else {
|
|
||||||
Style::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
ListItem::new(content).style(style)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let list = List::new(items).block(Block::default().borders(Borders::ALL));
|
|
||||||
|
|
||||||
f.render_widget(list, chunks[1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn draw_messages(f: &mut Frame, app: &App, area: Rect) {
|
|
||||||
let chunks = Layout::default()
|
|
||||||
.direction(Direction::Vertical)
|
|
||||||
.constraints([
|
|
||||||
Constraint::Length(3),
|
|
||||||
Constraint::Min(0),
|
|
||||||
Constraint::Length(3),
|
|
||||||
])
|
|
||||||
.split(area);
|
|
||||||
|
|
||||||
let header = Paragraph::new(app.get_current_chat_name()).block(
|
|
||||||
Block::default()
|
|
||||||
.borders(Borders::ALL)
|
|
||||||
.style(Style::default().fg(Color::White)),
|
|
||||||
);
|
|
||||||
f.render_widget(header, chunks[0]);
|
|
||||||
|
|
||||||
let mut message_lines: Vec<Line> = vec![];
|
|
||||||
|
|
||||||
for msg in &app.messages {
|
|
||||||
message_lines.push(Line::from(""));
|
|
||||||
|
|
||||||
let time_and_name = if msg.is_outgoing {
|
|
||||||
let status = match msg.read_status {
|
|
||||||
2 => "✓✓",
|
|
||||||
1 => "✓",
|
|
||||||
_ => "",
|
|
||||||
};
|
|
||||||
format!("{} ────────────────────────────────────── {} {}",
|
|
||||||
msg.sender, msg.time, status)
|
|
||||||
} else {
|
|
||||||
format!("{} ──────────────────────────────────────── {}",
|
|
||||||
msg.sender, msg.time)
|
|
||||||
};
|
|
||||||
|
|
||||||
let style = if msg.is_outgoing {
|
|
||||||
Style::default().fg(Color::Green)
|
|
||||||
} else {
|
|
||||||
Style::default().fg(Color::Cyan)
|
|
||||||
};
|
|
||||||
|
|
||||||
message_lines.push(Line::from(Span::styled(time_and_name, style)));
|
|
||||||
message_lines.push(Line::from(msg.text.clone()));
|
|
||||||
}
|
|
||||||
|
|
||||||
let messages = Paragraph::new(message_lines)
|
|
||||||
.block(Block::default().borders(Borders::ALL))
|
|
||||||
.style(Style::default().fg(Color::White));
|
|
||||||
|
|
||||||
f.render_widget(messages, chunks[1]);
|
|
||||||
|
|
||||||
let input = Paragraph::new(format!("> {}_", app.input))
|
|
||||||
.block(Block::default().borders(Borders::ALL));
|
|
||||||
f.render_widget(input, chunks[2]);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn draw_status_bar(f: &mut Frame, _app: &App, area: Rect) {
|
|
||||||
let status_text = " Esc: Back | Enter: Open | ^R: Reply | ^E: Edit | ^D: Delete";
|
|
||||||
let status = Paragraph::new(status_text)
|
|
||||||
.style(Style::default().fg(Color::Gray))
|
|
||||||
.block(
|
|
||||||
Block::default()
|
|
||||||
.borders(Borders::TOP)
|
|
||||||
.title("[User: Online]"),
|
|
||||||
);
|
|
||||||
|
|
||||||
f.render_widget(status, area);
|
|
||||||
}
|
}
|
||||||
|
|||||||
259
src/ui/profile.rs
Normal file
259
src/ui/profile.rs
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
use ratatui::{
|
||||||
|
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||||
|
style::{Color, Modifier, Style},
|
||||||
|
text::{Line, Span},
|
||||||
|
widgets::{Block, Borders, Paragraph},
|
||||||
|
Frame,
|
||||||
|
};
|
||||||
|
use crate::app::App;
|
||||||
|
use crate::tdlib::client::ProfileInfo;
|
||||||
|
|
||||||
|
/// Рендерит режим просмотра профиля
|
||||||
|
pub fn render(f: &mut Frame, area: Rect, app: &App, profile: &ProfileInfo) {
|
||||||
|
// Проверяем, показывать ли модалку подтверждения
|
||||||
|
let confirmation_step = app.get_leave_group_confirmation_step();
|
||||||
|
if confirmation_step > 0 {
|
||||||
|
render_leave_confirmation_modal(f, area, confirmation_step);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let chunks = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Length(3), // Header
|
||||||
|
Constraint::Min(0), // Profile info
|
||||||
|
Constraint::Length(3), // Actions help
|
||||||
|
])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
// Header
|
||||||
|
let header_text = format!("👤 ПРОФИЛЬ: {}", profile.title);
|
||||||
|
let header = Paragraph::new(header_text)
|
||||||
|
.block(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(Style::default().fg(Color::Cyan))
|
||||||
|
)
|
||||||
|
.style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD));
|
||||||
|
f.render_widget(header, chunks[0]);
|
||||||
|
|
||||||
|
// Profile info
|
||||||
|
let mut lines: Vec<Line> = Vec::new();
|
||||||
|
|
||||||
|
// Тип чата
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::styled("Тип: ", Style::default().fg(Color::Gray)),
|
||||||
|
Span::styled(&profile.chat_type, Style::default().fg(Color::White)),
|
||||||
|
]));
|
||||||
|
lines.push(Line::from(""));
|
||||||
|
|
||||||
|
// ID
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::styled("ID: ", Style::default().fg(Color::Gray)),
|
||||||
|
Span::styled(format!("{}", profile.chat_id), Style::default().fg(Color::White)),
|
||||||
|
]));
|
||||||
|
lines.push(Line::from(""));
|
||||||
|
|
||||||
|
// Username
|
||||||
|
if let Some(username) = &profile.username {
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::styled("Username: ", Style::default().fg(Color::Gray)),
|
||||||
|
Span::styled(username, Style::default().fg(Color::Cyan)),
|
||||||
|
]));
|
||||||
|
lines.push(Line::from(""));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phone number (только для личных чатов)
|
||||||
|
if let Some(phone) = &profile.phone_number {
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::styled("Телефон: ", Style::default().fg(Color::Gray)),
|
||||||
|
Span::styled(phone, Style::default().fg(Color::White)),
|
||||||
|
]));
|
||||||
|
lines.push(Line::from(""));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Online status (только для личных чатов)
|
||||||
|
if let Some(status) = &profile.online_status {
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::styled("Статус: ", Style::default().fg(Color::Gray)),
|
||||||
|
Span::styled(status, Style::default().fg(Color::Green)),
|
||||||
|
]));
|
||||||
|
lines.push(Line::from(""));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bio (только для личных чатов)
|
||||||
|
if let Some(bio) = &profile.bio {
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::styled("О себе: ", Style::default().fg(Color::Gray)),
|
||||||
|
]));
|
||||||
|
// Разбиваем bio на строки если длинное
|
||||||
|
let bio_lines: Vec<&str> = bio.lines().collect();
|
||||||
|
for bio_line in bio_lines {
|
||||||
|
lines.push(Line::from(Span::styled(bio_line, Style::default().fg(Color::White))));
|
||||||
|
}
|
||||||
|
lines.push(Line::from(""));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Member count (для групп/каналов)
|
||||||
|
if let Some(count) = profile.member_count {
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::styled("Участников: ", Style::default().fg(Color::Gray)),
|
||||||
|
Span::styled(format!("{}", count), Style::default().fg(Color::White)),
|
||||||
|
]));
|
||||||
|
lines.push(Line::from(""));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Description (для групп/каналов)
|
||||||
|
if let Some(desc) = &profile.description {
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::styled("Описание: ", Style::default().fg(Color::Gray)),
|
||||||
|
]));
|
||||||
|
let desc_lines: Vec<&str> = desc.lines().collect();
|
||||||
|
for desc_line in desc_lines {
|
||||||
|
lines.push(Line::from(Span::styled(desc_line, Style::default().fg(Color::White))));
|
||||||
|
}
|
||||||
|
lines.push(Line::from(""));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invite link (для групп/каналов)
|
||||||
|
if let Some(link) = &profile.invite_link {
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::styled("Ссылка: ", Style::default().fg(Color::Gray)),
|
||||||
|
Span::styled(link, Style::default().fg(Color::Blue).add_modifier(Modifier::UNDERLINED)),
|
||||||
|
]));
|
||||||
|
lines.push(Line::from(""));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Разделитель
|
||||||
|
lines.push(Line::from("────────────────────────────────"));
|
||||||
|
lines.push(Line::from(""));
|
||||||
|
|
||||||
|
// Действия
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
"Действия:",
|
||||||
|
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
|
||||||
|
)));
|
||||||
|
lines.push(Line::from(""));
|
||||||
|
|
||||||
|
let actions = get_available_actions(profile);
|
||||||
|
for (idx, action) in actions.iter().enumerate() {
|
||||||
|
let is_selected = idx == app.selected_profile_action;
|
||||||
|
let marker = if is_selected { "▶ " } else { " " };
|
||||||
|
let style = if is_selected {
|
||||||
|
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(Color::White)
|
||||||
|
};
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::styled(marker, Style::default().fg(Color::Yellow)),
|
||||||
|
Span::styled(*action, style),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
let info_widget = Paragraph::new(lines)
|
||||||
|
.block(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(Style::default().fg(Color::Cyan))
|
||||||
|
)
|
||||||
|
.scroll((0, 0));
|
||||||
|
f.render_widget(info_widget, chunks[1]);
|
||||||
|
|
||||||
|
// Help bar
|
||||||
|
let help_line = Line::from(vec![
|
||||||
|
Span::styled(" ↑↓ ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
|
||||||
|
Span::raw("навигация"),
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled(" Enter ", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)),
|
||||||
|
Span::raw("выбрать"),
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled(" Esc ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
|
||||||
|
Span::raw("выход"),
|
||||||
|
]);
|
||||||
|
let help = Paragraph::new(help_line)
|
||||||
|
.block(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(Style::default().fg(Color::Cyan))
|
||||||
|
)
|
||||||
|
.alignment(Alignment::Center);
|
||||||
|
f.render_widget(help, chunks[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Получить список доступных действий
|
||||||
|
fn get_available_actions(profile: &ProfileInfo) -> Vec<&'static str> {
|
||||||
|
let mut actions = vec![];
|
||||||
|
|
||||||
|
if profile.username.is_some() {
|
||||||
|
actions.push("Открыть в браузере");
|
||||||
|
}
|
||||||
|
|
||||||
|
actions.push("Скопировать ID");
|
||||||
|
|
||||||
|
if profile.is_group {
|
||||||
|
actions.push("Покинуть группу");
|
||||||
|
}
|
||||||
|
|
||||||
|
actions
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Рендерит модалку подтверждения выхода из группы
|
||||||
|
fn render_leave_confirmation_modal(f: &mut Frame, area: Rect, step: u8) {
|
||||||
|
// Затемняем фон
|
||||||
|
let modal_area = centered_rect(60, 30, area);
|
||||||
|
|
||||||
|
let text = if step == 1 {
|
||||||
|
"Вы хотите выйти из группы?"
|
||||||
|
} else {
|
||||||
|
"Вы ТОЧНО хотите выйти из группы?!?!?"
|
||||||
|
};
|
||||||
|
|
||||||
|
let lines = vec![
|
||||||
|
Line::from(""),
|
||||||
|
Line::from(Span::styled(
|
||||||
|
text,
|
||||||
|
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
|
||||||
|
)),
|
||||||
|
Line::from(""),
|
||||||
|
Line::from(""),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled("y/н/Enter", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)),
|
||||||
|
Span::raw(" — да "),
|
||||||
|
Span::styled("n/т/Esc", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
|
||||||
|
Span::raw(" — нет"),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
|
||||||
|
let modal = Paragraph::new(lines)
|
||||||
|
.block(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(Style::default().fg(Color::Red))
|
||||||
|
.title(" ⚠ ВНИМАНИЕ ")
|
||||||
|
.title_style(Style::default().fg(Color::Red).add_modifier(Modifier::BOLD))
|
||||||
|
)
|
||||||
|
.alignment(Alignment::Center);
|
||||||
|
|
||||||
|
f.render_widget(modal, modal_area);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Вспомогательная функция для центрирования прямоугольника
|
||||||
|
fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
|
||||||
|
let popup_layout = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Percentage((100 - percent_y) / 2),
|
||||||
|
Constraint::Percentage(percent_y),
|
||||||
|
Constraint::Percentage((100 - percent_y) / 2),
|
||||||
|
])
|
||||||
|
.split(r);
|
||||||
|
|
||||||
|
Layout::default()
|
||||||
|
.direction(Direction::Horizontal)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Percentage((100 - percent_x) / 2),
|
||||||
|
Constraint::Percentage(percent_x),
|
||||||
|
Constraint::Percentage((100 - percent_x) / 2),
|
||||||
|
])
|
||||||
|
.split(popup_layout[1])[1]
|
||||||
|
}
|
||||||
160
src/utils.rs
Normal file
160
src/utils.rs
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
use std::ffi::CString;
|
||||||
|
use std::os::raw::c_char;
|
||||||
|
|
||||||
|
#[link(name = "tdjson")]
|
||||||
|
extern "C" {
|
||||||
|
fn td_execute(request: *const c_char) -> *const c_char;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Отключаем логи TDLib синхронно, до создания клиента
|
||||||
|
pub fn disable_tdlib_logs() {
|
||||||
|
let request = r#"{"@type":"setLogVerbosityLevel","new_verbosity_level":0}"#;
|
||||||
|
let c_request = CString::new(request).unwrap();
|
||||||
|
unsafe {
|
||||||
|
let _ = td_execute(c_request.as_ptr());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Также перенаправляем логи в никуда
|
||||||
|
let request2 = r#"{"@type":"setLogStream","log_stream":{"@type":"logStreamEmpty"}}"#;
|
||||||
|
let c_request2 = CString::new(request2).unwrap();
|
||||||
|
unsafe {
|
||||||
|
let _ = td_execute(c_request2.as_ptr());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Форматирование timestamp в время HH:MM с учётом timezone offset
|
||||||
|
/// timezone_str: строка формата "+03:00" или "-05:00"
|
||||||
|
pub fn format_timestamp_with_tz(timestamp: i32, timezone_str: &str) -> String {
|
||||||
|
let secs = timestamp as i64;
|
||||||
|
|
||||||
|
// Парсим timezone offset (например "+03:00" -> 3 часа)
|
||||||
|
let offset_hours = parse_timezone_offset(timezone_str);
|
||||||
|
|
||||||
|
let hours = ((secs % 86400) / 3600) as i32;
|
||||||
|
let minutes = ((secs % 3600) / 60) as u32;
|
||||||
|
|
||||||
|
// Применяем timezone offset
|
||||||
|
let local_hours = ((hours + offset_hours) % 24 + 24) % 24;
|
||||||
|
|
||||||
|
format!("{:02}:{:02}", local_hours, minutes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Парсит timezone строку типа "+03:00" в количество часов
|
||||||
|
fn parse_timezone_offset(tz: &str) -> i32 {
|
||||||
|
// Простой парсинг "+03:00" или "-05:00"
|
||||||
|
if tz.len() >= 3 {
|
||||||
|
let sign = if tz.starts_with('-') { -1 } else { 1 };
|
||||||
|
let hours_str = &tz[1..3];
|
||||||
|
if let Ok(hours) = hours_str.parse::<i32>() {
|
||||||
|
return sign * hours;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
3 // fallback к MSK
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Устаревшая функция для обратной совместимости (используется дефолтный +03:00)
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn format_timestamp(timestamp: i32) -> String {
|
||||||
|
format_timestamp_with_tz(timestamp, "+03:00")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Форматирование timestamp в дату для разделителя
|
||||||
|
pub fn format_date(timestamp: i32) -> String {
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
let now = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs() as i64;
|
||||||
|
|
||||||
|
let msg_day = timestamp as i64 / 86400;
|
||||||
|
let today = now / 86400;
|
||||||
|
|
||||||
|
if msg_day == today {
|
||||||
|
"Сегодня".to_string()
|
||||||
|
} else if msg_day == today - 1 {
|
||||||
|
"Вчера".to_string()
|
||||||
|
} else {
|
||||||
|
// Простое форматирование даты
|
||||||
|
let days_since_epoch = timestamp as i64 / 86400;
|
||||||
|
// Приблизительный расчёт даты (без учёта високосных годов)
|
||||||
|
let year = 1970 + (days_since_epoch / 365) as i32;
|
||||||
|
let day_of_year = days_since_epoch % 365;
|
||||||
|
|
||||||
|
let months = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
||||||
|
let mut month = 0;
|
||||||
|
let mut day = day_of_year as i32;
|
||||||
|
|
||||||
|
for (i, &m) in months.iter().enumerate() {
|
||||||
|
if day < m {
|
||||||
|
month = i + 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
day -= m;
|
||||||
|
}
|
||||||
|
|
||||||
|
format!("{:02}.{:02}.{}", day + 1, month, year)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Получить день из timestamp для группировки
|
||||||
|
pub fn get_day(timestamp: i32) -> i64 {
|
||||||
|
timestamp as i64 / 86400
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Форматирование timestamp в полную дату и время (DD.MM.YYYY HH:MM)
|
||||||
|
pub fn format_datetime(timestamp: i32) -> String {
|
||||||
|
let secs = timestamp as i64;
|
||||||
|
|
||||||
|
// Время
|
||||||
|
let hours = ((secs % 86400) / 3600) as u32;
|
||||||
|
let minutes = ((secs % 3600) / 60) as u32;
|
||||||
|
let local_hours = (hours + 3) % 24; // MSK
|
||||||
|
|
||||||
|
// Дата
|
||||||
|
let days_since_epoch = secs / 86400;
|
||||||
|
let year = 1970 + (days_since_epoch / 365) as i32;
|
||||||
|
let day_of_year = days_since_epoch % 365;
|
||||||
|
|
||||||
|
let months = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
||||||
|
let mut month = 1;
|
||||||
|
let mut day = day_of_year as i32;
|
||||||
|
|
||||||
|
for (i, &m) in months.iter().enumerate() {
|
||||||
|
if day < m {
|
||||||
|
month = i + 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
day -= m;
|
||||||
|
}
|
||||||
|
|
||||||
|
format!("{:02}.{:02}.{} {:02}:{:02}", day + 1, month, year, local_hours, minutes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Форматирование "был(а) онлайн" из timestamp
|
||||||
|
pub fn format_was_online(timestamp: i32) -> String {
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
let now = SystemTime::now()
|
||||||
|
.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user