Compare commits
102 Commits
09c5c5674e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d3565c9ff9 | |||
|
|
90776448ce | ||
| 6344e0ff6a | |||
|
|
c89a5e13f8 | ||
|
|
07a41ff796 | ||
|
|
e2971e5ff5 | ||
| de18d6978b | |||
|
|
dea3559da7 | ||
|
|
260b81443e | ||
|
|
df89c4e376 | ||
|
|
ec2758ce18 | ||
| 564df43910 | |||
|
|
a095fe277b | ||
| 42f16b1a2b | |||
|
|
dfd4184039 | ||
|
|
25c57c55fb | ||
| 044b859cec | |||
|
|
51e7941668 | ||
|
|
3b7ef41cae | ||
|
|
166fda93a4 | ||
|
|
d4e1ed1376 | ||
|
|
d9eb61dda7 | ||
|
|
c7865b46a7 | ||
|
|
264f183510 | ||
|
|
2442a90e23 | ||
|
|
48d883a746 | ||
|
|
df19bc742c | ||
|
|
78fe09bf11 | ||
|
|
8bd08318bb | ||
|
|
6639dc876c | ||
|
|
6d08300daa | ||
|
|
8a467b6418 | ||
|
|
7bc264198f | ||
|
|
2a5fd6aa35 | ||
|
|
b0f1f9fdc2 | ||
|
|
6845ee69bf | ||
|
|
ffd52d2384 | ||
|
|
931954d829 | ||
|
|
1d0bfb53e0 | ||
|
|
c5235de6e2 | ||
| 7ca9ea29ea | |||
|
|
92cc89a2e6 | ||
|
|
7823efa724 | ||
|
|
7dbb2209c8 | ||
|
|
5f1d715e8f | ||
|
|
bccf07501f | ||
|
|
776271ff36 | ||
|
|
8844c2953d | ||
|
|
bea0bcbed0 | ||
|
|
1cc61ea026 | ||
| d10dc6599a | |||
|
|
bd5e5be618 | ||
|
|
72c4a886fa | ||
|
|
222a21770c | ||
|
|
c881f74ecb | ||
|
|
9a04455113 | ||
|
|
dec60ea74e | ||
|
|
5ac10ea24c | ||
|
|
b081886e34 | ||
|
|
0acf864c28 | ||
|
|
88ff4dd3b7 | ||
|
|
2dbbf1cb5b | ||
|
|
315395f1f2 | ||
|
|
6150fe3cdb | ||
|
|
9d9232f74f | ||
|
|
67fd7506b3 | ||
|
|
7e372bffef | ||
|
|
45d03b59fd | ||
|
|
a518875421 | ||
|
|
f4c24ddabe | ||
|
|
ee416dff45 | ||
|
|
3edbaf2c2b | ||
|
|
7b2dd6c9a9 | ||
|
|
3c8fec7ca6 | ||
|
|
0768283e8a | ||
|
|
8e48d076de | ||
| 0cd477f294 | |||
|
|
ed5a4f9c72 | ||
| 8855a07ccd | |||
|
|
2980e52113 | ||
|
|
9465f067fe | ||
|
|
5c92c059c9 | ||
|
|
dd4981d216 | ||
|
|
4d9d76ed23 | ||
|
|
dff0897da4 | ||
|
|
e690acfb09 | ||
|
|
c6beea5608 | ||
| 9cc63952f4 | |||
|
|
2b04b785c0 | ||
|
|
f1a26b906c | ||
|
|
c27d027ebf | ||
| 0a4ab1b40d | |||
| 20f1c470c4 | |||
| c2ddb0a449 | |||
| 72a8f3e6b1 | |||
| 61dc09fd50 | |||
| 86e2b4c804 | |||
| 6c297758a0 | |||
| 65a73f35de | |||
| 0f379dc240 | |||
| b81eec55d6 | |||
| 652b101571 |
40
.github/ISSUE_TEMPLATE/bug_report.md
vendored
40
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -1,40 +0,0 @@
|
||||
---
|
||||
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
34
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -1,34 +0,0 @@
|
||||
---
|
||||
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
51
.github/pull_request_template.md
vendored
@@ -1,51 +0,0 @@
|
||||
## Описание
|
||||
|
||||
Краткое описание изменений в этом 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
50
.github/workflows/ci.yml
vendored
@@ -1,50 +0,0 @@
|
||||
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
|
||||
@@ -108,3 +108,14 @@ default_modes:
|
||||
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
|
||||
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
|
||||
fixed_tools: []
|
||||
|
||||
# override of the corresponding setting in serena_config.yml, see the documentation there.
|
||||
# If null or missing, the value from the global config is used.
|
||||
symbol_info_budget:
|
||||
|
||||
# The language backend to use for this project.
|
||||
# If not set, the global setting from serena_config.yml is used.
|
||||
# Valid values: LSP, JetBrains
|
||||
# Note: the backend is fixed at startup. If a project with a different backend
|
||||
# is activated post-init, an error will be returned.
|
||||
language_backend:
|
||||
|
||||
26
.woodpecker/check.yml
Normal file
26
.woodpecker/check.yml
Normal file
@@ -0,0 +1,26 @@
|
||||
when:
|
||||
- event: pull_request
|
||||
|
||||
steps:
|
||||
- name: fmt
|
||||
image: rust:latest
|
||||
commands:
|
||||
- rustup component add rustfmt
|
||||
- cargo fmt -- --check
|
||||
|
||||
- name: clippy
|
||||
image: rust:latest
|
||||
environment:
|
||||
CARGO_HOME: /tmp/cargo
|
||||
commands:
|
||||
- apt-get update -qq && apt-get install -y -qq pkg-config libssl-dev libdbus-1-dev zlib1g-dev > /dev/null 2>&1
|
||||
- rustup component add clippy
|
||||
- cargo clippy -- -D warnings
|
||||
|
||||
- name: test
|
||||
image: rust:latest
|
||||
environment:
|
||||
CARGO_HOME: /tmp/cargo
|
||||
commands:
|
||||
- apt-get update -qq && apt-get install -y -qq pkg-config libssl-dev libdbus-1-dev zlib1g-dev > /dev/null 2>&1
|
||||
- cargo test
|
||||
66
CHANGELOG.md
66
CHANGELOG.md
@@ -1,66 +0,0 @@
|
||||
# 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
|
||||
1204
CONTEXT.md
1204
CONTEXT.md
File diff suppressed because it is too large
Load Diff
125
CONTRIBUTING.md
125
CONTRIBUTING.md
@@ -1,125 +0,0 @@
|
||||
# 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).
|
||||
1558
Cargo.lock
generated
1558
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
27
Cargo.toml
27
Cargo.toml
@@ -10,30 +10,51 @@ keywords = ["telegram", "tui", "terminal", "cli"]
|
||||
categories = ["command-line-utilities"]
|
||||
|
||||
[features]
|
||||
default = ["clipboard", "url-open"]
|
||||
default = ["clipboard", "url-open", "notifications", "images"]
|
||||
clipboard = ["dep:arboard"]
|
||||
url-open = ["dep:open"]
|
||||
notifications = ["dep:notify-rust"]
|
||||
images = ["dep:ratatui-image", "dep:image"]
|
||||
|
||||
[dependencies]
|
||||
ratatui = "0.29"
|
||||
crossterm = "0.28"
|
||||
tdlib-rs = { version = "1.1", features = ["download-tdlib"] }
|
||||
tdlib-rs = { version = "1.2.0", features = ["download-tdlib"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
async-trait = "0.1"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
dotenvy = "0.15"
|
||||
chrono = "0.4"
|
||||
open = { version = "5.0", optional = true }
|
||||
arboard = { version = "3.4", optional = true }
|
||||
notify-rust = { version = "4.11", optional = true }
|
||||
ratatui-image = { version = "8.1", optional = true, features = ["image-defaults"] }
|
||||
image = { version = "0.25", optional = true }
|
||||
toml = "0.8"
|
||||
dirs = "5.0"
|
||||
thiserror = "1.0"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
base64 = "0.22.1"
|
||||
fs2 = "0.4"
|
||||
|
||||
[dev-dependencies]
|
||||
insta = "1.34"
|
||||
tokio-test = "0.4"
|
||||
criterion = "0.5"
|
||||
|
||||
[build-dependencies]
|
||||
tdlib-rs = { version = "1.1", features = ["download-tdlib"] }
|
||||
tdlib-rs = { version = "1.2.0", features = ["download-tdlib"] }
|
||||
|
||||
[[bench]]
|
||||
name = "group_messages"
|
||||
harness = false
|
||||
|
||||
[[bench]]
|
||||
name = "formatting"
|
||||
harness = false
|
||||
|
||||
[[bench]]
|
||||
name = "format_markdown"
|
||||
harness = false
|
||||
|
||||
@@ -66,6 +66,11 @@ cargo run
|
||||
|
||||
---
|
||||
|
||||
### 4. Работа с git
|
||||
|
||||
НИКОГДА НЕ КОММИТЬ ИЗМЕНЕНИЯ ПОКА ТЕБЯ НЕ ПОПРОСЯТ!!!
|
||||
|
||||
|
||||
## Чеклист перед началом работы
|
||||
|
||||
- [ ] Прочитал CONTEXT.md
|
||||
|
||||
227
FAQ.md
227
FAQ.md
@@ -1,227 +0,0 @@
|
||||
# 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.
|
||||
52
HOTKEYS.md
52
HOTKEYS.md
@@ -41,7 +41,43 @@
|
||||
| `d` / `Delete` | `в` | Удалить сообщение |
|
||||
| `y` | `н` | Копировать текст в буфер обмена |
|
||||
| `e` | `у` | Добавить реакцию (Emoji picker) |
|
||||
| `i` | | Открыть профиль чата/пользователя |
|
||||
| `v` | `м` | Открыть изображение в полном размере |
|
||||
| `Ctrl+i` | `Ctrl+ш` | Открыть профиль чата/пользователя |
|
||||
|
||||
## Просмотр изображений
|
||||
|
||||
### Режим просмотра изображения
|
||||
|
||||
| Клавиша | Действие |
|
||||
|---------|----------|
|
||||
| `v` / `м` | Открыть изображение (в режиме выбора) |
|
||||
| `←` | Предыдущее изображение в чате |
|
||||
| `→` | Следующее изображение в чате |
|
||||
| `Esc` | Закрыть просмотр изображения |
|
||||
|
||||
**Примечание**: Изображения отображаются inline в чате автоматически. Используйте `v` для просмотра в полном размере.
|
||||
|
||||
## Прослушивание голосовых сообщений
|
||||
|
||||
### Управление воспроизведением
|
||||
|
||||
| Клавиша | Русская раскладка | Действие |
|
||||
|---------|-------------------|----------|
|
||||
| `Space` | | Воспроизвести/Пауза (в режиме выбора голосового) |
|
||||
| `s` | `ы` | Остановить воспроизведение |
|
||||
|
||||
### Во время воспроизведения
|
||||
|
||||
| Клавиша | Действие |
|
||||
|---------|----------|
|
||||
| `Space` | Пауза / Возобновить |
|
||||
| `s` / `ы` | Остановить |
|
||||
| `←` | Перемотка назад (по умолчанию -5 сек) |
|
||||
| `→` | Перемотка вперед (по умолчанию +5 сек) |
|
||||
| `↑` | Увеличить громкость (+10%) |
|
||||
| `↓` | Уменьшить громкость (-10%) |
|
||||
|
||||
**Примечание**: Голосовые сообщения показывают progress bar во время воспроизведения: `▶ ████████░░░░░░ 0:08 / 0:15`
|
||||
|
||||
## Модалки подтверждения
|
||||
|
||||
@@ -103,6 +139,8 @@
|
||||
- Удалить: `d` / `в` / `Delete`
|
||||
- Копировать: `y` / `н`
|
||||
- Реакция: `e` / `у`
|
||||
- Просмотр изображения: `v` / `м` (если выбрано сообщение с фото)
|
||||
- Воспроизведение голосового: `Space` (если выбрано голосовое сообщение)
|
||||
- Отменить: `Esc`
|
||||
|
||||
### Режим редактирования
|
||||
@@ -120,6 +158,16 @@
|
||||
- Переслать: `Enter`
|
||||
- Отменить: `Esc`
|
||||
|
||||
### Режим просмотра изображения
|
||||
- Навигация: `←/→` (предыдущее/следующее изображение)
|
||||
- Закрыть: `Esc`
|
||||
|
||||
### Режим воспроизведения голосового
|
||||
- Пауза/Возобновить: `Space`
|
||||
- Остановить: `s` / `ы`
|
||||
- Перемотка: `←/→` (-5с / +5с)
|
||||
- Громкость: `↑/↓` (+/- 10%)
|
||||
|
||||
## Поддержка русской раскладки
|
||||
|
||||
Все основные vim-клавиши поддерживают русскую раскладку:
|
||||
@@ -135,6 +183,8 @@
|
||||
| `d` | `в` | Delete |
|
||||
| `y` | `н` | Copy (Yank) |
|
||||
| `e` | `у` | Emoji reaction |
|
||||
| `v` | `м` | View image |
|
||||
| `s` | `ы` | Stop audio |
|
||||
|
||||
## Подсказки
|
||||
|
||||
|
||||
122
INSTALL.md
122
INSTALL.md
@@ -1,122 +0,0 @@
|
||||
# Установка 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/
|
||||
```
|
||||
@@ -1,357 +1,328 @@
|
||||
# Структура проекта
|
||||
|
||||
## Архитектура (ASCII)
|
||||
|
||||
```
|
||||
┌─────────────┐
|
||||
│ main.rs │ Event loop (60 FPS)
|
||||
└──────┬──────┘
|
||||
│
|
||||
┌────────────┼────────────┐
|
||||
▼ ▼ ▼
|
||||
┌──────────┐ ┌──────────┐ ┌──────────┐
|
||||
│ input/ │ │ app/ │ │ ui/ │
|
||||
│ handlers │ │ state │ │ render │
|
||||
└────┬─────┘ └────┬─────┘ └────┬─────┘
|
||||
│ │ │
|
||||
│ ┌──────┴──────┐ │
|
||||
│ │ methods/ │ │
|
||||
│ │ (5 traits) │ │
|
||||
│ └──────┬──────┘ │
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌─────────────────────────────────┐
|
||||
│ tdlib/ │
|
||||
│ TdClientTrait → TdClient │
|
||||
│ messages/ | auth | chats │
|
||||
└──────────────┬──────────────────┘
|
||||
│
|
||||
┌─────▼─────┐
|
||||
│ TDLib C │
|
||||
│ library │
|
||||
└───────────┘
|
||||
```
|
||||
|
||||
### Data Flow
|
||||
```
|
||||
TDLib Updates → mpsc channel → App state → UI rendering
|
||||
User Input → handlers → App methods (traits) → TdClient → TDLib API
|
||||
```
|
||||
|
||||
## Обзор директорий
|
||||
|
||||
```
|
||||
tele-tui/
|
||||
├── .github/ # GitHub конфигурация
|
||||
│ ├── ISSUE_TEMPLATE/ # Шаблоны для issue
|
||||
│ │ ├── bug_report.md
|
||||
│ │ └── feature_request.md
|
||||
│ ├── workflows/ # GitHub Actions CI/CD
|
||||
│ │ └── ci.yml
|
||||
├── src/
|
||||
│ ├── main.rs # Точка входа, event loop
|
||||
│ ├── lib.rs # Экспорт модулей для тестов
|
||||
│ ├── types.rs # ChatId, MessageId (newtype wrappers)
|
||||
│ ├── constants.rs # MAX_MESSAGES_IN_CHAT, etc.
|
||||
│ ├── formatting.rs # Markdown entity форматирование
|
||||
│ ├── message_grouping.rs # Группировка сообщений по дате/отправителю
|
||||
│ ├── notifications.rs # Desktop уведомления (NotificationManager)
|
||||
│ │
|
||||
│ ├── app/ # Состояние приложения
|
||||
│ │ ├── mod.rs # App<T> struct, конструкторы, getters (372 loc)
|
||||
│ │ ├── state.rs # AppScreen enum
|
||||
│ │ ├── chat_state.rs # ChatState enum (state machine)
|
||||
│ │ ├── chat_filter.rs # ChatFilter, ChatFilterCriteria
|
||||
│ │ ├── chat_list_state.rs # Состояние списка чатов
|
||||
│ │ ├── auth_state.rs # Состояние авторизации
|
||||
│ │ ├── compose_state.rs # Состояние compose bar
|
||||
│ │ ├── ui_state.rs # UI-related state
|
||||
│ │ ├── message_service.rs # Сервис сообщений
|
||||
│ │ ├── message_view_state.rs # Состояние просмотра сообщений
|
||||
│ │ └── methods/ # Trait-based методы App (Этап 2)
|
||||
│ │ ├── mod.rs # Re-exports 5 trait модулей
|
||||
│ │ ├── navigation.rs # NavigationMethods (7 методов)
|
||||
│ │ ├── messages.rs # MessageMethods (8 методов)
|
||||
│ │ ├── compose.rs # ComposeMethods (10 методов)
|
||||
│ │ ├── search.rs # SearchMethods (15 методов)
|
||||
│ │ └── modal.rs # ModalMethods (27 методов)
|
||||
│ │
|
||||
│ ├── config/ # Конфигурация (Этап 5)
|
||||
│ │ ├── mod.rs # Config struct, defaults (350 loc)
|
||||
│ │ ├── keybindings.rs # Command enum, Keybindings
|
||||
│ │ ├── validation.rs # validate(), parse_color()
|
||||
│ │ └── loader.rs # load(), save(), credentials
|
||||
│ │
|
||||
│ ├── input/ # Обработка пользовательского ввода
|
||||
│ │ ├── mod.rs # Роутинг по экранам
|
||||
│ │ ├── auth.rs # Ввод на экране авторизации
|
||||
│ │ ├── main_input.rs # Роутер главного экрана (159 loc, Этап 1)
|
||||
│ │ ├── key_handler.rs # Trait-based обработка клавиш
|
||||
│ │ └── handlers/ # Специализированные обработчики (Этап 1)
|
||||
│ │ ├── mod.rs # Exports + scroll_to_message()
|
||||
│ │ ├── global.rs # Ctrl+R/S/P/F глобальные команды
|
||||
│ │ ├── chat.rs # Открытый чат: ввод, скролл, selection
|
||||
│ │ ├── chat_list.rs # Навигация по списку чатов, папки
|
||||
│ │ ├── compose.rs # Forward mode
|
||||
│ │ ├── modal.rs # Profile, reactions, pinned, delete
|
||||
│ │ ├── search.rs # Поиск чатов и сообщений
|
||||
│ │ ├── clipboard.rs # Копирование в буфер обмена
|
||||
│ │ └── profile.rs # Хелперы профиля
|
||||
│ │
|
||||
│ ├── tdlib/ # TDLib интеграция
|
||||
│ │ ├── mod.rs # Экспорт публичных типов
|
||||
│ │ ├── types.rs # MessageInfo, ChatInfo, ProfileInfo, etc.
|
||||
│ │ ├── trait.rs # TdClientTrait (DI для тестов)
|
||||
│ │ ├── client.rs # TdClient struct, конструктор
|
||||
│ │ ├── client_impl.rs # impl TdClientTrait for TdClient
|
||||
│ │ ├── auth.rs # Авторизация (phone, code, 2FA)
|
||||
│ │ ├── chats.rs # Загрузка чатов, папок
|
||||
│ │ ├── users.rs # Кеш пользователей, статусы
|
||||
│ │ ├── reactions.rs # ReactionInfo, toggle_reaction
|
||||
│ │ ├── chat_helpers.rs # Вспомогательные функции чатов
|
||||
│ │ ├── update_handlers.rs # Обработка TDLib update events
|
||||
│ │ ├── message_converter.rs # Конвертация TDLib → MessageInfo
|
||||
│ │ ├── message_conversion.rs # Доп. функции конвертации
|
||||
│ │ └── messages/ # Менеджер сообщений (Этап 4)
|
||||
│ │ ├── mod.rs # MessageManager struct (99 loc)
|
||||
│ │ ├── convert.rs # convert_message, fetch_reply_info
|
||||
│ │ └── operations.rs # 11 TDLib API операций (616 loc)
|
||||
│ │
|
||||
│ ├── ui/ # Рендеринг интерфейса
|
||||
│ │ ├── mod.rs # render() — роутинг по экранам
|
||||
│ │ ├── loading.rs # Экран загрузки
|
||||
│ │ ├── auth.rs # Экран авторизации
|
||||
│ │ ├── main_screen.rs # Главный экран + папки
|
||||
│ │ ├── footer.rs # Футер с командами и статусом сети
|
||||
│ │ ├── chat_list.rs # Список чатов + онлайн-статус
|
||||
│ │ ├── messages.rs # Область сообщений (364 loc, Этап 3)
|
||||
│ │ ├── compose_bar.rs # Multi-mode input box (Этап 3)
|
||||
│ │ ├── profile.rs # Профиль пользователя/чата
|
||||
│ │ ├── modals/ # Модальные окна (Этап 3)
|
||||
│ │ │ ├── mod.rs # Re-exports
|
||||
│ │ │ ├── delete_confirm.rs # Подтверждение удаления
|
||||
│ │ │ ├── reaction_picker.rs # Выбор реакции
|
||||
│ │ │ ├── search.rs # Поиск по сообщениям
|
||||
│ │ │ └── pinned.rs # Закреплённые сообщения
|
||||
│ │ └── components/ # Переиспользуемые UI компоненты (Этап 6)
|
||||
│ │ ├── mod.rs # Re-exports
|
||||
│ │ ├── modal.rs # render_modal(), render_delete_confirm
|
||||
│ │ ├── input_field.rs # render_input_field()
|
||||
│ │ ├── message_bubble.rs # render_message_bubble(), sender, date
|
||||
│ │ ├── message_list.rs # render_message_item(), help_bar, scroll
|
||||
│ │ ├── chat_list_item.rs # render_chat_list_item()
|
||||
│ │ └── emoji_picker.rs # render_emoji_picker()
|
||||
│ │
|
||||
│ └── utils/ # Утилиты
|
||||
│ ├── mod.rs # Exports, with_timeout helpers
|
||||
│ ├── formatting.rs # format_timestamp, format_date, etc.
|
||||
│ ├── tdlib.rs # disable_tdlib_logs (FFI)
|
||||
│ ├── validation.rs # is_non_empty и др.
|
||||
│ ├── modal_handler.rs # handle_yes_no для Y/N модалок
|
||||
│ └── retry.rs # Retry утилиты
|
||||
│
|
||||
├── tests/ # Интеграционные тесты
|
||||
│ ├── helpers/ # Тестовая инфраструктура
|
||||
│ │ ├── mod.rs
|
||||
│ │ ├── app_builder.rs # TestAppBuilder (fluent API)
|
||||
│ │ ├── fake_tdclient.rs # FakeTdClient (мок TDLib)
|
||||
│ │ ├── fake_tdclient_impl.rs # impl TdClientTrait for FakeTdClient
|
||||
│ │ ├── test_data.rs # create_test_chat, TestMessageBuilder
|
||||
│ │ └── snapshot_utils.rs # Snapshot testing хелперы
|
||||
│ ├── input_navigation.rs # Тесты навигации клавиатурой
|
||||
│ ├── chat_list.rs # Тесты списка чатов
|
||||
│ ├── messages.rs # Тесты сообщений
|
||||
│ ├── send_message.rs # Тесты отправки
|
||||
│ ├── edit_message.rs # Тесты редактирования
|
||||
│ ├── delete_message.rs # Тесты удаления
|
||||
│ ├── reply_forward.rs # Тесты reply/forward
|
||||
│ ├── reactions.rs # Тесты реакций
|
||||
│ ├── search.rs # Тесты поиска
|
||||
│ ├── modals.rs # Тесты модальных окон
|
||||
│ ├── profile.rs # Тесты профиля
|
||||
│ ├── navigation.rs # Тесты навигации
|
||||
│ ├── drafts.rs # Тесты черновиков
|
||||
│ ├── copy.rs # Тесты копирования
|
||||
│ ├── screens.rs # Тесты экранов
|
||||
│ ├── footer.rs # Тесты футера
|
||||
│ ├── input_field.rs # Тесты поля ввода
|
||||
│ ├── config.rs # Тесты конфигурации
|
||||
│ ├── network_typing.rs # Тесты typing status
|
||||
│ ├── e2e_smoke.rs # Smoke тесты
|
||||
│ └── e2e_user_journey.rs # E2E user journey тесты
|
||||
│
|
||||
├── .github/ # GitHub конфигурация
|
||||
│ ├── ISSUE_TEMPLATE/
|
||||
│ ├── workflows/ci.yml
|
||||
│ └── pull_request_template.md
|
||||
│
|
||||
├── docs/ # Дополнительная документация
|
||||
│ └── TDLIB_INTEGRATION.md
|
||||
├── Cargo.toml # Манифест проекта
|
||||
├── Cargo.lock # Точные версии зависимостей
|
||||
├── build.rs # Build script (TDLib)
|
||||
├── rustfmt.toml # cargo fmt конфигурация
|
||||
├── .editorconfig # Настройки IDE
|
||||
├── .gitignore # Git ignore
|
||||
│
|
||||
├── 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 # Утилиты
|
||||
├── config.toml.example # Пример конфигурации
|
||||
├── credentials.example # Пример credentials
|
||||
│
|
||||
├── 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 # Политика безопасности
|
||||
├── CLAUDE.md # Инструкции для AI
|
||||
├── CONTEXT.md # Текущий статус
|
||||
├── ROADMAP.md # План развития
|
||||
├── DEVELOPMENT.md # Правила разработки
|
||||
├── REQUIREMENTS.md # Требования
|
||||
├── ARCHITECTURE.md # C4, sequence diagrams
|
||||
├── PROJECT_STRUCTURE.md # Этот файл
|
||||
├── E2E_TESTING.md # Гайд по тестированию
|
||||
├── HOTKEYS.md # Горячие клавиши
|
||||
├── CHANGELOG.md # История изменений
|
||||
├── README.md # Главная документация
|
||||
├── INSTALL.md # Установка
|
||||
├── FAQ.md # FAQ
|
||||
├── CONTRIBUTING.md # Гайд по контрибуции
|
||||
├── SECURITY.md # Безопасность
|
||||
└── LICENSE # MIT лицензия
|
||||
```
|
||||
|
||||
## Исходный код (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
|
||||
`App<T: TdClientTrait>` — главная структура, параметризована trait'ом для DI.
|
||||
|
||||
#### state.rs
|
||||
- `AppScreen` enum — текущий экран (Loading, Auth, Main)
|
||||
**State machine** (`ChatState` enum):
|
||||
```
|
||||
Normal → MessageSelection → Editing
|
||||
→ Reply
|
||||
→ Forward
|
||||
→ DeleteConfirmation
|
||||
→ ReactionPicker
|
||||
→ Profile
|
||||
→ SearchInChat
|
||||
→ PinnedMessages
|
||||
```
|
||||
|
||||
### 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
|
||||
- Футер с командами
|
||||
- Индикатор состояния сети
|
||||
**Trait-based methods** (5 traits на `App<T>`):
|
||||
| Trait | Методы | Описание |
|
||||
|-------|--------|----------|
|
||||
| NavigationMethods | 7 | next/previous_chat, close_chat, select_current_chat |
|
||||
| MessageMethods | 8 | is_editing, is_replying, get_selected_message, etc. |
|
||||
| ComposeMethods | 10 | start_reply, cancel_editing, load_draft, etc. |
|
||||
| SearchMethods | 15 | start_search, enter_message_search_mode, etc. |
|
||||
| ModalMethods | 27 | enter_profile_mode, exit_pinned_mode, etc. |
|
||||
|
||||
### input/ — Обработка ввода
|
||||
|
||||
#### mod.rs
|
||||
- Роутинг ввода по экранам
|
||||
**Маршрутизация** (порядок приоритетов в `main_input.rs`):
|
||||
1. Global commands (Ctrl+R/S/P/F)
|
||||
2. Profile mode
|
||||
3. Message search mode
|
||||
4. Pinned messages mode
|
||||
5. Reaction picker mode
|
||||
6. Delete confirmation
|
||||
7. Forward mode
|
||||
8. Chat search mode
|
||||
9. Enter/Esc commands
|
||||
10. Open chat input / Chat list navigation
|
||||
|
||||
#### auth.rs
|
||||
- Обработка ввода на экране авторизации
|
||||
### tdlib/ — Telegram интеграция
|
||||
|
||||
#### 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
|
||||
- Поддержка русской раскладки
|
||||
**Dependency Injection**: `TdClientTrait` позволяет подменять TdClient на `FakeTdClient` в тестах.
|
||||
|
||||
## Конфигурационные файлы
|
||||
**MessageManager** — управление сообщениями:
|
||||
- `convert.rs` — конвертация TDLib JSON → MessageInfo
|
||||
- `operations.rs` — 11 API операций (get_history, send, edit, delete, forward, search, etc.)
|
||||
|
||||
### Cargo.toml
|
||||
Манифест проекта:
|
||||
- Metadata (name, version, authors, license)
|
||||
- Dependencies
|
||||
- Build dependencies (tdlib-rs)
|
||||
### ui/ — Рендеринг
|
||||
|
||||
### rustfmt.toml
|
||||
Конфигурация `cargo fmt`:
|
||||
- max_width = 100
|
||||
- imports_granularity = "Crate"
|
||||
- Стиль комментариев
|
||||
**Компоненты** (`ui/components/`):
|
||||
| Компонент | Описание |
|
||||
|-----------|----------|
|
||||
| message_bubble | Рендеринг пузыря сообщения с реакциями |
|
||||
| message_list | Элемент списка сообщений (search/pinned) |
|
||||
| chat_list_item | Элемент списка чатов |
|
||||
| input_field | Поле ввода с курсором |
|
||||
| emoji_picker | Сетка выбора реакций |
|
||||
| modal | Центрированная модалка |
|
||||
|
||||
### .editorconfig
|
||||
Универсальные настройки для IDE:
|
||||
- Unix line endings (LF)
|
||||
- UTF-8 encoding
|
||||
- Отступы (4 spaces для Rust)
|
||||
### config/ — Конфигурация
|
||||
|
||||
## Рантайм файлы
|
||||
- **mod.rs** — struct Config, GeneralConfig, ColorsConfig, NotificationsConfig
|
||||
- **keybindings.rs** — Command enum (30+ команд), кастомные горячие клавиши
|
||||
- **validation.rs** — валидация timezone, цветов
|
||||
- **loader.rs** — загрузка из `~/.config/tele-tui/config.toml`, credentials
|
||||
|
||||
### tdlib_data/
|
||||
Создаётся автоматически TDLib:
|
||||
- Токены авторизации
|
||||
- Кеш сообщений и файлов
|
||||
- **НЕ коммитится** (в .gitignore)
|
||||
- **НЕ делиться** (содержит чувствительные данные)
|
||||
## Тестирование
|
||||
|
||||
### ~/.config/tele-tui/
|
||||
XDG config directory:
|
||||
- `config.toml` — пользовательская конфигурация
|
||||
- `credentials` — API_ID и API_HASH
|
||||
**500+ тестов** через `cargo test` (без TDLib).
|
||||
|
||||
## Документация
|
||||
**Инфраструктура**:
|
||||
- `TestAppBuilder` — fluent API для создания App с нужным состоянием
|
||||
- `FakeTdClient` — мок TDLib, реализует TdClientTrait
|
||||
- `TestMessageBuilder` — создание тестовых сообщений
|
||||
|
||||
### Пользовательская
|
||||
- **README.md** — главная страница, overview
|
||||
- **INSTALL.md** — установка и настройка
|
||||
- **HOTKEYS.md** — все горячие клавиши
|
||||
- **FAQ.md** — часто задаваемые вопросы
|
||||
|
||||
### Разработчика
|
||||
- **CONTRIBUTING.md** — как внести вклад
|
||||
- **DEVELOPMENT.md** — правила разработки
|
||||
- **PROJECT_STRUCTURE.md** — этот файл
|
||||
- **ROADMAP.md** — план развития
|
||||
- **REFACTORING_ROADMAP.md** — план рефакторинга
|
||||
- **TESTING_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>,
|
||||
}
|
||||
```
|
||||
**Типы тестов**:
|
||||
- Unit-тесты — в `#[cfg(test)]` секциях модулей
|
||||
- Integration-тесты — в `tests/` (навигация, отправка, UI рендеринг)
|
||||
- Doc-тесты — примеры в документации
|
||||
- E2E — smoke и user journey тесты
|
||||
|
||||
## Потоки выполнения
|
||||
|
||||
### Main thread
|
||||
- Event loop (16ms tick для 60 FPS)
|
||||
- UI rendering
|
||||
- Input handling
|
||||
- App state updates
|
||||
```
|
||||
Main thread TDLib thread
|
||||
│ │
|
||||
│ ◄── mpsc ─────── │ td_client.receive() в Tokio task
|
||||
│ │
|
||||
├── poll events │
|
||||
├── handle input │
|
||||
├── update state │
|
||||
├── render UI │
|
||||
└── sleep 16ms ──► │
|
||||
```
|
||||
|
||||
### TDLib thread
|
||||
- `td_client.receive()` в отдельном Tokio task
|
||||
- Updates отправляются через `mpsc::channel`
|
||||
- Неблокирующий для main thread
|
||||
## Рантайм файлы
|
||||
|
||||
### Blocking operations
|
||||
- Загрузка конфига (при запуске)
|
||||
- Авторизация (блокирует до ввода кода)
|
||||
- Graceful shutdown (2 sec timeout)
|
||||
| Путь | Описание |
|
||||
|------|----------|
|
||||
| `~/.config/tele-tui/config.toml` | Пользовательская конфигурация |
|
||||
| `~/.config/tele-tui/credentials` | API_ID и API_HASH |
|
||||
| `tdlib_data/` | TDLib сессия (НЕ коммитится) |
|
||||
|
||||
## Зависимости
|
||||
|
||||
### 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/`
|
||||
| Категория | Крейт | Назначение |
|
||||
|-----------|-------|------------|
|
||||
| UI | ratatui 0.29 | TUI framework |
|
||||
| UI | crossterm 0.28 | Terminal control |
|
||||
| Telegram | tdlib-rs 1.1 | TDLib bindings |
|
||||
| Async | tokio 1.x | Async runtime |
|
||||
| Config | serde + toml | Serialization |
|
||||
| Time | chrono 0.4 | Date/time |
|
||||
| System | dirs 5.0 | XDG directories |
|
||||
| System | arboard 3.4 | Clipboard |
|
||||
| Notify | notify-rust 4.11 | Desktop уведомления (feature) |
|
||||
| URL | open 5.0 | Открытие URL (feature) |
|
||||
|
||||
@@ -1,868 +0,0 @@
|
||||
# Refactoring Roadmap
|
||||
|
||||
Этот документ содержит список технического долга и планов по рефакторингу кодовой базы.
|
||||
|
||||
## Приоритет 1: Критичные улучшения
|
||||
|
||||
### 1. Схлопнуть состояния чата в enum
|
||||
|
||||
**Проблема**: Сейчас состояния чата хранятся как отдельные boolean поля в `App`:
|
||||
```rust
|
||||
is_message_selection_mode: bool,
|
||||
is_editing_mode: bool,
|
||||
is_reply_mode: bool,
|
||||
is_forward_mode: bool,
|
||||
is_delete_confirmation: bool,
|
||||
is_reaction_picker_mode: bool,
|
||||
is_profile_mode: bool,
|
||||
is_search_in_chat_mode: bool,
|
||||
```
|
||||
|
||||
**Решение**: Создать enum `ChatState`:
|
||||
```rust
|
||||
enum ChatState {
|
||||
Normal,
|
||||
MessageSelection {
|
||||
selected_message_id: i64,
|
||||
},
|
||||
Editing {
|
||||
message_id: i64,
|
||||
original_text: String,
|
||||
},
|
||||
Reply {
|
||||
message_id: i64,
|
||||
preview_text: String,
|
||||
},
|
||||
Forward {
|
||||
message_id: i64,
|
||||
selected_chat_index: usize,
|
||||
},
|
||||
DeleteConfirmation {
|
||||
message_id: i64,
|
||||
},
|
||||
ReactionPicker {
|
||||
message_id: i64,
|
||||
available_reactions: Vec<String>,
|
||||
selected_index: usize,
|
||||
},
|
||||
Profile {
|
||||
info: ProfileInfo,
|
||||
},
|
||||
SearchInChat {
|
||||
query: String,
|
||||
results: Vec<i64>,
|
||||
current_index: usize,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**Преимущества**:
|
||||
- Невозможно иметь несколько состояний одновременно (type-safe)
|
||||
- Проще обрабатывать переходы между состояниями
|
||||
- Меньше полей в `App`
|
||||
- Данные, связанные с состоянием, хранятся вместе с ним
|
||||
|
||||
**Затронутые файлы**:
|
||||
- `src/app/mod.rs` (добавить enum, убрать boolean поля)
|
||||
- `src/input/main_input.rs` (изменить логику обработки на match)
|
||||
- `src/ui/messages.rs` (изменить рендеринг на match)
|
||||
|
||||
---
|
||||
|
||||
### 2. Разделить TdClient на несколько модулей
|
||||
|
||||
**Проблема**: `TdClient` в `src/tdlib/client.rs` (~1500+ строк) делает слишком много:
|
||||
- Авторизация
|
||||
- Управление чатами
|
||||
- Управление сообщениями
|
||||
- Кеширование пользователей
|
||||
- Реакции
|
||||
- Network state
|
||||
|
||||
**Решение**: Разделить на модули:
|
||||
```
|
||||
src/tdlib/
|
||||
├── mod.rs # Экспорт публичных типов
|
||||
├── client.rs # Основной TdClient
|
||||
├── auth.rs # AuthManager
|
||||
├── chats.rs # ChatManager
|
||||
├── messages.rs # MessageManager
|
||||
├── users.rs # UserCache
|
||||
└── reactions.rs # ReactionManager
|
||||
```
|
||||
|
||||
**Преимущества**:
|
||||
- Принцип единственной ответственности
|
||||
- Проще тестировать отдельные модули
|
||||
- Легче найти и изменить код
|
||||
|
||||
---
|
||||
|
||||
### 3. Вынести константы в отдельный модуль
|
||||
|
||||
**Проблема**: Магические числа разбросаны по всему коду:
|
||||
```rust
|
||||
// В разных местах:
|
||||
500 // MAX_MESSAGES_IN_CHAT
|
||||
500 // MAX_USER_CACHE_SIZE
|
||||
200 // MAX_CHATS
|
||||
8 // Emoji picker columns
|
||||
10 // Max input height
|
||||
16 // Poll timeout (60 FPS)
|
||||
```
|
||||
|
||||
**Решение**: Создать `src/constants.rs`:
|
||||
```rust
|
||||
// Memory limits
|
||||
pub const MAX_MESSAGES_IN_CHAT: usize = 500;
|
||||
pub const MAX_USER_CACHE_SIZE: usize = 500;
|
||||
pub const MAX_CHATS: usize = 200;
|
||||
pub const MAX_CHAT_USER_IDS: usize = 500;
|
||||
|
||||
// UI constants
|
||||
pub const EMOJI_PICKER_COLUMNS: usize = 8;
|
||||
pub const EMOJI_PICKER_ROWS: usize = 6;
|
||||
pub const MAX_INPUT_HEIGHT: usize = 10;
|
||||
pub const MIN_TERMINAL_WIDTH: u16 = 80;
|
||||
pub const MIN_TERMINAL_HEIGHT: u16 = 20;
|
||||
|
||||
// Performance
|
||||
pub const POLL_TIMEOUT_MS: u64 = 16; // 60 FPS
|
||||
pub const SHUTDOWN_TIMEOUT_SECS: u64 = 2;
|
||||
pub const LAZY_LOAD_USERS_PER_TICK: usize = 5;
|
||||
|
||||
// TDLib
|
||||
pub const TDLIB_CHAT_LIMIT: i32 = 50;
|
||||
pub const TDLIB_MESSAGE_LIMIT: i32 = 50;
|
||||
```
|
||||
|
||||
**Преимущества**:
|
||||
- Единое место для всех констант
|
||||
- Проще изменить значения
|
||||
- Самодокументирующийся код
|
||||
|
||||
---
|
||||
|
||||
## Приоритет 2: Улучшение типобезопасности
|
||||
|
||||
### 4. Newtype pattern для ID ✅ ЗАВЕРШЕНО!
|
||||
|
||||
**Статус**: ЗАВЕРШЕНО (2026-01-31)
|
||||
|
||||
**Проблема**: Везде используется `i64` для `chat_id`, `message_id`, `user_id` — легко перепутать.
|
||||
|
||||
**Решение**: ✅ Реализовано в `src/types.rs`:
|
||||
```rust
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct ChatId(pub i64);
|
||||
|
||||
impl ChatId {
|
||||
pub fn new(id: i64) -> Self { Self(id) }
|
||||
pub fn as_i64(&self) -> i64 { self.0 }
|
||||
}
|
||||
|
||||
impl From<i64> for ChatId {
|
||||
fn from(id: i64) -> Self { ChatId(id) }
|
||||
}
|
||||
|
||||
impl Display for ChatId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
// Аналогично для MessageId и UserId
|
||||
```
|
||||
|
||||
**Что сделано**:
|
||||
- ✅ Создан `src/types.rs` с тремя типами: `ChatId`, `MessageId`, `UserId`
|
||||
- ✅ Добавлены методы `new()`, `as_i64()`, `From<i64>`, `Display`
|
||||
- ✅ Реализованы traits: `Hash`, `Eq`, `Serialize`, `Deserialize`
|
||||
- ✅ Обновлены 15+ модулей:
|
||||
- `tdlib/types.rs`, `tdlib/chats.rs`, `tdlib/messages.rs`, `tdlib/users.rs`
|
||||
- `tdlib/reactions.rs`, `tdlib/client.rs`
|
||||
- `app/mod.rs`, `app/chat_state.rs`, `input/main_input.rs`
|
||||
- Test helpers: `app_builder.rs`, `test_data.rs`
|
||||
- ✅ Исправлены 53 ошибки компиляции
|
||||
- ✅ Код компилируется успешно
|
||||
|
||||
**Преимущества**:
|
||||
- ✅ Невозможно случайно передать message_id вместо chat_id
|
||||
- ✅ Компилятор ловит ошибки на этапе компиляции
|
||||
- ✅ Улучшенная читаемость кода
|
||||
- ✅ Самодокументирующиеся типы
|
||||
|
||||
---
|
||||
|
||||
### 5. Создать enum для ошибок
|
||||
|
||||
**Проблема**: Везде используется `Result<T, String>` — теряется контекст ошибок.
|
||||
|
||||
**Решение**: Создать `src/error.rs`:
|
||||
```rust
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum TeletuiError {
|
||||
#[error("TDLib error: {0}")]
|
||||
TdLib(String),
|
||||
|
||||
#[error("Configuration error: {0}")]
|
||||
Config(String),
|
||||
|
||||
#[error("Network error: {0}")]
|
||||
Network(String),
|
||||
|
||||
#[error("Authentication error: {0}")]
|
||||
Auth(String),
|
||||
|
||||
#[error("Invalid timezone format: {0}")]
|
||||
InvalidTimezone(String),
|
||||
|
||||
#[error("Invalid color: {0}")]
|
||||
InvalidColor(String),
|
||||
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, TeletuiError>;
|
||||
```
|
||||
|
||||
**Зависимости**: `thiserror = "1.0"`
|
||||
|
||||
**Преимущества**:
|
||||
- Типобезопасная обработка ошибок
|
||||
- Понятные сообщения об ошибках
|
||||
- Возможность pattern matching
|
||||
|
||||
---
|
||||
|
||||
### 6. Группировка полей MessageInfo ✅ ЗАВЕРШЕНО!
|
||||
|
||||
**Статус**: ЗАВЕРШЕНО (2026-01-31)
|
||||
|
||||
**Проблема**: `MessageInfo` имеет слишком много плоских полей (~15+).
|
||||
|
||||
**Решение**: ✅ Реализовано - группировка в логические структуры:
|
||||
```rust
|
||||
pub struct MessageInfo {
|
||||
pub metadata: MessageMetadata,
|
||||
pub content: MessageContent,
|
||||
pub state: MessageState,
|
||||
pub interactions: MessageInteractions,
|
||||
}
|
||||
|
||||
pub struct MessageMetadata {
|
||||
pub id: MessageId,
|
||||
pub chat_id: ChatId,
|
||||
pub sender_id: UserId,
|
||||
pub date: i32,
|
||||
}
|
||||
|
||||
pub struct MessageContent {
|
||||
pub text: String,
|
||||
pub formatted_text: Option<FormattedText>,
|
||||
pub media_type: Option<String>,
|
||||
}
|
||||
|
||||
pub struct MessageState {
|
||||
pub is_outgoing: bool,
|
||||
pub is_edited: bool,
|
||||
pub is_pinned: bool,
|
||||
}
|
||||
|
||||
pub struct MessageInteractions {
|
||||
pub reply_to_message_id: Option<MessageId>,
|
||||
pub forward_info: Option<ForwardInfo>,
|
||||
pub reactions: Vec<ReactionInfo>,
|
||||
pub read_count: i32,
|
||||
}
|
||||
```
|
||||
|
||||
**Что сделано**:
|
||||
- ✅ Созданы 4 структуры: MessageMetadata, MessageContent, MessageState, MessageInteractions
|
||||
- ✅ Обновлена MessageInfo для использования новых структур
|
||||
- ✅ Добавлен конструктор MessageInfo::new()
|
||||
- ✅ Добавлены getter методы (id(), text(), sender_name(), и др.)
|
||||
- ✅ Обновлены 14 файлов (~200+ обращений):
|
||||
- ui/messages.rs: рендеринг (100+ изменений)
|
||||
- app/mod.rs: логика приложения
|
||||
- input/main_input.rs: обработка ввода
|
||||
- tdlib/client.rs: обработка updates
|
||||
- Все тестовые файлы
|
||||
- ✅ Код компилируется успешно
|
||||
|
||||
**Преимущества**:
|
||||
- ✅ Логическая группировка данных
|
||||
- ✅ Проще добавлять новые поля
|
||||
- ✅ Улучшенная читаемость кода
|
||||
- ✅ Меньше параметров в конструкторах (используется new())
|
||||
|
||||
---
|
||||
|
||||
### MessageBuilder pattern ✅ ЗАВЕРШЕНО!
|
||||
|
||||
**Статус**: ЗАВЕРШЕНО (2026-01-31)
|
||||
|
||||
**Проблема**: MessageInfo::new() принимает 14 параметров, что неудобно и подвержено ошибкам.
|
||||
|
||||
**Решение**: ✅ Реализован MessageBuilder с fluent API:
|
||||
```rust
|
||||
let message = MessageBuilder::new(MessageId::new(123))
|
||||
.sender_name("Alice")
|
||||
.text("Hello, world!")
|
||||
.outgoing()
|
||||
.read()
|
||||
.build();
|
||||
```
|
||||
|
||||
**Что сделано**:
|
||||
- ✅ Создана структура MessageBuilder в tdlib/types.rs
|
||||
- ✅ Реализовано 16 методов fluent API:
|
||||
- Базовые: sender_name, text, entities, date, edit_date
|
||||
- Флаги: outgoing, incoming, read, unread, edited
|
||||
- Права: editable, deletable_for_self, deletable_for_all
|
||||
- Дополнительно: reply_to, forward_from, reactions, add_reaction
|
||||
- ✅ Обновлён convert_message() для использования builder
|
||||
- ✅ Добавлены 6 unit тестов
|
||||
- ✅ Код компилируется успешно
|
||||
|
||||
**Преимущества**:
|
||||
- ✅ Более читабельный код
|
||||
- ✅ Самодокументирующийся API
|
||||
- ✅ Гибкость в установке опциональных полей
|
||||
- ✅ Проще поддерживать и расширять
|
||||
|
||||
**🎉 Priority 2 ЗАВЕРШЁН НА 100%! 🎉**
|
||||
|
||||
---
|
||||
|
||||
## Приоритет 3: Архитектурные улучшения
|
||||
|
||||
### 7. Выделить UI компоненты ✅ ЧАСТИЧНО ЗАВЕРШЕНО!
|
||||
|
||||
**Статус**: ЧАСТИЧНО ЗАВЕРШЕНО (4/5 компонентов, 2026-01-31)
|
||||
|
||||
**Проблема**: Код рендеринга дублируется, сложно переиспользовать.
|
||||
|
||||
**Решение**: ✅ Создано `src/ui/components/`:
|
||||
```
|
||||
src/ui/components/
|
||||
├── mod.rs ✅
|
||||
├── modal.rs ✅ (87 строк, полностью реализовано)
|
||||
├── input_field.rs ✅ (54 строк, полностью реализовано)
|
||||
├── message_bubble.rs ⚠️ (27 строк, placeholder, блокируется P3.8 и P3.9)
|
||||
├── chat_list_item.rs ✅ (78 строк, полностью реализовано)
|
||||
└── emoji_picker.rs ✅ (112 строк, полностью реализовано)
|
||||
```
|
||||
|
||||
**Что сделано**:
|
||||
- ✅ Создана структура модулей `src/ui/components/`
|
||||
- ✅ Реализовано 4 из 5 компонентов:
|
||||
- `modal.rs` — базовые модалки с центрированием
|
||||
- `input_field.rs` — текстовое поле с курсором
|
||||
- `chat_list_item.rs` — элемент списка чатов
|
||||
- `emoji_picker.rs` — picker реакций
|
||||
- ⚠️ `message_bubble.rs` — placeholder (требует P3.8 ✅ и P3.9 ✅)
|
||||
- ✅ Все компоненты используются в UI
|
||||
|
||||
**Что осталось**:
|
||||
- ⏳ Реализовать `message_bubble.rs` (теперь разблокировано!)
|
||||
- ⏳ Интегрировать `message_grouping` в `messages.rs`
|
||||
|
||||
**Преимущества**:
|
||||
- ✅ Переиспользуемые компоненты
|
||||
- ✅ Консистентный UI
|
||||
- ✅ Проще тестировать
|
||||
|
||||
---
|
||||
|
||||
### 8. Вынести форматирование в отдельный модуль
|
||||
|
||||
**Проблема**: Markdown форматирование захардкожено в `messages.rs` (~200+ строк).
|
||||
|
||||
**Решение**: Создать `src/formatting.rs`:
|
||||
```rust
|
||||
pub struct FormattedSpan {
|
||||
pub text: String,
|
||||
pub style: Style,
|
||||
}
|
||||
|
||||
pub fn format_text_entities(
|
||||
text: &str,
|
||||
entities: &[TextEntity],
|
||||
) -> Vec<FormattedSpan> {
|
||||
// Вся логика форматирования
|
||||
}
|
||||
```
|
||||
|
||||
**Преимущества**:
|
||||
- Разделение ответственности
|
||||
- Можно тестировать отдельно
|
||||
- Переиспользование в других местах
|
||||
|
||||
---
|
||||
|
||||
### 9. Вынести логику группировки сообщений ✅ ЗАВЕРШЕНО!
|
||||
|
||||
**Статус**: ЗАВЕРШЕНО (2026-01-31)
|
||||
|
||||
**Проблема**: Логика группировки сообщений смешана с рендерингом в `messages.rs`.
|
||||
|
||||
**Решение**: ✅ Создан `src/message_grouping.rs`:
|
||||
```rust
|
||||
pub enum MessageGroup {
|
||||
DateSeparator(i32),
|
||||
SenderHeader { is_outgoing: bool, sender_name: String },
|
||||
Message(MessageInfo),
|
||||
}
|
||||
|
||||
pub fn group_messages(messages: &[MessageInfo]) -> Vec<MessageGroup> {
|
||||
// Логика группировки по дате и отправителю
|
||||
}
|
||||
```
|
||||
|
||||
**Что сделано**:
|
||||
- ✅ Создан модуль `src/message_grouping.rs` (255 строк)
|
||||
- ✅ Реализован enum `MessageGroup` с тремя вариантами
|
||||
- ✅ Реализована функция `group_messages()` для группировки по дате и отправителю
|
||||
- ✅ Добавлена полная документация с примерами
|
||||
- ✅ Написано 5 unit тестов (все проходят)
|
||||
- ✅ Модуль добавлен в `src/lib.rs`
|
||||
- ✅ Код компилируется успешно
|
||||
|
||||
**Преимущества**:
|
||||
- ✅ Чистое разделение логики и представления
|
||||
- ✅ Легче тестировать группировку (покрыто тестами)
|
||||
- ✅ Можно переиспользовать
|
||||
- ✅ Готово для интеграции в `messages.rs`
|
||||
|
||||
---
|
||||
|
||||
### 10. Hotkey mapping в конфиг ✅ ЗАВЕРШЕНО!
|
||||
|
||||
**Статус**: ЗАВЕРШЕНО (2026-01-31)
|
||||
|
||||
**Проблема**: Хоткеи захардкожены в коде, нельзя настроить.
|
||||
|
||||
**Решение**: ✅ Добавлено в `config.toml`:
|
||||
```toml
|
||||
[hotkeys]
|
||||
# Навигация (vim + русские + стрелки)
|
||||
up = ["k", "р", "Up"]
|
||||
down = ["j", "о", "Down"]
|
||||
left = ["h", "р", "Left"]
|
||||
right = ["l", "д", "Right"]
|
||||
|
||||
# Действия (англ + русские)
|
||||
reply = ["r", "к"]
|
||||
forward = ["f", "а"]
|
||||
delete = ["d", "в", "Delete"]
|
||||
copy = ["y", "н"]
|
||||
react = ["e", "у"]
|
||||
profile = ["i", "ш"]
|
||||
```
|
||||
|
||||
**Что сделано**:
|
||||
- ✅ Создана структура `HotkeysConfig` в `src/config.rs`
|
||||
- ✅ Добавлены поля для всех действий (10 hotkeys)
|
||||
- ✅ Реализован метод `matches(key: KeyCode, action: &str) -> bool`
|
||||
- ✅ Поддержка символьных клавиш (англ + русские)
|
||||
- ✅ Поддержка специальных клавиш (Up, Down, Left, Right, Delete, Enter, Esc)
|
||||
- ✅ Добавлены дефолтные значения для всех hotkeys
|
||||
- ✅ Написано 9 unit тестов (all passing ✅)
|
||||
- ✅ Добавлена полная rustdoc документация
|
||||
- ✅ Config::default() включает hotkeys
|
||||
|
||||
**Примеры использования**:
|
||||
```rust
|
||||
let config = Config::default();
|
||||
|
||||
// Проверяем английскую клавишу
|
||||
if config.hotkeys.matches(KeyCode::Char('r'), "reply") {
|
||||
// Начать ответ
|
||||
}
|
||||
|
||||
// Проверяем русскую клавишу
|
||||
if config.hotkeys.matches(KeyCode::Char('к'), "reply") {
|
||||
// Начать ответ (та же логика)
|
||||
}
|
||||
|
||||
// Проверяем стрелку
|
||||
if config.hotkeys.matches(KeyCode::Up, "up") {
|
||||
// Вверх по списку
|
||||
}
|
||||
```
|
||||
|
||||
**Преимущества**:
|
||||
- ✅ Пользовательская настройка хоткеев через config.toml
|
||||
- ✅ Проще добавлять новые действия
|
||||
- ✅ Документация хоткеев в конфиге
|
||||
- ✅ Централизованное управление клавишами
|
||||
- ✅ Поддержка русской раскладки out of the box
|
||||
|
||||
**🎉 Priority 3 ЗАВЕРШЁН НА 100%! 🎉**
|
||||
|
||||
---
|
||||
|
||||
## Приоритет 4: Качество кода
|
||||
|
||||
### 11. Добавить юнит-тесты ✅ ЗАВЕРШЕНО!
|
||||
|
||||
**Статус**: ЗАВЕРШЕНО 100% (+106 строк тестов, 2026-02-01)
|
||||
|
||||
**Что сделано**:
|
||||
- ✅ Добавлены 9 unit тестов в `src/utils.rs` (в секции `#[cfg(test)]`)
|
||||
- ✅ Покрыты все edge cases для форматирования времени
|
||||
- ✅ Тестирование приватных функций через публичный API
|
||||
- ✅ Все 54 unit теста проходят (было 45, +9 новых)
|
||||
|
||||
**Добавленные тесты**:
|
||||
- `format_timestamp_with_tz` - положительный offset (+03:00)
|
||||
- `format_timestamp_with_tz` - отрицательный offset (-05:00)
|
||||
- `format_timestamp_with_tz` - нулевой offset (UTC)
|
||||
- `format_timestamp_with_tz` - переход через полночь
|
||||
- `format_timestamp_with_tz` - невалидный timezone (fallback)
|
||||
- `get_day` - расчет дня из timestamp
|
||||
- `get_day_grouping` - группировка сообщений по дням
|
||||
- `format_datetime` - полная дата и время с MSK
|
||||
- `parse_timezone_offset` - через публичный API (приватная функция)
|
||||
|
||||
**Примеры**:
|
||||
```rust
|
||||
#[test]
|
||||
fn test_format_timestamp_with_tz_positive_offset() {
|
||||
let timestamp = 1640000000; // 2021-12-20 11:33:20 UTC
|
||||
assert_eq!(format_timestamp_with_tz(timestamp, "+03:00"), "14:33");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_day_grouping() {
|
||||
let msg1 = 1640000000; // 2021-12-20 09:33:20
|
||||
let msg2 = 1640040000; // 2021-12-20 20:40:00
|
||||
assert_eq!(get_day(msg1), get_day(msg2)); // Один день
|
||||
}
|
||||
```
|
||||
|
||||
**Запуск**: `cargo test --lib utils::tests`
|
||||
|
||||
---
|
||||
|
||||
### 12. Добавить rustdoc комментарии ✅ ЗАВЕРШЕНО!
|
||||
|
||||
**Статус**: ЗАВЕРШЕНО 100% (+900 строк документации, 2026-02-01)
|
||||
|
||||
**Что сделано**:
|
||||
- ✅ Документированы все TDLib модули (auth, chats, messages, reactions, users)
|
||||
- ✅ Документированы все публичные структуры и методы
|
||||
- ✅ Добавлены примеры использования (34 doctests)
|
||||
- ✅ Документация для Config и утилит (formatting)
|
||||
- ✅ Все doctests работают (30 ignored для async, 4 compiled)
|
||||
|
||||
**Модули с документацией**:
|
||||
- `src/tdlib/auth.rs` - AuthManager, AuthState (6 doctests)
|
||||
- `src/tdlib/chats.rs` - ChatManager (8 doctests)
|
||||
- `src/tdlib/messages.rs` - MessageManager, 14 методов (6 doctests)
|
||||
- `src/tdlib/reactions.rs` - ReactionManager (3 doctests)
|
||||
- `src/tdlib/users.rs` - UserCache, LruCache (2 doctests)
|
||||
- `src/config.rs` - Config, ColorsConfig, GeneralConfig (4 doctests)
|
||||
- `src/formatting.rs` - Форматирование текста (2 doctests)
|
||||
- `src/tdlib/client.rs` - TdClient (1 doctest)
|
||||
- `src/app/mod.rs` - App (1 doctest)
|
||||
- `src/message_grouping.rs` - Группировка (1 doctest)
|
||||
- `src/tdlib/types.rs` - MessageBuilder (1 doctest)
|
||||
|
||||
**Примеры**:
|
||||
```rust
|
||||
/// Менеджер авторизации TDLib.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// let mut auth_manager = AuthManager::new(client_id);
|
||||
/// auth_manager.send_phone_number("+1234567890".to_string()).await?;
|
||||
/// auth_manager.send_code("12345".to_string()).await?;
|
||||
/// ```
|
||||
pub struct AuthManager { ... }
|
||||
```
|
||||
|
||||
**Генерация**: `cargo doc --open`
|
||||
|
||||
---
|
||||
|
||||
### 13. Config валидация ✅ ЗАВЕРШЕНО!
|
||||
|
||||
**Статус**: ЗАВЕРШЕНО 100% (+149 строк тестов, 2026-02-01)
|
||||
|
||||
**Что сделано**:
|
||||
- ✅ Валидация уже была реализована в `config.rs:344-389`
|
||||
- ✅ Вызов валидации в `Config::load():450-456`
|
||||
- ✅ Добавлено 15 comprehensive тестов для полного покрытия
|
||||
- ✅ Все 23 config теста проходят (8 существующих + 15 новых)
|
||||
|
||||
**Добавленные тесты**:
|
||||
- Валидация дефолтного конфига
|
||||
- Timezone: валидный (+03:00, -05:00), невалидный (без знака)
|
||||
- Цвета: все 18 стандартных ratatui цветов
|
||||
- Невалидные цвета (rainbow, purple, pink)
|
||||
- Case-insensitive парсинг (RED, Green, YELLOW)
|
||||
- parse_color() для всех вариантов (standard, light, gray/grey)
|
||||
- Fallback к White для невалидных цветов
|
||||
|
||||
**Реализация**: Уже была добавлена ранее:
|
||||
```rust
|
||||
impl Config {
|
||||
pub fn validate(&self) -> Result<(), TeletuiError> {
|
||||
// Проверка timezone
|
||||
if !self.general.timezone.starts_with('+')
|
||||
&& !self.general.timezone.starts_with('-') {
|
||||
return Err(TeletuiError::InvalidTimezone(
|
||||
format!("Timezone must start with + or -: {}", self.general.timezone)
|
||||
));
|
||||
}
|
||||
|
||||
// Проверка цветов
|
||||
let valid_colors = [
|
||||
"black", "red", "green", "yellow", "blue", "magenta",
|
||||
"cyan", "gray", "white", "darkgray", "lightred",
|
||||
"lightgreen", "lightyellow", "lightblue",
|
||||
"lightmagenta", "lightcyan"
|
||||
];
|
||||
|
||||
for color_name in [
|
||||
&self.colors.incoming_message,
|
||||
&self.colors.outgoing_message,
|
||||
&self.colors.selected_message,
|
||||
&self.colors.reaction_chosen,
|
||||
&self.colors.reaction_other,
|
||||
] {
|
||||
if !valid_colors.contains(&color_name.to_lowercase().as_str()) {
|
||||
return Err(TeletuiError::InvalidColor(
|
||||
format!("Unknown color: {}", color_name)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Вызывать при загрузке:
|
||||
```rust
|
||||
pub fn load() -> Self {
|
||||
let config = // ... загрузка из файла
|
||||
if let Err(e) = config.validate() {
|
||||
eprintln!("Config validation error: {}", e);
|
||||
return Self::default();
|
||||
}
|
||||
config
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 14. Async/await консистентность ✅ ЗАВЕРШЕНО!
|
||||
|
||||
**Статус**: ЗАВЕРШЕНО 100% (проверка кода, 2026-02-01)
|
||||
|
||||
**Проверка показала**: Код уже соответствует требованиям!
|
||||
|
||||
**Что проверено**:
|
||||
- ✅ `std::fs` используется только в `Config::load()` при старте (не в async runtime)
|
||||
- ✅ `std::thread::sleep` - не найдено ни разу
|
||||
- ✅ `tokio::time::sleep` используется в async функциях (messages.rs)
|
||||
- ✅ `tokio::time::timeout` используется (auth.rs, main_input.rs, main.rs)
|
||||
- ✅ Все файловые операции вызываются синхронно при инициализации
|
||||
|
||||
**Детали**:
|
||||
```rust
|
||||
// ✓ ПРАВИЛЬНО: Config::load() при старте, перед async runtime
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), io::Error> {
|
||||
let config = config::Config::load(); // Синхронно, при инициализации
|
||||
// ... async runtime начинается позже
|
||||
}
|
||||
|
||||
// ✓ ПРАВИЛЬНО: tokio::time::sleep в async функциях
|
||||
async fn load_messages() {
|
||||
use tokio::time::{sleep, Duration};
|
||||
sleep(Duration::from_millis(100)).await; // Не блокирует
|
||||
}
|
||||
```
|
||||
|
||||
**Вывод**: Блокирующих вызовов в async контексте нет. Код async-clean.
|
||||
|
||||
---
|
||||
|
||||
## Приоритет 5: Опциональные улучшения
|
||||
|
||||
### 15. Feature flags для зависимостей ✅ ЗАВЕРШЕНО
|
||||
|
||||
**Проблема**: Все зависимости всегда включены.
|
||||
|
||||
**Решение**: В `Cargo.toml`:
|
||||
```toml
|
||||
[features]
|
||||
default = ["clipboard", "url-open"]
|
||||
clipboard = ["dep:arboard"]
|
||||
url-open = ["dep:open"]
|
||||
|
||||
[dependencies]
|
||||
arboard = { version = "3.4", optional = true }
|
||||
open = { version = "5.0", optional = true }
|
||||
```
|
||||
|
||||
**Реализовано**:
|
||||
- ✅ Добавлены feature flags в Cargo.toml
|
||||
- ✅ Зависимости `arboard` и `open` сделаны опциональными
|
||||
- ✅ Условная компиляция в `src/input/main_input.rs`:
|
||||
- `#[cfg(feature = "url-open")]` для `open::that()`
|
||||
- `#[cfg(feature = "clipboard")]` / `#[cfg(not(feature = "clipboard"))]` для `copy_to_clipboard()`
|
||||
- ✅ Условная компиляция в `tests/copy.rs`:
|
||||
- `#[cfg(all(test, feature = "clipboard"))]` для clipboard тестов
|
||||
|
||||
**Преимущества**:
|
||||
- ✅ Уменьшение размера бинарника
|
||||
- ✅ Опциональная функциональность
|
||||
- ✅ Graceful degradation при отключении фич
|
||||
|
||||
---
|
||||
|
||||
### 16. LRU cache обобщение ✅ ЗАВЕРШЕНО
|
||||
|
||||
**Проблема**: Отдельные LRU кеши для `user_names` и `user_statuses`.
|
||||
|
||||
**Решение**: Создать обобщённый `LruCache<K, V>` или использовать готовый крейт `lru = "0.12"`.
|
||||
|
||||
**Реализовано**:
|
||||
- ✅ Обобщённая структура `LruCache<K, V>` в `src/tdlib/users.rs`
|
||||
- ✅ Type parameters:
|
||||
- `K: Eq + Hash + Clone + Copy` — тип ключа
|
||||
- `V: Clone` — тип значения
|
||||
- ✅ Обновлена `UserCache`:
|
||||
- `user_usernames: LruCache<UserId, String>`
|
||||
- `user_names: LruCache<UserId, String>`
|
||||
- `user_statuses: LruCache<UserId, UserOnlineStatus>`
|
||||
- ✅ Все методы обобщены: `get()`, `peek()`, `insert()`, `contains_key()`, `len()`
|
||||
|
||||
**Преимущества**:
|
||||
- ✅ Переиспользуемая реализация для любых типов ключей
|
||||
- ✅ Type-safe кеширование
|
||||
- ✅ Без дополнительных зависимостей
|
||||
|
||||
---
|
||||
|
||||
### 17. Tracing вместо println! ✅ ЗАВЕРШЕНО
|
||||
|
||||
**Проблема**: Используется `eprintln!` для логов.
|
||||
|
||||
**Решение**: Использовать `tracing`:
|
||||
```rust
|
||||
use tracing::{info, warn, error, debug};
|
||||
|
||||
// Вместо
|
||||
eprintln!("Warning: Could not load config: {}", e);
|
||||
|
||||
// Использовать
|
||||
warn!("Could not load config: {}", e);
|
||||
```
|
||||
|
||||
**Реализовано**:
|
||||
- ✅ Добавлены зависимости в `Cargo.toml`:
|
||||
- `tracing = "0.1"`
|
||||
- `tracing-subscriber = { version = "0.3", features = ["env-filter"] }`
|
||||
- ✅ Инициализирован subscriber в `main.rs`:
|
||||
- Уровень логов по умолчанию: `warn`
|
||||
- Настраивается через переменную окружения `RUST_LOG`
|
||||
- ✅ Заменены все `eprintln!` на tracing макросы в `src/config.rs`:
|
||||
- 4× `warn!()` для предупреждений
|
||||
- 1× `error!()` для ошибок валидации
|
||||
- 1× `warn!()` для fallback на дефолтную конфигурацию
|
||||
|
||||
**Преимущества**:
|
||||
- ✅ Структурированное логирование
|
||||
- ✅ Настраиваемые уровни логов (через `RUST_LOG`)
|
||||
- ✅ Лучшая интеграция с async кодом
|
||||
- ✅ Единый подход к логированию во всём проекте
|
||||
|
||||
---
|
||||
|
||||
## Метрики прогресса
|
||||
|
||||
- [x] Priority 1: 3/3 задач ✅ ЗАВЕРШЕНО!
|
||||
- [x] P1.1 — ChatState enum
|
||||
- [x] P1.2 — Разделить TdClient
|
||||
- [x] P1.3 — Константы
|
||||
- [x] Priority 2: 5/5 задач ✅ ЗАВЕРШЕНО! 🎉
|
||||
- [x] P2.5 — Error enum
|
||||
- [x] P2.3 — Config validation
|
||||
- [x] P2.4 — Newtype для ID
|
||||
- [x] P2.6 — MessageInfo реструктуризация
|
||||
- [x] P2.7 — MessageBuilder pattern
|
||||
- [x] Priority 3: 4/4 задач ✅ ЗАВЕРШЕНО! 🎉🎉
|
||||
- [x] P3.7 — UI компоненты (4/5, message_bubble блокируется)
|
||||
- [x] P3.8 — Formatting модуль ✅
|
||||
- [x] P3.9 — Message Grouping ✅
|
||||
- [x] P3.10 — Hotkey Mapping ✅
|
||||
- [x] Priority 4: 4/4 задач ✅ ЗАВЕРШЕНО! 🎉🎉🎉
|
||||
- [x] P4.11 — Unit tests ✅
|
||||
- [x] P4.12 — Rustdoc ✅
|
||||
- [x] P4.13 — Config validation ✅
|
||||
- [x] P4.14 — Async/await consistency ✅
|
||||
- [x] Priority 5: 3/3 задач ✅ ЗАВЕРШЕНО! 🎉🎉🎉
|
||||
- [x] P5.15 — Feature flags ✅
|
||||
- [x] P5.16 — LRU cache обобщение ✅
|
||||
- [x] P5.17 — Tracing ✅
|
||||
|
||||
**Всего**: 20/20 задач (100%) 🎉🎉🎉🎉🎉
|
||||
|
||||
---
|
||||
|
||||
## Предусловие: Тесты
|
||||
|
||||
**ВАЖНО**: Перед началом рефакторинга необходимо написать тесты!
|
||||
|
||||
См. [TESTING_ROADMAP.md](TESTING_ROADMAP.md) для плана покрытия тестами.
|
||||
|
||||
Минимальное покрытие для начала рефакторинга:
|
||||
- ✅ Фаза 0: Инфраструктура (helpers, fake client)
|
||||
- ✅ Snapshot тесты для основных экранов (chat list, messages)
|
||||
- ✅ Integration тесты для критичных flow (send, edit, navigation)
|
||||
|
||||
**Зачем**: Тесты гарантируют, что рефакторинг не сломает функциональность.
|
||||
|
||||
---
|
||||
|
||||
## Порядок выполнения
|
||||
|
||||
Рекомендуется выполнять в следующем порядке:
|
||||
|
||||
1. **P1.3** — Константы (быстро, малый риск)
|
||||
2. **P1.1** — ChatState enum (высокий impact)
|
||||
3. **P2.5** — Error enum (улучшает весь код)
|
||||
4. **P4.11** — Тесты для utils (базовая проверка)
|
||||
5. **P1.2** — Разделить TdClient (большой рефакторинг)
|
||||
6. **P2.4** — Newtype для ID (широкие изменения)
|
||||
7. **P3.7** — UI компоненты (постепенно)
|
||||
8. **P3.8** — Форматирование (изоляция логики)
|
||||
9. **P3.9** — Группировка сообщений (изоляция логики)
|
||||
10. Остальные по необходимости
|
||||
|
||||
---
|
||||
|
||||
## Принципы рефакторинга
|
||||
|
||||
1. **Один PR = одна задача** — не смешивать рефакторинг разных областей
|
||||
2. **Тесты прежде всего** — добавить тесты перед рефакторингом
|
||||
3. **Обратная совместимость** — сохранять работоспособность на каждом шаге
|
||||
4. **Маленькие шаги** — лучше 10 маленьких PR, чем 1 огромный
|
||||
5. **Документация** — обновлять документацию после изменений
|
||||
|
||||
---
|
||||
|
||||
## Примечания
|
||||
|
||||
- Этот документ живой и будет обновляться
|
||||
- Новые пункты добавляются по мере обнаружения
|
||||
- После завершения задачи отмечать в метриках
|
||||
- При появлении блокеров — документировать в соответствующей секции
|
||||
275
ROADMAP.md
275
ROADMAP.md
@@ -1,145 +1,168 @@
|
||||
# Roadmap
|
||||
|
||||
## Фаза 1: Базовая инфраструктура [DONE]
|
||||
## Завершённые фазы
|
||||
|
||||
- [x] Настройка проекта (Cargo.toml)
|
||||
- [x] TUI фреймворк (ratatui + crossterm)
|
||||
- [x] Базовый layout (папки, список чатов, область сообщений)
|
||||
- [x] Vim-style навигация (hjkl, стрелки)
|
||||
- [x] Русская раскладка (ролд)
|
||||
| Фаза | Описание | Ключевые результаты |
|
||||
|------|----------|---------------------|
|
||||
| 1 | Базовая инфраструктура | ratatui + crossterm, vim-навигация, русская раскладка |
|
||||
| 2 | TDLib интеграция | tdlib-rs, авторизация, загрузка чатов и сообщений |
|
||||
| 3 | Улучшение UX | Отправка, поиск, скролл, realtime обновления |
|
||||
| 4 | Папки и фильтрация | Загрузка папок из Telegram, переключение 1-9 |
|
||||
| 5 | Расширенный функционал | Онлайн-статус, галочки прочтения, медиа-заглушки, muted |
|
||||
| 6 | Полировка | 60 FPS, оптимизация памяти, graceful shutdown, динамический инпут |
|
||||
| 7 | Рефакторинг памяти | Единый источник данных, LRU-кэш (500 users), lazy loading |
|
||||
| 8 | Дополнительные фичи | Markdown, edit/delete, reply/forward, блочный курсор |
|
||||
| 9 | Расширенные возможности | Typing, pinned, поиск в чате, черновики, профиль, копирование, реакции, конфиг |
|
||||
| 10 | Desktop уведомления (83%) | notify-rust, muted фильтр, mentions, медиа. TODO: кастомные звуки |
|
||||
| 11 | Inline просмотр фото | Dual renderer (Halfblocks + iTerm2/Sixel), throttling 15 FPS, modal viewer, lazy loading, auto-download |
|
||||
| 12 | Голосовые сообщения | ffplay player, pause/resume with seek, VoiceCache, AudioConfig, progress bar + waveform UI |
|
||||
| 13 | Глубокий рефакторинг | 5 файлов (4582->модули), 5 traits, shared components, docs |
|
||||
|
||||
## Фаза 2: TDLib интеграция [DONE]
|
||||
---
|
||||
|
||||
- [x] Подключение tdlib-rs
|
||||
- [x] Авторизация (телефон + код + 2FA)
|
||||
- [x] Сохранение сессии
|
||||
- [x] Загрузка списка чатов
|
||||
- [x] Загрузка истории сообщений
|
||||
- [x] Отключение логов TDLib
|
||||
## Фаза 11: Inline просмотр фото в чате [DONE]
|
||||
|
||||
## Фаза 3: Улучшение UX [DONE]
|
||||
**UX**: Always-show inline preview (50 chars, Halfblocks) -> `v`/`м` открывает fullscreen modal (iTerm2/Sixel) -> `←`/`→` навигация между фото.
|
||||
|
||||
- [x] Отправка сообщений
|
||||
- [x] Фильтрация чатов (только Main, без архива)
|
||||
- [x] Поиск по чатам (Ctrl+S)
|
||||
- [x] Скролл истории сообщений
|
||||
- [x] Загрузка имён пользователей (вместо User_ID)
|
||||
- [x] Отметка сообщений как прочитанные
|
||||
- [x] Реальное время: новые сообщения
|
||||
### Реализовано:
|
||||
- [x] **Dual renderer архитектура**:
|
||||
- `inline_image_renderer`: Halfblocks (быстро, Unicode блоки) для навигации
|
||||
- `modal_image_renderer`: iTerm2/Sixel (медленно, высокое качество) для просмотра
|
||||
- [x] **Performance optimizations**:
|
||||
- Frame throttling: inline 15 FPS, текст 60 FPS
|
||||
- Lazy loading: только видимые изображения
|
||||
- LRU cache: max 100 протоколов
|
||||
- Skip partial rendering (no flickering)
|
||||
- [x] **UX улучшения**:
|
||||
- Always-show inline preview (фикс. ширина 50 chars)
|
||||
- Fullscreen modal на `v`/`м` с aspect ratio
|
||||
- Loading indicator в модалке
|
||||
- Navigation hotkeys: `←`/`→` между фото, `Esc`/`q` закрыть
|
||||
- [x] **Типы и API**:
|
||||
- `MediaInfo`, `PhotoInfo`, `PhotoDownloadState`, `ImageModalState`
|
||||
- `ImagesConfig` в config.toml
|
||||
- Feature flag `images` для зависимостей
|
||||
- [x] **Media модуль**:
|
||||
- `cache.rs`: ImageCache (LRU)
|
||||
- `image_renderer.rs`: new() + new_fast()
|
||||
- [x] **UI модули**:
|
||||
- `modals/image_viewer.rs`: fullscreen modal
|
||||
- `messages.rs`: throttled second-pass rendering
|
||||
- [x] **Авто-загрузка фото** (bugfix):
|
||||
- Auto-download последних 30 фото при открытии чата (`open_chat_and_load_data`)
|
||||
- Download on demand по `v` (вместо "Фото не загружено")
|
||||
- Retry при ошибке загрузки
|
||||
- Конфиг: `auto_download_images` + `show_images` в `[images]`
|
||||
|
||||
## Фаза 4: Папки и фильтрация [DONE]
|
||||
---
|
||||
|
||||
- [x] Загрузка папок из Telegram
|
||||
- [x] Переключение между папками (1-9)
|
||||
- [x] Фильтрация чатов по папке
|
||||
## Фаза 12: Прослушивание голосовых сообщений [DONE]
|
||||
|
||||
## Фаза 5: Расширенный функционал [DONE]
|
||||
### Этап 1: Инфраструктура аудио [DONE]
|
||||
- [x] Модуль `src/audio/`
|
||||
- `player.rs` — AudioPlayer на ffplay (subprocess)
|
||||
- `cache.rs` — VoiceCache (LRU, configurable size, `~/.cache/tele-tui/voice/`)
|
||||
- [x] AudioPlayer API: play(), play_from(ss), pause() (SIGSTOP), resume(), resume_from(ss), stop()
|
||||
- [x] Race condition fix: `starting` flag + pid ownership guard в потоках
|
||||
- [x] Drop impl для AudioPlayer (убивает ffplay при выходе)
|
||||
|
||||
- [x] Отображение онлайн-статуса (зелёная точка ●)
|
||||
- [x] Статус доставки/прочтения (✓, ✓✓)
|
||||
- [x] Поддержка медиа-заглушек (фото, видео, голосовые, стикеры и др.)
|
||||
- [x] Mentions (@) — индикатор непрочитанных упоминаний
|
||||
- [x] Muted чаты (иконка 🔇)
|
||||
### Этап 2: Интеграция с TDLib [DONE]
|
||||
- [x] Типы: `VoiceInfo`, `VoiceDownloadState`, `PlaybackState`, `PlaybackStatus`
|
||||
- [x] Конвертация `MessageVoiceNote` в `message_conversion.rs`
|
||||
- [x] `download_voice_note()` в TdClientTrait + client_impl + fake
|
||||
- [x] Методы `has_voice()`, `voice_info()`, `voice_info_mut()` на `MessageInfo`
|
||||
|
||||
## Фаза 6: Полировка [DONE]
|
||||
### Этап 3: UI для воспроизведения [DONE]
|
||||
- [x] Progress bar (━●─) с позицией и длительностью
|
||||
- [x] Waveform визуализация (▁▂▃▄▅▆▇█) из base64-encoded TDLib данных
|
||||
- [x] Иконки статуса: ▶ Playing, ⏸ Paused, ⏹ Stopped
|
||||
- [x] Throttled redraw: обновление UI только при смене секунды (не 60 FPS)
|
||||
|
||||
- [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 на несколько строк
|
||||
- Правильное выравнивание для исходящих/входящих
|
||||
### Этап 4: Хоткеи [DONE]
|
||||
- [x] Space — play/pause toggle (запуск + пауза/возобновление с откатом 1s)
|
||||
- [x] ←/→ — seek ±5 сек (через `resume_from()` — перезапуск ffplay с `-ss`)
|
||||
- [x] Seek работает и при воспроизведении, и на паузе (на паузе двигает позицию, при resume стартует с неё)
|
||||
- [x] MoveLeft/MoveRight как alias для SeekBackward/SeekForward (HashMap non-deterministic order fix)
|
||||
- [x] Автоматическая остановка при навигации на другое сообщение
|
||||
- [x] Остановка ffplay при выходе из приложения (Ctrl+C)
|
||||
|
||||
## Фаза 7: Глубокий рефакторинг памяти [DONE]
|
||||
### Этап 5: Конфигурация и кэш [DONE]
|
||||
- [x] `AudioConfig` в config.toml (`cache_size_mb`, `auto_download_voice`)
|
||||
- [x] `DEFAULT_AUDIO_CACHE_SIZE_MB` константа (100 MB)
|
||||
- [x] Ticker для progress bar в event loop (delta-based position tracking)
|
||||
- [x] VoiceCache интеграция: проверка кэша перед загрузкой, кэширование после download
|
||||
|
||||
- [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)
|
||||
### Технические детали
|
||||
- **Аудио:** ffplay (subprocess), resume/seek через перезапуск с `-ss` offset
|
||||
- **Race conditions:** `starting` flag предотвращает false `is_stopped()` при старте ffplay; pid ownership guard в потоках предотвращает затирание pid нового процесса старым
|
||||
- **Keybinding conflict:** Left/Right привязаны к MoveLeft/MoveRight и SeekBackward/SeekForward; HashMap iteration order не гарантирован → оба варианта обрабатываются как seek в режиме выбора сообщения
|
||||
- **Платформы:** macOS, Linux (везде где есть ffmpeg)
|
||||
- **Хоткеи:** Space (play/pause), ←/→ (seek ±5s)
|
||||
|
||||
## Фаза 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 для отмены
|
||||
- Отображение "↪ Переслано от" для пересланных сообщений
|
||||
## Фаза 14: Мультиаккаунт
|
||||
|
||||
## Фаза 9: Расширенные возможности [DONE]
|
||||
**Цель**: поддержка нескольких Telegram-аккаунтов с мгновенным переключением внутри приложения.
|
||||
|
||||
- [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`
|
||||
- Настройки: цветовая схема, часовой пояс, хоткеи
|
||||
- Загрузка конфига при старте
|
||||
### UI: Индикатор в footer + хоткеи
|
||||
|
||||
```
|
||||
┌──────────────┬───────────────────────────┐
|
||||
│ Saved Msgs │ Привет! │
|
||||
│ Иван Петров │ Как дела? │
|
||||
│ Работа чат │ │
|
||||
├──────────────┴───────────────────────────┤
|
||||
│ [NORMAL] Михаил ⟨1/2⟩ Work(3) │ Ctrl+A │
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **Footer**: текущий аккаунт + номер `⟨1/2⟩` + бейджи непрочитанных с других аккаунтов
|
||||
- **Быстрое переключение**: `Ctrl+1`..`Ctrl+9` — мгновенный switch без модалки
|
||||
- **Модалка управления** (`Ctrl+A`): список аккаунтов, добавление/удаление, выбор активного
|
||||
|
||||
### Модалка переключения аккаунтов
|
||||
|
||||
```
|
||||
┌──────────────────────────────────┐
|
||||
│ Аккаунты │
|
||||
│ │
|
||||
│ 1. Михаил (+7 900 ...) ● │ ← активный
|
||||
│ 2. Work (+7 911 ...) (3) │ ← 3 непрочитанных
|
||||
│ 3. + Добавить аккаунт │
|
||||
│ │
|
||||
│ [j/k навигация, Enter выбор] │
|
||||
│ [d — удалить аккаунт] │
|
||||
└──────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Техническая реализация: все клиенты одновременно
|
||||
|
||||
- **Несколько TdClient**: каждый аккаунт — отдельный `TdClient` со своим `database_directory`
|
||||
- Аккаунт 1: `~/.local/share/tele-tui/accounts/1/tdlib_data/`
|
||||
- Аккаунт 2: `~/.local/share/tele-tui/accounts/2/tdlib_data/`
|
||||
- **Все клиенты активны**: polling updates со всех аккаунтов одновременно (уведомления, непрочитанные)
|
||||
- **Мгновенное переключение**: swap активного `App.td_client` — чаты и сообщения уже загружены
|
||||
- **Общий конфиг**: `~/.config/tele-tui/config.toml` (один для всех аккаунтов)
|
||||
- **Профили аккаунтов**: `~/.config/tele-tui/accounts.toml` — список аккаунтов (имя, путь к БД)
|
||||
|
||||
### Этапы
|
||||
|
||||
- [x] **Этап 1: Инфраструктура профилей** (DONE)
|
||||
- Структура `AccountProfile` (name, display_name, db_path)
|
||||
- `accounts.toml` — хранение списка аккаунтов
|
||||
- Миграция `tdlib_data/` → `accounts/default/tdlib_data/` (обратная совместимость)
|
||||
- CLI: `--account <name>` для запуска конкретного аккаунта
|
||||
|
||||
- [x] **Этап 2+3: Account Switcher Modal + Переключение** (DONE)
|
||||
- Подход: single-client reinit (close TDLib → new TdClient → auth)
|
||||
- Модалка `Ctrl+A` — глобальный оверлей с навигацией j/k
|
||||
- Footer индикатор `[account_name]` если не "default"
|
||||
- `AccountSwitcherState` enum (SelectAccount / AddAccount)
|
||||
- `recreate_client()` метод в TdClientTrait (close → new → set_tdlib_parameters)
|
||||
- `add_account()` — создание нового аккаунта из модалки
|
||||
- `pending_account_switch` флаг → обработка в main loop
|
||||
|
||||
- [ ] **Этап 4: Расширенные возможности мультиаккаунта**
|
||||
- Хоткеи `Ctrl+1`..`Ctrl+9` — быстрое переключение
|
||||
- Бейджи непрочитанных с других аккаунтов (требует множественных TdClient)
|
||||
|
||||
64
SECURITY.md
64
SECURITY.md
@@ -1,64 +0,0 @@
|
||||
# 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 недели
|
||||
- **Низкие**: включаются в следующий релиз
|
||||
|
||||
## Спасибо
|
||||
|
||||
Мы ценим ваш вклад в безопасность проекта!
|
||||
@@ -1,571 +0,0 @@
|
||||
# Testing Progress Report
|
||||
|
||||
## Текущий статус: ВСЕ ТЕСТЫ ЗАВЕРШЕНЫ! 🎉🎊🚀
|
||||
|
||||
Все UI snapshot тесты и все integration тесты готовы! Превзошли план!
|
||||
|
||||
Дата: 2026-01-30 (обновлено #6 — ФИНАЛ)
|
||||
|
||||
---
|
||||
|
||||
## ✅ Что сделано
|
||||
|
||||
### Phase 2: Integration Tests (99%) 🔥
|
||||
|
||||
**Всего:** 73 integration теста из 74 запланированных
|
||||
|
||||
#### Phase 2.1: Send Message Flow (100%) ✅
|
||||
**Файл**: `tests/send_message.rs` (6 тестов)
|
||||
|
||||
- ✅ Отправка текстового сообщения
|
||||
- ✅ Отправка нескольких сообщений обновляет список
|
||||
- ✅ Отправка с markdown форматированием
|
||||
- ✅ Отправка в разные чаты
|
||||
- ✅ Получение входящего сообщения
|
||||
- ✅ Отправка с reply
|
||||
|
||||
#### Phase 2.2: Edit Message Flow (100%) ✅
|
||||
**Файл**: `tests/edit_message.rs` (6 тестов)
|
||||
|
||||
- ✅ Редактирование текста сообщения
|
||||
- ✅ Установка edit_date после редактирования
|
||||
- ✅ Проверка can_be_edited перед редактированием
|
||||
- ✅ Редактирование только своих сообщений
|
||||
- ✅ Множественные редактирования
|
||||
- ✅ Редактирование с форматированием
|
||||
|
||||
#### Phase 2.3: Delete Message Flow (100%) ✅
|
||||
**Файл**: `tests/delete_message.rs` (6 тестов)
|
||||
|
||||
- ✅ Удаление сообщения из списка
|
||||
- ✅ Множественные удаления
|
||||
- ✅ Проверка can_be_deleted
|
||||
- ✅ Удаление только своих сообщений
|
||||
- ✅ Удаление из разных чатов
|
||||
- ✅ Delete with revoke
|
||||
|
||||
#### Phase 2.4: Reply & Forward Flow (100%) ✅
|
||||
**Файл**: `tests/reply_forward.rs` (8 тестов)
|
||||
|
||||
- ✅ Reply на сообщение с превью
|
||||
- ✅ Reply сохраняет связь с оригиналом
|
||||
- ✅ Forward сообщения
|
||||
- ✅ Forward с sender_name
|
||||
- ✅ Forward в разные чаты
|
||||
- ✅ Reply + Forward комбо
|
||||
- ✅ Reply на forwarded сообщение
|
||||
- ✅ Forward reply сообщения
|
||||
|
||||
#### Phase 2.5: Reactions Flow (100%) ✅
|
||||
**Файл**: `tests/reactions.rs` (10 тестов)
|
||||
|
||||
- ✅ Добавление реакции на сообщение
|
||||
- ✅ Удаление реакции (toggle)
|
||||
- ✅ Множественные реакции на одно сообщение
|
||||
- ✅ Реакции от разных пользователей
|
||||
- ✅ Подсчёт реакций
|
||||
- ✅ Chosen реакция (своя)
|
||||
- ✅ Реакции обновляются в реальном времени
|
||||
- ✅ Получение доступных реакций чата
|
||||
- ✅ Реакции на forwarded сообщения
|
||||
- ✅ Очистка всех реакций
|
||||
|
||||
#### Phase 2.6: Search Flow (100%) ✅
|
||||
**Файл**: `tests/search.rs` (8 тестов)
|
||||
|
||||
- ✅ Поиск по названию чата
|
||||
- ✅ Поиск по @username
|
||||
- ✅ Поиск по сообщениям в чате
|
||||
- ✅ Навигация по результатам поиска
|
||||
- ✅ Case-insensitive поиск
|
||||
- ✅ Поиск с пробелами
|
||||
- ✅ Поиск возвращает пустой список если нет совпадений
|
||||
- ✅ Очистка поиска
|
||||
|
||||
#### Phase 2.7: Drafts Flow (100%) ✅
|
||||
**Файл**: `tests/drafts.rs` (7 тестов)
|
||||
|
||||
- ✅ Сохранение черновика при переключении чатов
|
||||
- ✅ Восстановление черновика при возврате
|
||||
- ✅ Удаление черновика после отправки
|
||||
- ✅ Черновики для разных чатов независимы
|
||||
- ✅ Индикатор черновика в списке чатов
|
||||
- ✅ Пустой черновик не сохраняется
|
||||
- ✅ Черновик сохраняется при закрытии чата
|
||||
|
||||
#### Phase 2.8: Navigation Flow (100%) ✅
|
||||
**Файл**: `tests/navigation.rs` (7 тестов)
|
||||
|
||||
- ✅ Навигация по списку чатов (↑/↓)
|
||||
- ✅ Открытие чата (Enter)
|
||||
- ✅ Закрытие чата (Esc)
|
||||
- ✅ Скролл сообщений (↑/↓)
|
||||
- ✅ Переключение между папками (1-9)
|
||||
- ✅ Навигация с wrap (переход с конца на начало)
|
||||
- ✅ Навигация в пустом списке
|
||||
|
||||
#### Phase 2.9: Profile Flow (100%) ✅
|
||||
**Файл**: `tests/profile.rs` (6 тестов)
|
||||
|
||||
- ✅ Открытие профиля личного чата
|
||||
- ✅ Профиль показывает имя и username
|
||||
- ✅ Профиль показывает телефон
|
||||
- ✅ Открытие профиля группы
|
||||
- ✅ Профиль группы показывает участников
|
||||
- ✅ Закрытие профиля (Esc)
|
||||
|
||||
#### Phase 2.10: Network & Typing Flow (100%) ✅
|
||||
**Файл**: `tests/network_typing.rs` (9 тестов)
|
||||
|
||||
- ✅ Typing indicator при наборе текста
|
||||
- ✅ Отправка typing action
|
||||
- ✅ Получение typing статуса
|
||||
- ✅ Typing timeout
|
||||
- ✅ Network state: WaitingForNetwork
|
||||
- ✅ Network state: ConnectingToProxy
|
||||
- ✅ Network state: Connecting
|
||||
- ✅ Network state: Updating
|
||||
- ✅ Network state: Ready
|
||||
|
||||
#### Phase 2.11: Copy Flow (100%) ✅
|
||||
**Файл**: `tests/copy.rs` (9 тестов)
|
||||
|
||||
- ✅ Форматирование простого сообщения
|
||||
- ✅ Форматирование с forward контекстом
|
||||
- ✅ Форматирование с reply контекстом
|
||||
- ✅ Форматирование с forward + reply одновременно
|
||||
- ✅ Форматирование длинного сообщения
|
||||
- ✅ Форматирование с markdown entities
|
||||
- ✅ Clipboard initialization (игнорируется в CI)
|
||||
- ✅ Копирование в реальный clipboard (ручное тестирование)
|
||||
- ✅ Кроссплатформенность clipboard
|
||||
|
||||
#### Phase 2.12: Config Flow (100%) ✅
|
||||
**Файл**: `tests/config.rs` (11 тестов)
|
||||
|
||||
- ✅ Дефолтные значения конфигурации
|
||||
- ✅ Кастомные значения конфигурации
|
||||
- ✅ Парсинг валидных цветов (red, green, blue, etc.)
|
||||
- ✅ Парсинг light цветов (lightred, lightgreen, etc.)
|
||||
- ✅ Парсинг невалидного цвета с fallback на White
|
||||
- ✅ Case-insensitive парсинг цветов
|
||||
- ✅ TOML сериализация и десериализация
|
||||
- ✅ Частичный TOML использует дефолты
|
||||
- ✅ Различные форматы timezone (+03:00, -05:00, +00:00)
|
||||
- ✅ Загрузка credentials из переменных окружения
|
||||
- ✅ Проверка формата ошибки когда credentials не найдены
|
||||
|
||||
---
|
||||
|
||||
### Фаза 1: UI Snapshot Tests (100%) ✅
|
||||
|
||||
**Всего:** 55 snapshot тестов
|
||||
|
||||
#### Фаза 1.1: Chat List (100%) ✅
|
||||
**Файл**: `tests/chat_list.rs` (9 тестов)
|
||||
|
||||
#### Фаза 1.2: Messages (100%) ✅
|
||||
**Файл**: `tests/messages.rs` (18 тестов)
|
||||
|
||||
#### Фаза 1.3: Modals (100%) ✅
|
||||
**Файл**: `tests/modals.rs` (8 тестов)
|
||||
|
||||
#### Фаза 1.4: Input Field (100%) ✅
|
||||
**Файл**: `tests/input_field.rs` (7 тестов)
|
||||
|
||||
#### Snapshot тесты для поля ввода:
|
||||
- ✅ `snapshot_empty_input` — пустое поле ввода с плейсхолдером
|
||||
- ✅ `snapshot_input_with_text` — поле с текстом и курсором █
|
||||
- ✅ `snapshot_input_long_text_2_lines` — длинный текст на 2 строки
|
||||
- ✅ `snapshot_input_long_text_max_lines` — очень длинный текст (максимум 10 строк)
|
||||
- ✅ `snapshot_input_editing_mode` — режим редактирования с превью оригинального сообщения
|
||||
- ✅ `snapshot_input_reply_mode` — режим ответа с превью сообщения
|
||||
- ✅ `snapshot_input_search_mode` — поле поиска с query
|
||||
|
||||
#### Результаты:
|
||||
- **7 новых snapshot тестов** — все проходят ✅
|
||||
- **7 snapshots приняты** через `cargo insta accept`
|
||||
- **Все тесты проходят**: 90 тестов (21 chat_list + 19 input_field + 30 messages + 20 modals)
|
||||
|
||||
---
|
||||
|
||||
### Фаза 1.6: Screens Snapshot Tests (100%) ✅
|
||||
|
||||
**Файл**: `tests/screens.rs` (7 тестов)
|
||||
|
||||
#### Snapshot тесты для полных экранов:
|
||||
- ✅ `snapshot_loading_screen_default` — экран загрузки (дефолтный)
|
||||
- ✅ `snapshot_loading_screen_with_status` — экран загрузки со статусом
|
||||
- ✅ `snapshot_auth_screen_phone` — экран авторизации (ввод телефона)
|
||||
- ✅ `snapshot_auth_screen_code` — экран авторизации (ввод кода)
|
||||
- ✅ `snapshot_auth_screen_password` — экран авторизации (ввод пароля 2FA)
|
||||
- ✅ `snapshot_main_screen_empty` — главный экран (пустой список чатов)
|
||||
- ✅ `snapshot_main_screen_terminal_too_small` — предупреждение о маленьком терминале
|
||||
|
||||
#### Обновления TestAppBuilder:
|
||||
- ✅ Добавлен метод `status_message(message)` — установить статус для loading screen
|
||||
- ✅ Добавлен метод `auth_state(state)` — установить состояние авторизации
|
||||
- ✅ Добавлен метод `phone_input(phone)` — установить phone input
|
||||
- ✅ Добавлен метод `code_input(code)` — установить code input
|
||||
- ✅ Добавлен метод `password_input(password)` — установить password input
|
||||
- ✅ Добавлены поля: `status_message`, `auth_state`, `phone_input`, `code_input`, `password_input`
|
||||
- ✅ Обновлен `build()` — применяет auth состояние и inputs
|
||||
|
||||
#### Результаты:
|
||||
- **7 новых snapshot тестов** — все проходят ✅
|
||||
- **7 snapshots приняты** через `cargo insta accept`
|
||||
- **Все тесты проходят**: 127 тестов (21 chat_list + 19 input_field + 30 messages + 20 modals + 18 footer + 19 screens)
|
||||
|
||||
---
|
||||
|
||||
### Фаза 1.5: Footer Snapshot Tests (100%) ✅
|
||||
|
||||
**Файл**: `tests/footer.rs` (6 тестов)
|
||||
|
||||
#### Snapshot тесты для нижней панели:
|
||||
- ✅ `snapshot_footer_chat_list` — footer в списке чатов
|
||||
- ✅ `snapshot_footer_open_chat` — footer в открытом чате
|
||||
- ✅ `snapshot_footer_network_waiting` — footer с "⚠ Нет сети"
|
||||
- ✅ `snapshot_footer_network_connecting_proxy` — footer с "⏳ Прокси..."
|
||||
- ✅ `snapshot_footer_network_connecting` — footer с "⏳ Подключение..."
|
||||
- ✅ `snapshot_footer_search_mode` — footer в режиме поиска
|
||||
|
||||
#### Изменения:
|
||||
- ✅ Сделан `footer` модуль публичным в `src/ui/mod.rs`
|
||||
|
||||
#### Результаты:
|
||||
- **6 новых snapshot тестов** — все проходят ✅
|
||||
- **6 snapshots приняты** через `cargo insta accept`
|
||||
- **Все тесты проходят**: 96 тестов (21 chat_list + 19 input_field + 30 messages + 20 modals + 18 footer)
|
||||
|
||||
---
|
||||
|
||||
### Фаза 1.4: Input Field Snapshot Tests (100%) ✅
|
||||
|
||||
**Файл**: `tests/modals.rs` (8 тестов)
|
||||
|
||||
#### Snapshot тесты для модальных окон:
|
||||
- ✅ `snapshot_delete_confirmation_modal` — модалка подтверждения удаления
|
||||
- ✅ `snapshot_emoji_picker_default` — emoji picker с дефолтным выбором
|
||||
- ✅ `snapshot_emoji_picker_with_selection` — emoji picker с выбранной реакцией (курсор)
|
||||
- ✅ `snapshot_profile_personal_chat` — профиль личного чата
|
||||
- ✅ `snapshot_profile_group_chat` — профиль группы (с участниками)
|
||||
- ✅ `snapshot_pinned_message` — закреплённое сообщение вверху чата
|
||||
- ✅ `snapshot_search_in_chat` — поиск в чате с результатами
|
||||
- ✅ `snapshot_forward_mode` — режим пересылки (выбор чата)
|
||||
|
||||
#### Обновления TestAppBuilder:
|
||||
- ✅ Добавлен метод `with_chats(chats)` — добавить несколько чатов сразу
|
||||
- ✅ Добавлен метод `message_search(query)` — режим поиска по сообщениям
|
||||
- ✅ Добавлен метод `forward_mode(message_id)` — режим пересылки
|
||||
- ✅ Добавлены поля: `message_search_mode`, `message_search_query`, `forwarding_message_id`, `is_selecting_forward_chat`
|
||||
|
||||
#### Исправления:
|
||||
- ✅ Переименованы тесты с динамическими датами (today/yesterday) на фиксированный old_date
|
||||
- ✅ Удалены нестабильные snapshots зависящие от текущей даты
|
||||
- ✅ Все модальные режимы теперь тестируются через snapshots
|
||||
|
||||
#### Результаты:
|
||||
- **8 новых snapshot тестов** — все проходят ✅
|
||||
- **8 snapshots приняты** через `cargo insta accept`
|
||||
- **Все тесты проходят**: 71 тест (21 chat_list + 30 messages + 20 modals)
|
||||
|
||||
---
|
||||
|
||||
### Фаза 1.2: Messages Snapshot Tests (95%) ✅
|
||||
|
||||
**Файл**: `tests/messages.rs` (19 тестов)
|
||||
|
||||
#### Snapshot тесты для области сообщений:
|
||||
- ✅ `snapshot_empty_chat` — пустой чат без сообщений
|
||||
- ✅ `snapshot_single_incoming_message` — одно входящее сообщение
|
||||
- ✅ `snapshot_single_outgoing_message` — одно исходящее сообщение
|
||||
- ✅ `snapshot_date_separator_today` — разделитель "Сегодня"
|
||||
- ✅ `snapshot_date_separator_yesterday` — разделитель "Вчера"
|
||||
- ✅ `snapshot_sender_grouping` — группировка по отправителю (Alice → Alice → Bob)
|
||||
- ✅ `snapshot_outgoing_sent` — исходящее с ✓ (отправлено)
|
||||
- ✅ `snapshot_outgoing_read` — исходящее с ✓✓ (прочитано)
|
||||
- ✅ `snapshot_edited_message` — сообщение с индикатором ✎
|
||||
- ✅ `snapshot_long_message_wrap` — длинное сообщение с переносом
|
||||
- ✅ `snapshot_markdown_bold_italic_code` — **bold** *italic* `code`
|
||||
- ✅ `snapshot_markdown_link_mention` — [links](url) и @mentions
|
||||
- ✅ `snapshot_markdown_spoiler` — ||спойлер||
|
||||
- ✅ `snapshot_media_placeholder` — [Фото], [Видео] и т.д.
|
||||
- ✅ `snapshot_reply_message` — reply с превью оригинала
|
||||
- ✅ `snapshot_forwarded_message` — ↪ Переслано от Alice
|
||||
- ✅ `snapshot_single_reaction` — сообщение с одной реакцией [👍]
|
||||
- ✅ `snapshot_multiple_reactions` — [👍] 5 👎 3
|
||||
- ✅ `snapshot_selected_message` — выбранное сообщение (подсветка)
|
||||
|
||||
#### Обновления TestAppBuilder:
|
||||
- ✅ Добавлен метод `with_message(chat_id, message)` — добавить одно сообщение
|
||||
- ✅ Добавлен метод `with_messages(chat_id, messages)` — добавить несколько сообщений
|
||||
- ✅ Добавлен метод `selecting_message(index)` — установить выбранное сообщение
|
||||
- ✅ Обновлен `build()` — применяет сообщения к `app.td_client.current_chat_messages`
|
||||
|
||||
#### Результаты:
|
||||
- **19 новых snapshot тестов** — все проходят ✅
|
||||
- **19 snapshots приняты** через `cargo insta accept`
|
||||
- **Все тесты проходят**: 52 теста (21 chat_list + 31 messages)
|
||||
|
||||
---
|
||||
|
||||
### Фаза 0: Инфраструктура (100%)
|
||||
|
||||
#### 1. Зависимости
|
||||
- ✅ Добавлено `insta = "1.34"` для snapshot тестов
|
||||
- ✅ Добавлено `tokio-test = "0.4"` для async тестов
|
||||
- ✅ Настроен `.gitignore` для `.snap.new` файлов
|
||||
|
||||
#### 2. Test Helpers (5 модулей)
|
||||
|
||||
**`tests/helpers/mod.rs`**
|
||||
- Экспортирует все вспомогательные модули
|
||||
- Удобный доступ к TestAppBuilder, FakeTdClient и утилитам
|
||||
|
||||
**`tests/helpers/test_data.rs`**
|
||||
- ✅ `TestChatBuilder` — fluent API для создания тестовых чатов
|
||||
- ✅ `TestMessageBuilder` — fluent API для создания тестовых сообщений
|
||||
- ✅ Хелперы: `create_test_chat()`, `create_test_message()`, `create_test_user()`
|
||||
- ✅ Поддержка всех полей: unread, pinned, muted, mentions, reactions, reply, forward
|
||||
|
||||
**`tests/helpers/fake_tdclient.rs`**
|
||||
- ✅ `FakeTdClient` — in-memory мок для интеграционных тестов
|
||||
- ✅ Методы: `send_message()`, `edit_message()`, `delete_message()`, `add_reaction()`
|
||||
- ✅ Tracking отправленных/отредактированных/удалённых сообщений
|
||||
- ✅ Fluent API для построения клиента с данными
|
||||
- ✅ Встроенные юнит-тесты для проверки мока
|
||||
|
||||
**`tests/helpers/snapshot_utils.rs`**
|
||||
- ✅ `buffer_to_string()` — конвертация ratatui Buffer в строку для snapshots
|
||||
- ✅ `render_to_buffer()` — рендеринг UI в виртуальный терминал
|
||||
- ✅ `assert_ui_snapshot!` макрос для упрощения snapshot тестов
|
||||
- ✅ Удаление trailing spaces для чистых snapshots
|
||||
- ✅ Встроенные тесты
|
||||
|
||||
**`tests/helpers/app_builder.rs`**
|
||||
- ✅ `TestAppBuilder` — fluent API для создания тестового App
|
||||
- ✅ Методы: `with_chat()`, `selected_chat()`, `message_input()`, `searching()`, etc.
|
||||
- ✅ Поддержка всех режимов: edit, reply, search, reaction_picker, profile
|
||||
- ✅ Встроенные тесты для билдера
|
||||
|
||||
#### 3. Первые UI тесты
|
||||
|
||||
**`tests/ui/chat_list_test.rs`** (9 тестов)
|
||||
- ✅ snapshot_empty_chat_list
|
||||
- ✅ snapshot_chat_list_with_three_chats
|
||||
- ✅ snapshot_chat_with_unread_count
|
||||
- ✅ snapshot_chat_with_pinned
|
||||
- ✅ snapshot_chat_with_muted
|
||||
- ✅ snapshot_chat_with_mentions
|
||||
- ✅ snapshot_selected_chat
|
||||
- ✅ snapshot_chat_long_title
|
||||
- ✅ snapshot_chat_search_mode
|
||||
|
||||
---
|
||||
|
||||
## 📊 Метрики
|
||||
|
||||
**Создано файлов**: 18
|
||||
- 5 helpers
|
||||
- 6 snapshot test files (chat_list, messages, modals, input_field, footer, screens)
|
||||
- 10 integration test files (send_message, edit_message, delete_message, reply_forward, reactions, search, drafts, navigation, profile, network_typing)
|
||||
- 1 mod.rs
|
||||
|
||||
**Строк кода**: ~6500+
|
||||
- Helpers: ~1000 строк
|
||||
- Snapshot тесты: ~1200 строк
|
||||
- Integration тесты: ~4300 строк
|
||||
|
||||
**Тестов написано**:
|
||||
- Snapshot тесты: 55
|
||||
- Integration тесты: 73
|
||||
- Helper тесты: ~12
|
||||
- **Всего: 140+ тестов**
|
||||
|
||||
**Покрытие**:
|
||||
- Фаза 0: Инфраструктура ✅ (100%)
|
||||
- Фаза 1: UI Snapshot Tests ✅ (100%)
|
||||
- 1.1 Chat List: 9/9 ✅
|
||||
- 1.2 Messages: 18/18 ✅
|
||||
- 1.3 Modals: 8/8 ✅
|
||||
- 1.4 Input Field: 7/7 ✅
|
||||
- 1.5 Footer: 6/6 ✅
|
||||
- 1.6 Screens: 7/7 ✅
|
||||
- Фаза 2: Integration Tests ✅ (100%!)
|
||||
- 2.1 Send Message: 6/6 ✅
|
||||
- 2.2 Edit Message: 6/6 ✅
|
||||
- 2.3 Delete Message: 6/6 ✅
|
||||
- 2.4 Reply & Forward: 8/8 ✅
|
||||
- 2.5 Reactions: 10/10 ✅
|
||||
- 2.6 Search: 8/8 ✅
|
||||
- 2.7 Drafts: 7/7 ✅
|
||||
- 2.8 Navigation: 7/7 ✅
|
||||
- 2.9 Profile: 6/6 ✅
|
||||
- 2.10 Network & Typing: 9/9 ✅
|
||||
- 2.11 Copy: 9/9 ✅ (вместо 3!)
|
||||
- 2.12 Config: 11/11 ✅ (вместо 8!)
|
||||
- **Общий прогресс: 148/151 (98%) — ПРЕВЗОШЛИ ПЛАН!** 🎉
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Структура
|
||||
|
||||
```
|
||||
tests/
|
||||
├── helpers/
|
||||
│ ├── mod.rs ✅ Создан
|
||||
│ ├── app_builder.rs ✅ Создан + 5 тестов
|
||||
│ ├── fake_tdclient.rs ✅ Создан + 4 теста
|
||||
│ ├── snapshot_utils.rs ✅ Создан + 2 теста
|
||||
│ └── test_data.rs ✅ Создан
|
||||
└── ui/
|
||||
├── mod.rs ✅ Создан
|
||||
└── chat_list_test.rs ✅ Создан (9 snapshot тестов)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Примеры использования
|
||||
|
||||
### Создание тестового чата
|
||||
```rust
|
||||
let chat = TestChatBuilder::new("Mom", 123)
|
||||
.unread_count(5)
|
||||
.pinned()
|
||||
.muted()
|
||||
.draft("Hello...")
|
||||
.build();
|
||||
```
|
||||
|
||||
### Создание тестового App
|
||||
```rust
|
||||
let app = TestAppBuilder::new()
|
||||
.with_chat(chat)
|
||||
.selected_chat(123)
|
||||
.message_input("Hello!")
|
||||
.build();
|
||||
```
|
||||
|
||||
### Snapshot тест
|
||||
```rust
|
||||
#[test]
|
||||
fn snapshot_my_ui() {
|
||||
let app = TestAppBuilder::new()
|
||||
.with_chat(create_test_chat("Mom", 123))
|
||||
.build();
|
||||
|
||||
let buffer = render_to_buffer(80, 24, |f| {
|
||||
render_chat_list(f, f.size(), &app);
|
||||
});
|
||||
|
||||
assert_snapshot!("my_ui", buffer_to_string(&buffer));
|
||||
}
|
||||
```
|
||||
|
||||
### Мок клиент для интеграционных тестов
|
||||
```rust
|
||||
let mut client = FakeTdClient::new()
|
||||
.with_chat(create_test_chat("Mom", 123));
|
||||
|
||||
let msg_id = client.send_message(123, "Hello".to_string(), None);
|
||||
assert_eq!(client.sent_messages().len(), 1);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎉 ВСЕ ОСНОВНЫЕ ТЕСТЫ ЗАВЕРШЕНЫ!
|
||||
|
||||
### Прогресс: 98% (148/151 тестов) — ПРЕВЗОШЛИ ПЛАН! 🚀
|
||||
|
||||
**Все основные тесты готовы:**
|
||||
- ✅ Phase 0: Инфраструктура (100%)
|
||||
- ✅ Phase 1: UI Snapshot Tests (100%) — 55 тестов
|
||||
- ✅ Phase 2: Integration Tests (100%!) — 93 теста
|
||||
|
||||
**Превзошли план на 9 тестов!**
|
||||
- Copy Flow: 9 тестов (вместо 3)
|
||||
- Config Flow: 11 тестов (вместо 8)
|
||||
|
||||
### Опциональные тесты (можно сделать позже)
|
||||
|
||||
#### Фаза 3: E2E Smoke Tests (4 теста)
|
||||
**Файл**: `tests/e2e/smoke_test.rs`
|
||||
|
||||
- [ ] Приложение запускается без краша
|
||||
- [ ] Приложение рендерит loading screen
|
||||
- [ ] Приложение корректно завершается по Ctrl+C
|
||||
- [ ] Минимальный размер терминала не крашит приложение
|
||||
|
||||
**Примечание**: E2E тесты требуют реального TDLib или сложного мока, поэтому опциональны.
|
||||
|
||||
#### Фаза 4: Дополнительные тесты (8 тестов)
|
||||
|
||||
**4.1 Utils Tests** (5 тестов)
|
||||
- [ ] `format_timestamp_with_tz` с разными timezone
|
||||
- [ ] `parse_timezone_offset` валидные значения
|
||||
- [ ] `parse_timezone_offset` инвалидные значения (fallback)
|
||||
- [ ] `format_date` для сегодня, вчера, старых дат
|
||||
- [ ] `format_was_online` для разных временных промежутков
|
||||
|
||||
**4.2 Performance Benchmarks** (3 теста)
|
||||
- [ ] Benchmark рендеринга 100 сообщений
|
||||
- [ ] Benchmark рендеринга списка 50 чатов
|
||||
- [ ] Benchmark форматирования markdown текста
|
||||
|
||||
### Итого
|
||||
|
||||
**Завершено**: 148 тестов (98%)
|
||||
**Опционально**: 12 тестов (2%)
|
||||
**Всего**: 160 тестов потенциально
|
||||
|
||||
---
|
||||
|
||||
## 💡 Технические заметки
|
||||
|
||||
### Текущие ограничения
|
||||
1. **TestAppBuilder создаёт реальный TdClient** — подходит только для UI/snapshot тестов
|
||||
2. **Для интеграционных тестов** понадобится рефакторинг: либо trait для TdClient, либо dependency injection
|
||||
|
||||
### Решения
|
||||
- Snapshot тесты используют TestAppBuilder (UI рендеринг без вызова TdClient методов)
|
||||
- Интеграционные тесты будут использовать FakeTdClient напрямую
|
||||
- Возможно потребуется создать `IntegrationTestSession` для комплексных сценариев
|
||||
|
||||
---
|
||||
|
||||
## ✨ Качество кода
|
||||
|
||||
**Все helpers покрыты тестами**:
|
||||
- `app_builder.rs`: 5 тестов
|
||||
- `fake_tdclient.rs`: 4 теста
|
||||
- `snapshot_utils.rs`: 2 теста
|
||||
|
||||
**Документация**:
|
||||
- Все публичные функции имеют doc-комментарии
|
||||
- Примеры использования в комментариях
|
||||
- README-секция в TESTING_ROADMAP.md
|
||||
|
||||
---
|
||||
|
||||
## 🎓 Что изучили
|
||||
|
||||
1. **Snapshot testing** с insta — мощный инструмент для TUI
|
||||
2. **ratatui::backend::TestBackend** — виртуальный терминал для тестов
|
||||
3. **Fluent builder pattern** — удобно для построения тестовых данных
|
||||
4. **Test helpers organization** — разделение на модули для переиспользования
|
||||
|
||||
---
|
||||
|
||||
## 📝 Обновлённые файлы
|
||||
|
||||
- `Cargo.toml` — добавлены dev-dependencies
|
||||
- `.gitignore` — добавлены правила для snapshots
|
||||
- `TESTING_ROADMAP.md` — обновлён прогресс
|
||||
- `README.md` — добавлена ссылка на TESTING_ROADMAP
|
||||
- `REFACTORING_ROADMAP.md` — добавлено предусловие о тестах
|
||||
|
||||
---
|
||||
|
||||
**Статус**: Готов к продолжению! 🚀
|
||||
**Следующий шаг**: Запустить тесты и убедиться что всё компилируется, затем продолжить с Фазы 1.2
|
||||
@@ -1,620 +0,0 @@
|
||||
# Testing Roadmap
|
||||
|
||||
План покрытия tele-tui тестами с фокусом на интеграционные и e2e тесты.
|
||||
|
||||
## Стратегия тестирования
|
||||
|
||||
### Подход: Комбо (Snapshot + Integration + E2E)
|
||||
|
||||
1. **Snapshot Testing (70%)** — проверка UI рендеринга через insta
|
||||
2. **Integration Testing (25%)** — проверка логики и flow через FakeTdClient
|
||||
3. **E2E Smoke Testing (5%)** — базовая проверка что приложение запускается
|
||||
|
||||
### Почему не юнит-тесты?
|
||||
|
||||
- TUI сложно тестировать через юниты (моки, хрупкость)
|
||||
- Интеграционные тесты дают больше уверенности
|
||||
- Snapshots ловят UI регрессии лучше, чем assert координат
|
||||
|
||||
---
|
||||
|
||||
## Фаза 0: Инфраструктура
|
||||
|
||||
### Зависимости
|
||||
|
||||
- [x] Добавить `insta = "1.34"` в dev-dependencies
|
||||
- [x] Добавить `tokio-test = "0.4"` в dev-dependencies
|
||||
- [x] Настроить `.gitignore` для snapshots (добавить `tests/snapshots/*.new`)
|
||||
|
||||
### Helpers и Test Utilities
|
||||
|
||||
- [x] Создать `tests/helpers/mod.rs`
|
||||
- [x] Создать `tests/helpers/app_builder.rs` — builder для тестового App
|
||||
- [x] Создать `tests/helpers/fake_tdclient.rs` — mock TDLib клиент
|
||||
- [x] Создать `tests/helpers/snapshot_utils.rs` — утилиты для snapshot тестов
|
||||
- [x] Создать `tests/helpers/test_data.rs` — фикстуры данных (чаты, сообщения)
|
||||
|
||||
```rust
|
||||
// tests/helpers/mod.rs
|
||||
pub mod app_builder;
|
||||
pub mod fake_tdclient;
|
||||
pub mod snapshot_utils;
|
||||
pub mod test_data;
|
||||
|
||||
pub use app_builder::TestAppBuilder;
|
||||
pub use fake_tdclient::FakeTdClient;
|
||||
pub use snapshot_utils::{render_to_string, assert_ui_snapshot};
|
||||
pub use test_data::{create_test_chat, create_test_message};
|
||||
```
|
||||
|
||||
**Файлы для создания**:
|
||||
```
|
||||
tests/
|
||||
├── helpers/
|
||||
│ ├── mod.rs
|
||||
│ ├── app_builder.rs
|
||||
│ ├── fake_tdclient.rs
|
||||
│ ├── snapshot_utils.rs
|
||||
│ └── test_data.rs
|
||||
└── snapshots/ # Создаётся insta автоматически
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Фаза 1: Snapshot Tests для UI (Приоритет: ВЫСОКИЙ)
|
||||
|
||||
### 1.1 Chat List — Список чатов
|
||||
|
||||
**Файл**: `tests/ui/chat_list_test.rs`
|
||||
|
||||
- [x] Пустой список чатов
|
||||
- [x] Список с 3 чатами (без индикаторов)
|
||||
- [x] Чат с непрочитанными сообщениями `(5)`
|
||||
- [x] Чат с иконкой закреплённого 📌
|
||||
- [x] Чат с иконкой mute 🔇
|
||||
- [x] Чат с индикатором mention @
|
||||
- [ ] Чат с онлайн-статусом ●
|
||||
- [x] Выбранный чат (с ▌)
|
||||
- [x] Список чатов в режиме поиска
|
||||
- [x] Длинное название чата (обрезка)
|
||||
|
||||
**Пример теста**:
|
||||
```rust
|
||||
#[test]
|
||||
fn snapshot_chat_list_with_unread() {
|
||||
let app = TestAppBuilder::new()
|
||||
.with_chat(create_test_chat("Mom", 123, unread: 5))
|
||||
.with_chat(create_test_chat("Boss", 456, unread: 0))
|
||||
.build();
|
||||
|
||||
assert_ui_snapshot!("chat_list_with_unread", app, |f, app| {
|
||||
render_chat_list(f, f.size(), app);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 1.2 Messages — Область сообщений
|
||||
|
||||
**Файл**: `tests/messages.rs`
|
||||
|
||||
- [x] Пустой чат (нет сообщений)
|
||||
- [x] Одно входящее сообщение
|
||||
- [x] Одно исходящее сообщение
|
||||
- [x] Группировка по дате (разделитель "Сегодня")
|
||||
- [x] Группировка по дате (разделитель "Вчера")
|
||||
- [x] Группировка по отправителю (заголовок с именем)
|
||||
- [x] Исходящее сообщение с ✓ (отправлено)
|
||||
- [x] Исходящее сообщение с ✓✓ (прочитано)
|
||||
- [x] Сообщение с индикатором редактирования ✎
|
||||
- [x] Длинное сообщение (wrap на несколько строк)
|
||||
- [x] Markdown: жирный, курсив, код
|
||||
- [x] Markdown: ссылка, упоминание
|
||||
- [x] Markdown: спойлер
|
||||
- [x] Сообщение с медиа-заглушкой [Фото]
|
||||
- [x] Reply сообщение с превью
|
||||
- [x] Пересланное сообщение (↪ Переслано от)
|
||||
- [x] Сообщение с одной реакцией [👍]
|
||||
- [x] Сообщение с несколькими реакциями [👍] 5 👎 3
|
||||
- [x] Выбранное сообщение (подсветка)
|
||||
|
||||
---
|
||||
|
||||
### 1.3 Modals — Модальные окна
|
||||
|
||||
**Файл**: `tests/modals.rs`
|
||||
|
||||
- [x] Delete confirmation модалка
|
||||
- [x] Emoji picker (8x6 сетка)
|
||||
- [x] Emoji picker с выбранной реакцией (курсор)
|
||||
- [x] Profile модалка (личный чат)
|
||||
- [x] Profile модалка (группа)
|
||||
- [x] Pinned message вверху чата
|
||||
- [x] Search в чате (с результатами)
|
||||
- [x] Forward mode (список чатов для пересылки)
|
||||
|
||||
---
|
||||
|
||||
### 1.4 Input Field — Поле ввода
|
||||
|
||||
**Файл**: `tests/input_field.rs`
|
||||
|
||||
- [x] Пустое поле ввода
|
||||
- [x] Поле ввода с текстом и курсором █
|
||||
- [x] Поле ввода с длинным текстом (2 строки)
|
||||
- [x] Поле ввода с длинным текстом (10 строк, максимум)
|
||||
- [x] Режим редактирования (с превью)
|
||||
- [x] Режим reply (с превью сообщения)
|
||||
- [x] Режим поиска (с query)
|
||||
|
||||
---
|
||||
|
||||
### 1.5 Footer — Нижняя панель ✅
|
||||
|
||||
**Файл**: `tests/footer.rs`
|
||||
|
||||
- [x] Footer в списке чатов (команды навигации)
|
||||
- [x] Footer в открытом чате (команды сообщений)
|
||||
- [x] Footer с индикатором "⚠ Нет сети"
|
||||
- [x] Footer с индикатором "⏳ Подключение к прокси..."
|
||||
- [x] Footer с индикатором "⏳ Подключение..."
|
||||
- [x] Footer в режиме поиска
|
||||
|
||||
---
|
||||
|
||||
### 1.6 Screens — Полные экраны ✅
|
||||
|
||||
**Файл**: `tests/screens.rs`
|
||||
|
||||
- [x] Loading screen (default)
|
||||
- [x] Loading screen (со статусом)
|
||||
- [x] Auth screen (ввод телефона)
|
||||
- [x] Auth screen (ввод кода)
|
||||
- [x] Auth screen (ввод пароля 2FA)
|
||||
- [x] Main screen (пустой список чатов)
|
||||
- [x] Минимальный размер терминала (предупреждение)
|
||||
|
||||
---
|
||||
|
||||
## Фаза 2: Integration Tests для логики (Приоритет: ВЫСОКИЙ)
|
||||
|
||||
### 2.1 Send Message Flow ✅
|
||||
|
||||
**Файл**: `tests/send_message.rs` (6 тестов)
|
||||
|
||||
- [x] Отправка текстового сообщения
|
||||
- [x] Отправка нескольких сообщений
|
||||
- [x] Отправка с markdown форматированием
|
||||
- [x] Отправка в разные чаты
|
||||
- [x] Получение входящего сообщения
|
||||
- [x] Отправка с reply
|
||||
|
||||
---
|
||||
|
||||
### 2.2 Edit Message Flow ✅
|
||||
|
||||
**Файл**: `tests/edit_message.rs` (6 тестов)
|
||||
|
||||
- [x] Редактирование текста сообщения
|
||||
- [x] Установка edit_date после редактирования
|
||||
- [x] Проверка can_be_edited перед редактированием
|
||||
- [x] Редактирование только своих сообщений
|
||||
- [x] Множественные редактирования
|
||||
- [x] Редактирование с форматированием
|
||||
|
||||
---
|
||||
|
||||
### 2.3 Delete Message Flow ✅
|
||||
|
||||
**Файл**: `tests/delete_message.rs` (6 тестов)
|
||||
|
||||
- [x] Удаление сообщения из списка
|
||||
- [x] Множественные удаления
|
||||
- [x] Проверка can_be_deleted
|
||||
- [x] Удаление только своих сообщений
|
||||
- [x] Удаление из разных чатов
|
||||
- [x] Delete with revoke
|
||||
|
||||
---
|
||||
|
||||
### 2.4 Reply & Forward Flow ✅
|
||||
|
||||
**Файл**: `tests/reply_forward.rs` (8 тестов)
|
||||
|
||||
- [x] Reply на сообщение с превью
|
||||
- [x] Reply сохраняет связь с оригиналом
|
||||
- [x] Forward сообщения
|
||||
- [x] Forward с sender_name
|
||||
- [x] Forward в разные чаты
|
||||
- [x] Reply + Forward комбо
|
||||
- [x] Reply на forwarded сообщение
|
||||
- [x] Forward reply сообщения
|
||||
|
||||
---
|
||||
|
||||
### 2.5 Reactions Flow ✅
|
||||
|
||||
**Файл**: `tests/reactions.rs` (10 тестов)
|
||||
|
||||
- [x] Добавление реакции на сообщение
|
||||
- [x] Удаление реакции (toggle)
|
||||
- [x] Множественные реакции на одно сообщение
|
||||
- [x] Реакции от разных пользователей
|
||||
- [x] Подсчёт реакций
|
||||
- [x] Chosen реакция (своя)
|
||||
- [x] Реакции обновляются в реальном времени
|
||||
- [x] Получение доступных реакций чата
|
||||
- [x] Реакции на forwarded сообщения
|
||||
- [x] Очистка всех реакций
|
||||
|
||||
---
|
||||
|
||||
### 2.6 Search Flow ✅
|
||||
|
||||
**Файл**: `tests/search.rs` (8 тестов)
|
||||
|
||||
- [x] Поиск по названию чата
|
||||
- [x] Поиск по @username
|
||||
- [x] Поиск по сообщениям в чате
|
||||
- [x] Навигация по результатам поиска
|
||||
- [x] Case-insensitive поиск
|
||||
- [x] Поиск с пробелами
|
||||
- [x] Поиск возвращает пустой список если нет совпадений
|
||||
- [x] Очистка поиска
|
||||
|
||||
---
|
||||
|
||||
### 2.7 Drafts Flow ✅
|
||||
|
||||
**Файл**: `tests/drafts.rs` (7 тестов)
|
||||
|
||||
- [x] Сохранение черновика при переключении чатов
|
||||
- [x] Восстановление черновика при возврате
|
||||
- [x] Удаление черновика после отправки
|
||||
- [x] Черновики для разных чатов независимы
|
||||
- [x] Индикатор черновика в списке чатов
|
||||
- [x] Пустой черновик не сохраняется
|
||||
- [x] Черновик сохраняется при закрытии чата
|
||||
|
||||
---
|
||||
|
||||
### 2.8 Navigation Flow ✅
|
||||
|
||||
**Файл**: `tests/navigation.rs` (7 тестов)
|
||||
|
||||
- [x] Навигация по списку чатов (↑/↓)
|
||||
- [x] Открытие чата (Enter)
|
||||
- [x] Закрытие чата (Esc)
|
||||
- [x] Скролл сообщений (↑/↓)
|
||||
- [x] Переключение между папками (1-9)
|
||||
- [x] Навигация с wrap (переход с конца на начало)
|
||||
- [x] Навигация в пустом списке
|
||||
|
||||
---
|
||||
|
||||
### 2.9 Profile Flow ✅
|
||||
|
||||
**Файл**: `tests/profile.rs` (6 тестов)
|
||||
|
||||
- [x] Открытие профиля личного чата
|
||||
- [x] Профиль показывает имя и username
|
||||
- [x] Профиль показывает телефон
|
||||
- [x] Открытие профиля группы
|
||||
- [x] Профиль группы показывает участников
|
||||
- [x] Закрытие профиля (Esc)
|
||||
|
||||
---
|
||||
|
||||
### 2.10 Network & Typing Flow ✅
|
||||
|
||||
**Файл**: `tests/network_typing.rs` (9 тестов)
|
||||
|
||||
- [x] Typing indicator при наборе текста
|
||||
- [x] Отправка typing action
|
||||
- [x] Получение typing статуса
|
||||
- [x] Typing timeout
|
||||
- [x] Network state: WaitingForNetwork
|
||||
- [x] Network state: ConnectingToProxy
|
||||
- [x] Network state: Connecting
|
||||
- [x] Network state: Updating
|
||||
- [x] Network state: Ready
|
||||
|
||||
---
|
||||
|
||||
### 2.11 Copy Flow ✅
|
||||
|
||||
**Файл**: `tests/copy.rs` (9 тестов - ПРЕВЗОШЛИ ПЛАН!)
|
||||
|
||||
- [x] Форматирование простого сообщения
|
||||
- [x] Форматирование с forward контекстом
|
||||
- [x] Форматирование с reply контекстом
|
||||
- [x] Форматирование с forward + reply одновременно
|
||||
- [x] Форматирование длинного сообщения
|
||||
- [x] Форматирование с markdown entities
|
||||
- [x] Clipboard initialization
|
||||
- [x] Копирование в реальный clipboard (ручное)
|
||||
- [x] Кроссплатформенность clipboard
|
||||
|
||||
---
|
||||
|
||||
### 2.12 Config Flow ✅
|
||||
|
||||
**Файл**: `tests/config.rs` (11 тестов - ПРЕВЗОШЛИ ПЛАН!)
|
||||
|
||||
- [x] Дефолтные значения конфигурации
|
||||
- [x] Кастомные значения конфигурации
|
||||
- [x] Парсинг валидных цветов
|
||||
- [x] Парсинг light цветов
|
||||
- [x] Парсинг невалидного цвета с fallback
|
||||
- [x] Case-insensitive парсинг цветов
|
||||
- [x] TOML сериализация и десериализация
|
||||
- [x] Частичный TOML использует дефолты
|
||||
- [x] Различные форматы timezone
|
||||
- [x] Загрузка credentials из переменных окружения
|
||||
- [x] Проверка формата ошибки когда credentials не найдены
|
||||
|
||||
---
|
||||
|
||||
## Фаза 3: E2E Integration Tests (Приоритет: СРЕДНИЙ) ✅
|
||||
|
||||
### 3.1 Smoke Tests ✅
|
||||
**Файл**: `tests/e2e_smoke.rs` (4 теста)
|
||||
|
||||
- [x] Приложение запускается без краша
|
||||
- [x] Проверка минимального размера терминала
|
||||
- [x] Базовые константы приложения
|
||||
- [x] Graceful shutdown флаг
|
||||
|
||||
### 3.2 User Journey Tests ✅
|
||||
**Файл**: `tests/e2e_user_journey.rs` (8 тестов)
|
||||
|
||||
- [x] App Launch → Auth → Chat List
|
||||
- [x] Open Chat → Load History → Send Message
|
||||
- [x] Receive Incoming Message While Chat Open
|
||||
- [x] Multi-step conversation flow
|
||||
- [x] Switch between chats
|
||||
- [x] Edit message in conversation flow
|
||||
- [x] Reply to message in conversation
|
||||
- [x] Network state changes during conversation
|
||||
|
||||
**Итого**: 12/12 E2E тестов (100%) ✅
|
||||
|
||||
**Примечание**: Все тесты используют FakeTdClient для полной симуляции TDLib без реального подключения.
|
||||
|
||||
---
|
||||
|
||||
## Фаза 4: Дополнительные тесты (Приоритет: НИЗКИЙ)
|
||||
|
||||
### 4.1 Utils Tests
|
||||
|
||||
**Файл**: `tests/unit/utils_test.rs`
|
||||
|
||||
- [ ] `format_timestamp_with_tz` с разными timezone
|
||||
- [ ] `parse_timezone_offset` валидные значения
|
||||
- [ ] `parse_timezone_offset` инвалидные значения (fallback)
|
||||
- [ ] `format_date` для сегодня, вчера, старых дат
|
||||
- [ ] `format_was_online` для разных временных промежутков
|
||||
|
||||
### 4.2 Performance Tests
|
||||
|
||||
**Файл**: `tests/performance/render_bench.rs`
|
||||
|
||||
- [ ] Benchmark рендеринга 100 сообщений
|
||||
- [ ] Benchmark рендеринга списка 50 чатов
|
||||
- [ ] Benchmark форматирования markdown текста
|
||||
|
||||
---
|
||||
|
||||
## Метрики прогресса
|
||||
|
||||
### Фаза 0: Инфраструктура
|
||||
- [x] 8/8 задач выполнено ✅
|
||||
|
||||
### Фаза 1: Snapshot Tests ✅
|
||||
- [x] 1.1 Chat List: 10/10 (100%) ✅
|
||||
- [x] 1.2 Messages: 19/19 (100%) ✅
|
||||
- [x] 1.3 Modals: 8/8 (100%) ✅
|
||||
- [x] 1.4 Input Field: 7/7 (100%) ✅
|
||||
- [x] 1.5 Footer: 6/6 (100%) ✅
|
||||
- [x] 1.6 Screens: 7/7 (100%) ✅
|
||||
- **Итого: 57/57 snapshot тестов (100%)** ✅
|
||||
|
||||
### Фаза 2: Integration Tests ✅
|
||||
- [x] 2.1 Send Message: 6/6 ✅
|
||||
- [x] 2.2 Edit Message: 6/6 ✅
|
||||
- [x] 2.3 Delete Message: 6/6 ✅
|
||||
- [x] 2.4 Reply & Forward: 8/8 ✅
|
||||
- [x] 2.5 Reactions: 10/10 ✅
|
||||
- [x] 2.6 Search: 8/8 ✅
|
||||
- [x] 2.7 Drafts: 7/7 ✅
|
||||
- [x] 2.8 Navigation: 7/7 ✅
|
||||
- [x] 2.9 Profile: 6/6 ✅
|
||||
- [x] 2.10 Network & Typing: 9/9 ✅
|
||||
- [x] 2.11 Copy: 9/9 ✅ (вместо 3!)
|
||||
- [x] 2.12 Config: 11/11 ✅ (вместо 8!)
|
||||
- **Итого: 93/93 интеграционных тестов (100%!) — ПРЕВЗОШЛИ ПЛАН!** 🎉
|
||||
|
||||
### Фаза 3: E2E Integration
|
||||
- [x] 3.1 Smoke Tests: 4/4 ✅
|
||||
- [x] 3.2 User Journey: 8/8 ✅
|
||||
- **Итого: 12/12 E2E тестов (100%)** ✅
|
||||
|
||||
### Фаза 4: Дополнительно
|
||||
- [ ] 4.1 Utils: 0/5
|
||||
- [ ] 4.2 Performance: 0/3
|
||||
- **Итого: 0/8 дополнительных тестов**
|
||||
|
||||
---
|
||||
|
||||
## Общий прогресс
|
||||
|
||||
**Всего**: 164/171 тестов (96%) — ПРЕВЗОШЛИ ПЛАН! 🎉🎉🎉
|
||||
|
||||
**Фаза 0 (Инфраструктура)**: ✅ Завершена (100%)
|
||||
**Фаза 1 (UI Snapshot Tests)**: ✅ 57/57 (100%) — ЗАВЕРШЕНА! 🎉
|
||||
- 1.1 Chat List: 10/10 (включая онлайн-статус) ✅
|
||||
- 1.2 Messages: 19/19 ✅
|
||||
- 1.3 Modals: 8/8 ✅
|
||||
- 1.4 Input Field: 7/7 ✅
|
||||
- 1.5 Footer: 6/6 ✅
|
||||
- 1.6 Screens: 7/7 ✅
|
||||
|
||||
**Фаза 2 (Integration Tests)**: ✅ 93/93 (100%!) — ПРЕВЗОШЛИ ПЛАН!
|
||||
- Завершено: 2.1-2.12 ✅
|
||||
- Превзошли план на 9 тестов: Copy (9 вместо 3), Config (11 вместо 8)
|
||||
|
||||
**Фаза 3 (E2E Integration Tests)**: ✅ 12/12 (100%) — ЗАВЕРШЕНА! 🎉
|
||||
- Smoke Tests: 4/4 ✅
|
||||
- User Journey: 8/8 ✅
|
||||
|
||||
**Опционально**:
|
||||
- Фаза 4 (Utils + Performance): 0/8
|
||||
|
||||
---
|
||||
|
||||
## Приоритизация
|
||||
|
||||
### Критичные (делать в первую очередь):
|
||||
1. **Фаза 0**: Инфраструктура (без неё никуда)
|
||||
2. **1.2**: Messages snapshots (ядро приложения)
|
||||
3. **2.1**: Send message (основной flow)
|
||||
4. **2.8**: Navigation (базовая навигация)
|
||||
|
||||
### Важные (делать после критичных):
|
||||
5. **1.1**: Chat list snapshots
|
||||
6. **2.2**: Edit message
|
||||
7. **2.3**: Delete message
|
||||
8. **2.5**: Reactions
|
||||
9. **2.6**: Search
|
||||
|
||||
### Желательные (можно отложить):
|
||||
10. **1.3-1.6**: Остальные snapshots
|
||||
11. **2.4, 2.7, 2.9-2.12**: Остальные flows
|
||||
12. **Фаза 3**: E2E smoke tests
|
||||
|
||||
### Опциональные (по желанию):
|
||||
13. **Фаза 4**: Utils и performance
|
||||
|
||||
---
|
||||
|
||||
## Технологии
|
||||
|
||||
### Основные
|
||||
- **insta** — snapshot testing
|
||||
- **tokio-test** — async testing utilities
|
||||
- **ratatui::backend::TestBackend** — виртуальный терминал
|
||||
|
||||
### Дополнительные (опционально)
|
||||
- **expectrl** — для E2E тестов с реальным бинарником
|
||||
- **criterion** — для бенчмарков (фаза 4.2)
|
||||
- **mockall** — если понадобятся моки (скорее всего нет)
|
||||
|
||||
---
|
||||
|
||||
## Примеры структуры тестов
|
||||
|
||||
### Snapshot Test
|
||||
```rust
|
||||
use insta::assert_snapshot;
|
||||
use ratatui::backend::TestBackend;
|
||||
use ratatui::Terminal;
|
||||
|
||||
#[test]
|
||||
fn snapshot_messages_with_reactions() {
|
||||
let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
|
||||
|
||||
let app = TestAppBuilder::new()
|
||||
.with_message(create_test_message("Hello!", reactions: vec![
|
||||
reaction("👍", 1, chosen: true),
|
||||
reaction("👎", 3, chosen: false),
|
||||
]))
|
||||
.build();
|
||||
|
||||
terminal.draw(|f| {
|
||||
render_messages(f, f.size(), &app);
|
||||
}).unwrap();
|
||||
|
||||
let buffer = terminal.backend().buffer();
|
||||
assert_snapshot!(buffer_to_string(buffer));
|
||||
}
|
||||
```
|
||||
|
||||
### Integration Test
|
||||
```rust
|
||||
use crate::helpers::{TestAppBuilder, FakeTdClient};
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_send_message_updates_ui() {
|
||||
let fake_client = FakeTdClient::new()
|
||||
.with_chat("Mom", 123);
|
||||
|
||||
let mut app = TestAppBuilder::new()
|
||||
.with_client(fake_client)
|
||||
.with_selected_chat(123)
|
||||
.build();
|
||||
|
||||
// Ввод текста
|
||||
app.input_text = "Hello!".to_string();
|
||||
|
||||
// Отправка
|
||||
app.handle_key(KeyCode::Enter).await;
|
||||
|
||||
// Проверки
|
||||
assert_eq!(app.input_text, ""); // Инпут очистился
|
||||
assert_eq!(app.current_messages().len(), 1);
|
||||
assert_eq!(app.current_messages()[0].text, "Hello!");
|
||||
assert_eq!(fake_client.sent_messages().len(), 1);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Команды
|
||||
|
||||
```bash
|
||||
# Прогнать все тесты
|
||||
cargo test
|
||||
|
||||
# Прогнать только snapshot тесты
|
||||
cargo test --test ui
|
||||
|
||||
# Прогнать только integration тесты
|
||||
cargo test --test integration
|
||||
|
||||
# Обновить snapshots (после ревью изменений)
|
||||
cargo insta review
|
||||
|
||||
# Принять все новые snapshots
|
||||
cargo insta accept
|
||||
|
||||
# Показать diff для изменённых snapshots
|
||||
cargo insta test --review
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Правила
|
||||
|
||||
1. **Один тест = один сценарий** — не делать мега-тесты
|
||||
2. **Snapshots коммитим** — они часть тестов
|
||||
3. **Фикстуры переиспользуем** — общие данные в `test_data.rs`
|
||||
4. **Тесты изолированы** — каждый тест создаёт свой App
|
||||
5. **Порядок не важен** — тесты можно запускать в любом порядке
|
||||
|
||||
---
|
||||
|
||||
## TODO перед началом
|
||||
|
||||
- [ ] Прочитать документацию insta: https://insta.rs/
|
||||
- [ ] Решить: нужен ли trait для TdClient или достаточно FakeTdClient
|
||||
- [ ] Обсудить: какие тесты делать в первую очередь
|
||||
|
||||
---
|
||||
|
||||
## Примечания
|
||||
|
||||
- Этот документ будет обновляться по мере написания тестов
|
||||
- После завершения фазы — отмечать в метриках
|
||||
- Если тест падает или не актуален — документировать причину
|
||||
- Snapshots хранятся в `tests/snapshots/__snapshots__/`
|
||||
86
benches/format_markdown.rs
Normal file
86
benches/format_markdown.rs
Normal file
@@ -0,0 +1,86 @@
|
||||
use criterion::{black_box, criterion_group, criterion_main, Criterion};
|
||||
use tdlib_rs::enums::{TextEntity, TextEntityType};
|
||||
use tele_tui::formatting::format_text_with_entities;
|
||||
|
||||
fn create_text_with_entities() -> (String, Vec<TextEntity>) {
|
||||
let text = "This is bold and italic text with code and a link and mention".to_string();
|
||||
|
||||
let entities = vec![
|
||||
TextEntity {
|
||||
offset: 8,
|
||||
length: 4, // bold
|
||||
type_: TextEntityType::Bold,
|
||||
},
|
||||
TextEntity {
|
||||
offset: 17,
|
||||
length: 6, // italic
|
||||
type_: TextEntityType::Italic,
|
||||
},
|
||||
TextEntity {
|
||||
offset: 34,
|
||||
length: 4, // code
|
||||
type_: TextEntityType::Code,
|
||||
},
|
||||
TextEntity {
|
||||
offset: 45,
|
||||
length: 4, // link
|
||||
type_: TextEntityType::Url,
|
||||
},
|
||||
TextEntity {
|
||||
offset: 54,
|
||||
length: 7, // mention
|
||||
type_: TextEntityType::Mention,
|
||||
},
|
||||
];
|
||||
|
||||
(text, entities)
|
||||
}
|
||||
|
||||
fn benchmark_format_simple_text(c: &mut Criterion) {
|
||||
let text = "Simple text without any formatting".to_string();
|
||||
let entities = vec![];
|
||||
|
||||
c.bench_function("format_simple_text", |b| {
|
||||
b.iter(|| format_text_with_entities(black_box(&text), black_box(&entities)));
|
||||
});
|
||||
}
|
||||
|
||||
fn benchmark_format_markdown_text(c: &mut Criterion) {
|
||||
let (text, entities) = create_text_with_entities();
|
||||
|
||||
c.bench_function("format_markdown_text", |b| {
|
||||
b.iter(|| format_text_with_entities(black_box(&text), black_box(&entities)));
|
||||
});
|
||||
}
|
||||
|
||||
fn benchmark_format_long_text(c: &mut Criterion) {
|
||||
let mut text = String::new();
|
||||
let mut entities = vec![];
|
||||
|
||||
// Создаем длинный текст с множеством форматирований
|
||||
for i in 0..100 {
|
||||
let start = text.len();
|
||||
text.push_str(&format!("Word{} ", i));
|
||||
|
||||
// Добавляем форматирование к каждому 3-му слову
|
||||
if i % 3 == 0 {
|
||||
entities.push(TextEntity {
|
||||
offset: start as i32,
|
||||
length: format!("Word{}", i).len() as i32,
|
||||
type_: TextEntityType::Bold,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
c.bench_function("format_long_text_with_100_entities", |b| {
|
||||
b.iter(|| format_text_with_entities(black_box(&text), black_box(&entities)));
|
||||
});
|
||||
}
|
||||
|
||||
criterion_group!(
|
||||
benches,
|
||||
benchmark_format_simple_text,
|
||||
benchmark_format_markdown_text,
|
||||
benchmark_format_long_text
|
||||
);
|
||||
criterion_main!(benches);
|
||||
38
benches/formatting.rs
Normal file
38
benches/formatting.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
use criterion::{black_box, criterion_group, criterion_main, Criterion};
|
||||
use tele_tui::utils::formatting::{format_date, format_timestamp_with_tz, get_day};
|
||||
|
||||
fn benchmark_format_timestamp(c: &mut Criterion) {
|
||||
c.bench_function("format_timestamp_50_times", |b| {
|
||||
b.iter(|| {
|
||||
for i in 0..50 {
|
||||
let timestamp = 1640000000 + (i * 60);
|
||||
black_box(format_timestamp_with_tz(timestamp, "+03:00"));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn benchmark_format_date(c: &mut Criterion) {
|
||||
c.bench_function("format_date_50_times", |b| {
|
||||
b.iter(|| {
|
||||
for i in 0..50 {
|
||||
let timestamp = 1640000000 + (i * 86400);
|
||||
black_box(format_date(timestamp));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn benchmark_get_day(c: &mut Criterion) {
|
||||
c.bench_function("get_day_1000_times", |b| {
|
||||
b.iter(|| {
|
||||
for i in 0..1000 {
|
||||
let timestamp = 1640000000 + (i * 60);
|
||||
black_box(get_day(timestamp));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
criterion_group!(benches, benchmark_format_timestamp, benchmark_format_date, benchmark_get_day);
|
||||
criterion_main!(benches);
|
||||
43
benches/group_messages.rs
Normal file
43
benches/group_messages.rs
Normal file
@@ -0,0 +1,43 @@
|
||||
use criterion::{black_box, criterion_group, criterion_main, Criterion};
|
||||
use tele_tui::message_grouping::group_messages;
|
||||
use tele_tui::tdlib::types::MessageBuilder;
|
||||
use tele_tui::types::MessageId;
|
||||
|
||||
fn create_test_messages(count: usize) -> Vec<tele_tui::tdlib::MessageInfo> {
|
||||
(0..count)
|
||||
.map(|i| {
|
||||
let builder = MessageBuilder::new(MessageId::new(i as i64))
|
||||
.sender_name(&format!("User{}", i % 10))
|
||||
.text(&format!(
|
||||
"Test message number {} with some longer text to make it more realistic",
|
||||
i
|
||||
))
|
||||
.date(1640000000 + (i as i32 * 60));
|
||||
|
||||
if i % 2 == 0 {
|
||||
builder.outgoing().read().build()
|
||||
} else {
|
||||
builder.incoming().build()
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn benchmark_group_100_messages(c: &mut Criterion) {
|
||||
let messages = create_test_messages(100);
|
||||
|
||||
c.bench_function("group_100_messages", |b| {
|
||||
b.iter(|| group_messages(black_box(&messages)));
|
||||
});
|
||||
}
|
||||
|
||||
fn benchmark_group_500_messages(c: &mut Criterion) {
|
||||
let messages = create_test_messages(500);
|
||||
|
||||
c.bench_function("group_500_messages", |b| {
|
||||
b.iter(|| group_messages(black_box(&messages)));
|
||||
});
|
||||
}
|
||||
|
||||
criterion_group!(benches, benchmark_group_100_messages, benchmark_group_500_messages);
|
||||
criterion_main!(benches);
|
||||
@@ -6,15 +6,6 @@ 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
|
||||
|
||||
127
src/accounts/lock.rs
Normal file
127
src/accounts/lock.rs
Normal file
@@ -0,0 +1,127 @@
|
||||
//! Per-account advisory file locking to prevent concurrent access.
|
||||
//!
|
||||
//! Uses `flock` (via `fs2`) for automatic lock release on process crash/SIGKILL.
|
||||
//! Lock file: `~/.local/share/tele-tui/accounts/{name}/tele-tui.lock`
|
||||
|
||||
use fs2::FileExt;
|
||||
use std::fs::{self, File};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Returns the lock file path for a given account.
|
||||
///
|
||||
/// Path: `{data_dir}/tele-tui/accounts/{name}/tele-tui.lock`
|
||||
pub fn account_lock_path(account_name: &str) -> PathBuf {
|
||||
let mut path = dirs::data_dir().unwrap_or_else(|| PathBuf::from("."));
|
||||
path.push("tele-tui");
|
||||
path.push("accounts");
|
||||
path.push(account_name);
|
||||
path.push("tele-tui.lock");
|
||||
path
|
||||
}
|
||||
|
||||
/// Acquires an exclusive advisory lock for the given account.
|
||||
///
|
||||
/// Creates the lock file and parent directories if needed.
|
||||
/// Returns the open `File` handle — the lock is held as long as this handle exists.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error message if the lock is already held by another process
|
||||
/// or if the lock file cannot be created.
|
||||
pub fn acquire_lock(account_name: &str) -> Result<File, String> {
|
||||
let lock_path = account_lock_path(account_name);
|
||||
|
||||
// Ensure parent directory exists
|
||||
if let Some(parent) = lock_path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| {
|
||||
format!(
|
||||
"Не удалось создать директорию для lock-файла: {}",
|
||||
e
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
let file = File::create(&lock_path).map_err(|e| {
|
||||
format!("Не удалось создать lock-файл {}: {}", lock_path.display(), e)
|
||||
})?;
|
||||
|
||||
file.try_lock_exclusive().map_err(|_| {
|
||||
format!(
|
||||
"Аккаунт '{}' уже используется другим экземпляром tele-tui.\n\
|
||||
Lock-файл: {}",
|
||||
account_name,
|
||||
lock_path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
/// Explicitly releases the lock by unlocking and dropping the file handle.
|
||||
///
|
||||
/// Used during account switching to release the old account's lock
|
||||
/// before acquiring the new one.
|
||||
pub fn release_lock(lock_file: File) {
|
||||
let _ = lock_file.unlock();
|
||||
drop(lock_file);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_lock_path_structure() {
|
||||
let path = account_lock_path("default");
|
||||
let path_str = path.to_string_lossy();
|
||||
assert!(path_str.contains("tele-tui"));
|
||||
assert!(path_str.contains("accounts"));
|
||||
assert!(path_str.contains("default"));
|
||||
assert!(path_str.ends_with("tele-tui.lock"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lock_path_per_account() {
|
||||
let path1 = account_lock_path("work");
|
||||
let path2 = account_lock_path("personal");
|
||||
assert_ne!(path1, path2);
|
||||
assert!(path1.to_string_lossy().contains("work"));
|
||||
assert!(path2.to_string_lossy().contains("personal"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_acquire_and_release() {
|
||||
let name = "test-lock-acquire-release";
|
||||
let lock = acquire_lock(name).expect("first acquire should succeed");
|
||||
|
||||
// Second acquire should fail (same process, exclusive lock)
|
||||
let result = acquire_lock(name);
|
||||
assert!(result.is_err(), "second acquire should fail");
|
||||
assert!(
|
||||
result.unwrap_err().contains("уже используется"),
|
||||
"error should mention already in use"
|
||||
);
|
||||
|
||||
// Release and re-acquire
|
||||
release_lock(lock);
|
||||
let lock2 = acquire_lock(name).expect("acquire after release should succeed");
|
||||
|
||||
// Cleanup
|
||||
release_lock(lock2);
|
||||
let _ = fs::remove_file(account_lock_path(name));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lock_released_on_drop() {
|
||||
let name = "test-lock-drop";
|
||||
{
|
||||
let _lock = acquire_lock(name).expect("acquire should succeed");
|
||||
// _lock dropped here
|
||||
}
|
||||
|
||||
// After drop, lock should be free
|
||||
let lock = acquire_lock(name).expect("acquire after drop should succeed");
|
||||
release_lock(lock);
|
||||
let _ = fs::remove_file(account_lock_path(name));
|
||||
}
|
||||
}
|
||||
202
src/accounts/manager.rs
Normal file
202
src/accounts/manager.rs
Normal file
@@ -0,0 +1,202 @@
|
||||
//! Account manager: loading, saving, migration, and resolution.
|
||||
//!
|
||||
//! Handles `accounts.toml` lifecycle and legacy `./tdlib_data/` migration
|
||||
//! to XDG data directory.
|
||||
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::profile::{account_db_path, validate_account_name, AccountsConfig};
|
||||
|
||||
/// Returns the path to `accounts.toml` in the config directory.
|
||||
///
|
||||
/// `~/.config/tele-tui/accounts.toml`
|
||||
pub fn accounts_config_path() -> Option<PathBuf> {
|
||||
dirs::config_dir().map(|mut path| {
|
||||
path.push("tele-tui");
|
||||
path.push("accounts.toml");
|
||||
path
|
||||
})
|
||||
}
|
||||
|
||||
/// Loads `accounts.toml` or creates it with default values.
|
||||
///
|
||||
/// On first run, also attempts to migrate legacy `./tdlib_data/` directory
|
||||
/// to the XDG data location.
|
||||
pub fn load_or_create() -> AccountsConfig {
|
||||
let config_path = match accounts_config_path() {
|
||||
Some(path) => path,
|
||||
None => {
|
||||
tracing::warn!("Could not determine config directory for accounts, using defaults");
|
||||
return AccountsConfig::default_single();
|
||||
}
|
||||
};
|
||||
|
||||
if config_path.exists() {
|
||||
// Load existing config
|
||||
match fs::read_to_string(&config_path) {
|
||||
Ok(content) => match toml::from_str::<AccountsConfig>(&content) {
|
||||
Ok(config) => return config,
|
||||
Err(e) => {
|
||||
tracing::warn!("Could not parse accounts.toml: {}", e);
|
||||
return AccountsConfig::default_single();
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::warn!("Could not read accounts.toml: {}", e);
|
||||
return AccountsConfig::default_single();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// First run: migrate legacy data if present, then create default config
|
||||
migrate_legacy();
|
||||
|
||||
let config = AccountsConfig::default_single();
|
||||
if let Err(e) = save(&config) {
|
||||
tracing::warn!("Could not save initial accounts.toml: {}", e);
|
||||
}
|
||||
config
|
||||
}
|
||||
|
||||
/// Saves `AccountsConfig` to `accounts.toml`.
|
||||
pub fn save(config: &AccountsConfig) -> Result<(), String> {
|
||||
let config_path =
|
||||
accounts_config_path().ok_or_else(|| "Could not determine config directory".to_string())?;
|
||||
|
||||
// Ensure parent directory exists
|
||||
if let Some(parent) = config_path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("Could not create config directory: {}", e))?;
|
||||
}
|
||||
|
||||
let toml_string = toml::to_string_pretty(config)
|
||||
.map_err(|e| format!("Could not serialize accounts config: {}", e))?;
|
||||
|
||||
fs::write(&config_path, toml_string)
|
||||
.map_err(|e| format!("Could not write accounts.toml: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Migrates legacy `./tdlib_data/` from CWD to XDG data dir.
|
||||
///
|
||||
/// If `./tdlib_data/` exists in the current working directory, moves it to
|
||||
/// `~/.local/share/tele-tui/accounts/default/tdlib_data/`.
|
||||
fn migrate_legacy() {
|
||||
let legacy_path = PathBuf::from("tdlib_data");
|
||||
if !legacy_path.exists() || !legacy_path.is_dir() {
|
||||
return;
|
||||
}
|
||||
|
||||
let target = account_db_path("default");
|
||||
|
||||
// Don't overwrite if target already exists
|
||||
if target.exists() {
|
||||
tracing::info!(
|
||||
"Legacy ./tdlib_data/ found but target already exists at {}, skipping migration",
|
||||
target.display()
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create parent directories
|
||||
if let Some(parent) = target.parent() {
|
||||
if let Err(e) = fs::create_dir_all(parent) {
|
||||
tracing::error!("Could not create target directory for migration: {}", e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Move (rename) the directory
|
||||
match fs::rename(&legacy_path, &target) {
|
||||
Ok(()) => {
|
||||
tracing::info!("Migrated ./tdlib_data/ -> {}", target.display());
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Could not migrate ./tdlib_data/ to {}: {}", target.display(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves which account to use from CLI arg or default.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `config` - The loaded accounts configuration
|
||||
/// * `account_arg` - Optional account name from `--account` CLI flag
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The resolved account name and its db_path.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the specified account is not found or the name is invalid.
|
||||
pub fn resolve_account(
|
||||
config: &AccountsConfig,
|
||||
account_arg: Option<&str>,
|
||||
) -> Result<(String, PathBuf), String> {
|
||||
let account_name = account_arg.unwrap_or(&config.default_account);
|
||||
|
||||
// Validate name
|
||||
validate_account_name(account_name)?;
|
||||
|
||||
// Find account in config
|
||||
let _account = config.find_account(account_name).ok_or_else(|| {
|
||||
let available: Vec<&str> = config.accounts.iter().map(|a| a.name.as_str()).collect();
|
||||
format!(
|
||||
"Account '{}' not found. Available accounts: {}",
|
||||
account_name,
|
||||
available.join(", ")
|
||||
)
|
||||
})?;
|
||||
|
||||
let db_path = account_db_path(account_name);
|
||||
Ok((account_name.to_string(), db_path))
|
||||
}
|
||||
|
||||
/// Adds a new account to `accounts.toml` and creates its data directory.
|
||||
///
|
||||
/// Validates the name, checks for duplicates, adds the profile to config,
|
||||
/// saves the config, and creates the data directory.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The db_path for the new account.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the name is invalid, already exists, or I/O fails.
|
||||
pub fn add_account(name: &str, display_name: &str) -> Result<std::path::PathBuf, String> {
|
||||
validate_account_name(name)?;
|
||||
|
||||
let mut config = load_or_create();
|
||||
|
||||
// Check for duplicate
|
||||
if config.find_account(name).is_some() {
|
||||
return Err(format!("Account '{}' already exists", name));
|
||||
}
|
||||
|
||||
// Add new profile
|
||||
config.accounts.push(super::profile::AccountProfile {
|
||||
name: name.to_string(),
|
||||
display_name: display_name.to_string(),
|
||||
});
|
||||
|
||||
// Save config
|
||||
save(&config)?;
|
||||
|
||||
// Create data directory
|
||||
ensure_account_dir(name)
|
||||
}
|
||||
|
||||
/// Ensures the account data directory exists.
|
||||
///
|
||||
/// Creates `~/.local/share/tele-tui/accounts/{name}/tdlib_data/` if needed.
|
||||
pub fn ensure_account_dir(account_name: &str) -> Result<PathBuf, String> {
|
||||
let db_path = account_db_path(account_name);
|
||||
fs::create_dir_all(&db_path)
|
||||
.map_err(|e| format!("Could not create account directory: {}", e))?;
|
||||
Ok(db_path)
|
||||
}
|
||||
15
src/accounts/mod.rs
Normal file
15
src/accounts/mod.rs
Normal file
@@ -0,0 +1,15 @@
|
||||
//! Account profiles module for multi-account support.
|
||||
//!
|
||||
//! Manages account profiles stored in `~/.config/tele-tui/accounts.toml`.
|
||||
//! Each account has its own TDLib database directory under
|
||||
//! `~/.local/share/tele-tui/accounts/{name}/tdlib_data/`.
|
||||
|
||||
pub mod lock;
|
||||
pub mod manager;
|
||||
pub mod profile;
|
||||
|
||||
pub use lock::{acquire_lock, release_lock};
|
||||
#[allow(unused_imports)]
|
||||
pub use manager::{add_account, ensure_account_dir, load_or_create, resolve_account, save};
|
||||
#[allow(unused_imports)]
|
||||
pub use profile::{account_db_path, validate_account_name, AccountProfile, AccountsConfig};
|
||||
147
src/accounts/profile.rs
Normal file
147
src/accounts/profile.rs
Normal file
@@ -0,0 +1,147 @@
|
||||
//! Account profile data structures and validation.
|
||||
//!
|
||||
//! Defines `AccountProfile` and `AccountsConfig` for multi-account support.
|
||||
//! Account names are validated to contain only alphanumeric characters, hyphens, and underscores.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Configuration for all accounts, stored in `~/.config/tele-tui/accounts.toml`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AccountsConfig {
|
||||
/// Name of the default account to use when no `--account` flag is provided.
|
||||
pub default_account: String,
|
||||
|
||||
/// List of configured accounts.
|
||||
pub accounts: Vec<AccountProfile>,
|
||||
}
|
||||
|
||||
/// A single account profile.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AccountProfile {
|
||||
/// Unique identifier (used in directory names and CLI flag).
|
||||
pub name: String,
|
||||
|
||||
/// Human-readable display name.
|
||||
pub display_name: String,
|
||||
}
|
||||
|
||||
impl AccountsConfig {
|
||||
/// Creates a default config with a single "default" account.
|
||||
pub fn default_single() -> Self {
|
||||
Self {
|
||||
default_account: "default".to_string(),
|
||||
accounts: vec![AccountProfile {
|
||||
name: "default".to_string(),
|
||||
display_name: "Default".to_string(),
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
/// Finds an account by name.
|
||||
pub fn find_account(&self, name: &str) -> Option<&AccountProfile> {
|
||||
self.accounts.iter().find(|a| a.name == name)
|
||||
}
|
||||
}
|
||||
|
||||
impl AccountProfile {
|
||||
/// Computes the TDLib database directory path for this account.
|
||||
///
|
||||
/// Returns `~/.local/share/tele-tui/accounts/{name}/tdlib_data`
|
||||
/// (or platform equivalent via `dirs::data_dir()`).
|
||||
pub fn db_path(&self) -> PathBuf {
|
||||
account_db_path(&self.name)
|
||||
}
|
||||
}
|
||||
|
||||
/// Computes the TDLib database directory path for a given account name.
|
||||
///
|
||||
/// Returns `{data_dir}/tele-tui/accounts/{name}/tdlib_data`.
|
||||
pub fn account_db_path(account_name: &str) -> PathBuf {
|
||||
let mut path = dirs::data_dir().unwrap_or_else(|| PathBuf::from("."));
|
||||
path.push("tele-tui");
|
||||
path.push("accounts");
|
||||
path.push(account_name);
|
||||
path.push("tdlib_data");
|
||||
path
|
||||
}
|
||||
|
||||
/// Validates an account name.
|
||||
///
|
||||
/// Valid names contain only lowercase alphanumeric characters, hyphens, and underscores.
|
||||
/// Must be 1-32 characters long.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns a descriptive error message if the name is invalid.
|
||||
pub fn validate_account_name(name: &str) -> Result<(), String> {
|
||||
if name.is_empty() {
|
||||
return Err("Account name cannot be empty".to_string());
|
||||
}
|
||||
if name.len() > 32 {
|
||||
return Err("Account name cannot be longer than 32 characters".to_string());
|
||||
}
|
||||
if !name
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
|
||||
{
|
||||
return Err(
|
||||
"Account name can only contain lowercase letters, digits, hyphens, and underscores"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
if name.starts_with('-') || name.starts_with('_') {
|
||||
return Err("Account name cannot start with a hyphen or underscore".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_validate_account_name_valid() {
|
||||
assert!(validate_account_name("default").is_ok());
|
||||
assert!(validate_account_name("work").is_ok());
|
||||
assert!(validate_account_name("my-account").is_ok());
|
||||
assert!(validate_account_name("account_2").is_ok());
|
||||
assert!(validate_account_name("a").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_account_name_invalid() {
|
||||
assert!(validate_account_name("").is_err());
|
||||
assert!(validate_account_name("My Account").is_err());
|
||||
assert!(validate_account_name("UPPER").is_err());
|
||||
assert!(validate_account_name("with spaces").is_err());
|
||||
assert!(validate_account_name("-starts-with-dash").is_err());
|
||||
assert!(validate_account_name("_starts-with-underscore").is_err());
|
||||
assert!(validate_account_name(&"a".repeat(33)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_single_config() {
|
||||
let config = AccountsConfig::default_single();
|
||||
assert_eq!(config.default_account, "default");
|
||||
assert_eq!(config.accounts.len(), 1);
|
||||
assert_eq!(config.accounts[0].name, "default");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_account() {
|
||||
let config = AccountsConfig::default_single();
|
||||
assert!(config.find_account("default").is_some());
|
||||
assert!(config.find_account("nonexistent").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_db_path_contains_account_name() {
|
||||
let path = account_db_path("work");
|
||||
let path_str = path.to_string_lossy();
|
||||
assert!(path_str.contains("tele-tui"));
|
||||
assert!(path_str.contains("accounts"));
|
||||
assert!(path_str.contains("work"));
|
||||
assert!(path_str.ends_with("tdlib_data"));
|
||||
}
|
||||
}
|
||||
87
src/app/auth_state.rs
Normal file
87
src/app/auth_state.rs
Normal file
@@ -0,0 +1,87 @@
|
||||
/// Состояние аутентификации
|
||||
///
|
||||
/// Отвечает за данные авторизации:
|
||||
/// - Ввод номера телефона
|
||||
/// - Ввод кода подтверждения
|
||||
/// - Ввод пароля (2FA)
|
||||
|
||||
/// Состояние аутентификации
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct AuthState {
|
||||
/// Введённый номер телефона
|
||||
phone_input: String,
|
||||
|
||||
/// Введённый код подтверждения
|
||||
code_input: String,
|
||||
|
||||
/// Введённый пароль (для 2FA)
|
||||
password_input: String,
|
||||
}
|
||||
|
||||
impl AuthState {
|
||||
/// Создать новое состояние аутентификации
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
// === Phone input ===
|
||||
|
||||
pub fn phone_input(&self) -> &str {
|
||||
&self.phone_input
|
||||
}
|
||||
|
||||
pub fn phone_input_mut(&mut self) -> &mut String {
|
||||
&mut self.phone_input
|
||||
}
|
||||
|
||||
pub fn set_phone_input(&mut self, input: String) {
|
||||
self.phone_input = input;
|
||||
}
|
||||
|
||||
pub fn clear_phone_input(&mut self) {
|
||||
self.phone_input.clear();
|
||||
}
|
||||
|
||||
// === Code input ===
|
||||
|
||||
pub fn code_input(&self) -> &str {
|
||||
&self.code_input
|
||||
}
|
||||
|
||||
pub fn code_input_mut(&mut self) -> &mut String {
|
||||
&mut self.code_input
|
||||
}
|
||||
|
||||
pub fn set_code_input(&mut self, input: String) {
|
||||
self.code_input = input;
|
||||
}
|
||||
|
||||
pub fn clear_code_input(&mut self) {
|
||||
self.code_input.clear();
|
||||
}
|
||||
|
||||
// === Password input ===
|
||||
|
||||
pub fn password_input(&self) -> &str {
|
||||
&self.password_input
|
||||
}
|
||||
|
||||
pub fn password_input_mut(&mut self) -> &mut String {
|
||||
&mut self.password_input
|
||||
}
|
||||
|
||||
pub fn set_password_input(&mut self, input: String) {
|
||||
self.password_input = input;
|
||||
}
|
||||
|
||||
pub fn clear_password_input(&mut self) {
|
||||
self.password_input.clear();
|
||||
}
|
||||
|
||||
/// Очистить все поля ввода
|
||||
pub fn clear_all(&mut self) {
|
||||
self.phone_input.clear();
|
||||
self.code_input.clear();
|
||||
self.password_input.clear();
|
||||
}
|
||||
}
|
||||
326
src/app/chat_filter.rs
Normal file
326
src/app/chat_filter.rs
Normal file
@@ -0,0 +1,326 @@
|
||||
/// Модуль для централизованной фильтрации чатов
|
||||
///
|
||||
/// Предоставляет единый источник правды для всех видов фильтрации:
|
||||
/// - По папкам (folders)
|
||||
/// - По поисковому запросу
|
||||
/// - По статусу (archived, muted, и т.д.)
|
||||
///
|
||||
/// Используется как в App, так и в UI слое для консистентной фильтрации.
|
||||
use crate::tdlib::ChatInfo;
|
||||
|
||||
/// Критерии фильтрации чатов
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ChatFilterCriteria {
|
||||
/// Фильтр по папке (folder_id)
|
||||
pub folder_id: Option<i32>,
|
||||
|
||||
/// Поисковый запрос (по названию или username)
|
||||
pub search_query: Option<String>,
|
||||
|
||||
/// Показывать только закреплённые
|
||||
pub pinned_only: bool,
|
||||
|
||||
/// Показывать только непрочитанные
|
||||
pub unread_only: bool,
|
||||
|
||||
/// Показывать только с упоминаниями
|
||||
pub mentions_only: bool,
|
||||
|
||||
/// Скрывать muted чаты
|
||||
pub hide_muted: bool,
|
||||
|
||||
/// Скрывать архивные чаты
|
||||
pub hide_archived: bool,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl ChatFilterCriteria {
|
||||
/// Создаёт критерии с дефолтными значениями
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Фильтр только по папке
|
||||
pub fn by_folder(folder_id: Option<i32>) -> Self {
|
||||
Self { folder_id, ..Default::default() }
|
||||
}
|
||||
|
||||
/// Фильтр только по поисковому запросу
|
||||
pub fn by_search(query: String) -> Self {
|
||||
Self { search_query: Some(query), ..Default::default() }
|
||||
}
|
||||
|
||||
/// Builder: установить папку
|
||||
pub fn with_folder(mut self, folder_id: Option<i32>) -> Self {
|
||||
self.folder_id = folder_id;
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder: установить поисковый запрос
|
||||
pub fn with_search(mut self, query: String) -> Self {
|
||||
self.search_query = Some(query);
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder: показывать только закреплённые
|
||||
pub fn pinned_only(mut self, enabled: bool) -> Self {
|
||||
self.pinned_only = enabled;
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder: показывать только непрочитанные
|
||||
pub fn unread_only(mut self, enabled: bool) -> Self {
|
||||
self.unread_only = enabled;
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder: показывать только с упоминаниями
|
||||
pub fn mentions_only(mut self, enabled: bool) -> Self {
|
||||
self.mentions_only = enabled;
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder: скрывать muted
|
||||
pub fn hide_muted(mut self, enabled: bool) -> Self {
|
||||
self.hide_muted = enabled;
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder: скрывать архивные
|
||||
pub fn hide_archived(mut self, enabled: bool) -> Self {
|
||||
self.hide_archived = enabled;
|
||||
self
|
||||
}
|
||||
|
||||
/// Проверяет подходит ли чат под все критерии
|
||||
pub fn matches(&self, chat: &ChatInfo) -> bool {
|
||||
// Фильтр по папке
|
||||
if let Some(folder_id) = self.folder_id {
|
||||
if !chat.folder_ids.contains(&folder_id) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Фильтр по поисковому запросу
|
||||
if let Some(ref query) = self.search_query {
|
||||
if !query.is_empty() {
|
||||
let query_lower = query.to_lowercase();
|
||||
let title_matches = chat.title.to_lowercase().contains(&query_lower);
|
||||
let username_matches = chat
|
||||
.username
|
||||
.as_ref()
|
||||
.map(|u| u.to_lowercase().contains(&query_lower))
|
||||
.unwrap_or(false);
|
||||
|
||||
if !title_matches && !username_matches {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Только закреплённые
|
||||
if self.pinned_only && !chat.is_pinned {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Только непрочитанные
|
||||
if self.unread_only && chat.unread_count == 0 {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Только с упоминаниями
|
||||
if self.mentions_only && chat.unread_mention_count == 0 {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Скрывать muted
|
||||
if self.hide_muted && chat.is_muted {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Скрывать архивные (folder_id == 1)
|
||||
if self.hide_archived && chat.folder_ids.contains(&1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Централизованный фильтр чатов
|
||||
#[allow(dead_code)]
|
||||
pub struct ChatFilter;
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl ChatFilter {
|
||||
/// Фильтрует список чатов по критериям
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `chats` - Исходный список чатов
|
||||
/// * `criteria` - Критерии фильтрации
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Отфильтрованный список чатов (без клонирования, только references)
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// let criteria = ChatFilterCriteria::by_folder(Some(0))
|
||||
/// .with_search("John".to_string());
|
||||
///
|
||||
/// let filtered = ChatFilter::filter(&all_chats, &criteria);
|
||||
/// ```
|
||||
pub fn filter<'a>(chats: &'a [ChatInfo], criteria: &ChatFilterCriteria) -> Vec<&'a ChatInfo> {
|
||||
chats.iter().filter(|chat| criteria.matches(chat)).collect()
|
||||
}
|
||||
|
||||
/// Фильтрует чаты по папке
|
||||
///
|
||||
/// Упрощённая версия для наиболее частого случая.
|
||||
pub fn by_folder(chats: &[ChatInfo], folder_id: Option<i32>) -> Vec<&ChatInfo> {
|
||||
let criteria = ChatFilterCriteria::by_folder(folder_id);
|
||||
Self::filter(chats, &criteria)
|
||||
}
|
||||
|
||||
/// Фильтрует чаты по поисковому запросу
|
||||
///
|
||||
/// Упрощённая версия для поиска.
|
||||
pub fn by_search<'a>(chats: &'a [ChatInfo], query: &str) -> Vec<&'a ChatInfo> {
|
||||
if query.is_empty() {
|
||||
return chats.iter().collect();
|
||||
}
|
||||
|
||||
let criteria = ChatFilterCriteria::by_search(query.to_string());
|
||||
Self::filter(chats, &criteria)
|
||||
}
|
||||
|
||||
/// Подсчитывает чаты подходящие под критерии
|
||||
pub fn count(chats: &[ChatInfo], criteria: &ChatFilterCriteria) -> usize {
|
||||
chats.iter().filter(|chat| criteria.matches(chat)).count()
|
||||
}
|
||||
|
||||
/// Подсчитывает непрочитанные сообщения в отфильтрованных чатах
|
||||
pub fn count_unread(chats: &[ChatInfo], criteria: &ChatFilterCriteria) -> i32 {
|
||||
chats
|
||||
.iter()
|
||||
.filter(|chat| criteria.matches(chat))
|
||||
.map(|chat| chat.unread_count)
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// Подсчитывает непрочитанные упоминания в отфильтрованных чатах
|
||||
pub fn count_unread_mentions(chats: &[ChatInfo], criteria: &ChatFilterCriteria) -> i32 {
|
||||
chats
|
||||
.iter()
|
||||
.filter(|chat| criteria.matches(chat))
|
||||
.map(|chat| chat.unread_mention_count)
|
||||
.sum()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::types::ChatId;
|
||||
|
||||
fn create_test_chat(
|
||||
id: i64,
|
||||
title: &str,
|
||||
username: Option<&str>,
|
||||
folder_ids: Vec<i32>,
|
||||
unread: i32,
|
||||
mentions: i32,
|
||||
is_pinned: bool,
|
||||
is_muted: bool,
|
||||
) -> ChatInfo {
|
||||
use crate::types::MessageId;
|
||||
|
||||
ChatInfo {
|
||||
id: ChatId::new(id),
|
||||
title: title.to_string(),
|
||||
username: username.map(String::from),
|
||||
folder_ids,
|
||||
unread_count: unread,
|
||||
unread_mention_count: mentions,
|
||||
is_pinned,
|
||||
is_muted,
|
||||
last_message_date: 0,
|
||||
last_message: String::new(),
|
||||
order: 0,
|
||||
last_read_outbox_message_id: MessageId::new(0),
|
||||
draft_text: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filter_by_folder() {
|
||||
let chats = vec![
|
||||
create_test_chat(1, "Chat 1", None, vec![0], 0, 0, false, false),
|
||||
create_test_chat(2, "Chat 2", None, vec![1], 0, 0, false, false),
|
||||
create_test_chat(3, "Chat 3", None, vec![0, 1], 0, 0, false, false),
|
||||
];
|
||||
|
||||
let filtered = ChatFilter::by_folder(&chats, Some(0));
|
||||
assert_eq!(filtered.len(), 2); // Chat 1 and Chat 3
|
||||
assert_eq!(filtered[0].id.as_i64(), 1);
|
||||
assert_eq!(filtered[1].id.as_i64(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filter_by_search() {
|
||||
let chats = vec![
|
||||
create_test_chat(1, "John Doe", Some("johndoe"), vec![0], 0, 0, false, false),
|
||||
create_test_chat(2, "Jane Smith", Some("janesmith"), vec![0], 0, 0, false, false),
|
||||
create_test_chat(3, "Bob Johnson", None, vec![0], 0, 0, false, false),
|
||||
];
|
||||
|
||||
// Поиск по имени
|
||||
let filtered = ChatFilter::by_search(&chats, "john");
|
||||
assert_eq!(filtered.len(), 2); // John Doe and Bob Johnson
|
||||
|
||||
// Поиск по username
|
||||
let filtered = ChatFilter::by_search(&chats, "smith");
|
||||
assert_eq!(filtered.len(), 1);
|
||||
assert_eq!(filtered[0].title, "Jane Smith");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filter_criteria_builder() {
|
||||
let chats = vec![
|
||||
create_test_chat(1, "Chat 1", None, vec![0], 5, 0, true, false),
|
||||
create_test_chat(2, "Chat 2", None, vec![0], 0, 0, false, false),
|
||||
create_test_chat(3, "Chat 3", None, vec![0], 10, 2, false, false),
|
||||
];
|
||||
|
||||
let criteria = ChatFilterCriteria::new()
|
||||
.with_folder(Some(0))
|
||||
.unread_only(true)
|
||||
.pinned_only(false);
|
||||
|
||||
let filtered = ChatFilter::filter(&chats, &criteria);
|
||||
assert_eq!(filtered.len(), 2); // Chat 1 and Chat 3 have unread
|
||||
|
||||
let criteria = ChatFilterCriteria::new().pinned_only(true);
|
||||
|
||||
let filtered = ChatFilter::filter(&chats, &criteria);
|
||||
assert_eq!(filtered.len(), 1); // Only Chat 1 is pinned
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_count_methods() {
|
||||
let chats = vec![
|
||||
create_test_chat(1, "Chat 1", None, vec![0], 5, 1, false, false),
|
||||
create_test_chat(2, "Chat 2", None, vec![0], 10, 2, false, false),
|
||||
create_test_chat(3, "Chat 3", None, vec![1], 3, 0, false, false),
|
||||
];
|
||||
|
||||
let criteria = ChatFilterCriteria::by_folder(Some(0));
|
||||
|
||||
assert_eq!(ChatFilter::count(&chats, &criteria), 2);
|
||||
assert_eq!(ChatFilter::count_unread(&chats, &criteria), 15); // 5 + 10
|
||||
assert_eq!(ChatFilter::count_unread_mentions(&chats, &criteria), 3); // 1 + 2
|
||||
}
|
||||
}
|
||||
195
src/app/chat_list_state.rs
Normal file
195
src/app/chat_list_state.rs
Normal file
@@ -0,0 +1,195 @@
|
||||
/// Состояние списка чатов
|
||||
///
|
||||
/// Отвечает за:
|
||||
/// - Список чатов
|
||||
/// - Выбранный чат в списке
|
||||
/// - Фильтрацию по папкам
|
||||
/// - Поиск чатов
|
||||
|
||||
use crate::app::chat_filter::{ChatFilter, ChatFilterCriteria};
|
||||
use crate::tdlib::ChatInfo;
|
||||
use ratatui::widgets::ListState;
|
||||
|
||||
/// Состояние списка чатов
|
||||
#[derive(Debug)]
|
||||
pub struct ChatListState {
|
||||
/// Список всех чатов
|
||||
pub chats: Vec<ChatInfo>,
|
||||
|
||||
/// Состояние виджета списка (выбранный индекс)
|
||||
pub list_state: ListState,
|
||||
|
||||
/// Выбранная папка (None = All, Some(id) = конкретная папка)
|
||||
pub selected_folder_id: Option<i32>,
|
||||
|
||||
/// Флаг режима поиска чатов
|
||||
pub is_searching: bool,
|
||||
|
||||
/// Поисковый запрос для фильтрации чатов
|
||||
pub search_query: String,
|
||||
}
|
||||
|
||||
impl Default for ChatListState {
|
||||
fn default() -> Self {
|
||||
let mut state = ListState::default();
|
||||
state.select(Some(0));
|
||||
|
||||
Self {
|
||||
chats: Vec::new(),
|
||||
list_state: state,
|
||||
selected_folder_id: None,
|
||||
is_searching: false,
|
||||
search_query: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ChatListState {
|
||||
/// Создать новое состояние списка чатов
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
// === Chats ===
|
||||
|
||||
pub fn chats(&self) -> &[ChatInfo] {
|
||||
&self.chats
|
||||
}
|
||||
|
||||
pub fn chats_mut(&mut self) -> &mut Vec<ChatInfo> {
|
||||
&mut self.chats
|
||||
}
|
||||
|
||||
pub fn set_chats(&mut self, chats: Vec<ChatInfo>) {
|
||||
self.chats = chats;
|
||||
}
|
||||
|
||||
pub fn add_chat(&mut self, chat: ChatInfo) {
|
||||
self.chats.push(chat);
|
||||
}
|
||||
|
||||
pub fn clear_chats(&mut self) {
|
||||
self.chats.clear();
|
||||
}
|
||||
|
||||
// === List state (selection) ===
|
||||
|
||||
pub fn list_state(&self) -> &ListState {
|
||||
&self.list_state
|
||||
}
|
||||
|
||||
pub fn list_state_mut(&mut self) -> &mut ListState {
|
||||
&mut self.list_state
|
||||
}
|
||||
|
||||
pub fn selected_index(&self) -> Option<usize> {
|
||||
self.list_state.selected()
|
||||
}
|
||||
|
||||
pub fn select(&mut self, index: Option<usize>) {
|
||||
self.list_state.select(index);
|
||||
}
|
||||
|
||||
// === Folder ===
|
||||
|
||||
pub fn selected_folder_id(&self) -> Option<i32> {
|
||||
self.selected_folder_id
|
||||
}
|
||||
|
||||
pub fn set_selected_folder_id(&mut self, id: Option<i32>) {
|
||||
self.selected_folder_id = id;
|
||||
}
|
||||
|
||||
// === Search ===
|
||||
|
||||
pub fn is_searching(&self) -> bool {
|
||||
self.is_searching
|
||||
}
|
||||
|
||||
pub fn set_searching(&mut self, searching: bool) {
|
||||
self.is_searching = searching;
|
||||
}
|
||||
|
||||
pub fn search_query(&self) -> &str {
|
||||
&self.search_query
|
||||
}
|
||||
|
||||
pub fn search_query_mut(&mut self) -> &mut String {
|
||||
&mut self.search_query
|
||||
}
|
||||
|
||||
pub fn set_search_query(&mut self, query: String) {
|
||||
self.search_query = query;
|
||||
}
|
||||
|
||||
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.list_state.select(Some(0));
|
||||
}
|
||||
|
||||
// === Navigation ===
|
||||
|
||||
/// Получить отфильтрованный список чатов
|
||||
pub fn get_filtered_chats(&self) -> Vec<&ChatInfo> {
|
||||
// Используем ChatFilter для централизованной фильтрации
|
||||
let mut criteria = ChatFilterCriteria::new().with_folder(self.selected_folder_id);
|
||||
|
||||
if !self.search_query.is_empty() {
|
||||
criteria = criteria.with_search(self.search_query.clone());
|
||||
}
|
||||
|
||||
ChatFilter::filter(&self.chats, &criteria)
|
||||
}
|
||||
|
||||
/// Выбрать следующий чат
|
||||
pub fn next_chat(&mut self) {
|
||||
let filtered = self.get_filtered_chats();
|
||||
if filtered.is_empty() {
|
||||
return;
|
||||
}
|
||||
let i = match self.list_state.selected() {
|
||||
Some(i) => {
|
||||
if i >= filtered.len() - 1 {
|
||||
0
|
||||
} else {
|
||||
i + 1
|
||||
}
|
||||
}
|
||||
None => 0,
|
||||
};
|
||||
self.list_state.select(Some(i));
|
||||
}
|
||||
|
||||
/// Выбрать предыдущий чат
|
||||
pub fn previous_chat(&mut self) {
|
||||
let filtered = self.get_filtered_chats();
|
||||
if filtered.is_empty() {
|
||||
return;
|
||||
}
|
||||
let i = match self.list_state.selected() {
|
||||
Some(i) => {
|
||||
if i == 0 {
|
||||
filtered.len() - 1
|
||||
} else {
|
||||
i - 1
|
||||
}
|
||||
}
|
||||
None => 0,
|
||||
};
|
||||
self.list_state.select(Some(i));
|
||||
}
|
||||
|
||||
/// Получить выбранный в данный момент чат
|
||||
pub fn get_selected_chat(&self) -> Option<&ChatInfo> {
|
||||
let filtered = self.get_filtered_chats();
|
||||
self.list_state
|
||||
.selected()
|
||||
.and_then(|i| filtered.get(i).copied())
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,21 @@
|
||||
use crate::tdlib::{MessageInfo, ProfileInfo};
|
||||
use crate::types::MessageId;
|
||||
|
||||
/// Vim-like input mode for chat view
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum InputMode {
|
||||
/// Normal mode — navigation and commands (default)
|
||||
#[default]
|
||||
Normal,
|
||||
/// Insert mode — text input only
|
||||
Insert,
|
||||
}
|
||||
|
||||
/// Состояния чата - взаимоисключающие режимы работы с чатом
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub enum ChatState {
|
||||
/// Обычный режим - просмотр сообщений, набор текста
|
||||
#[default]
|
||||
Normal,
|
||||
|
||||
/// Выбор сообщения для действия (edit/delete/reply/forward/reaction)
|
||||
@@ -33,8 +44,6 @@ pub enum ChatState {
|
||||
Forward {
|
||||
/// ID сообщения для пересылки
|
||||
message_id: MessageId,
|
||||
/// Находимся в режиме выбора чата для пересылки
|
||||
selecting_chat: bool,
|
||||
},
|
||||
|
||||
/// Подтверждение удаления сообщения
|
||||
@@ -82,12 +91,6 @@ pub enum ChatState {
|
||||
},
|
||||
}
|
||||
|
||||
impl Default for ChatState {
|
||||
fn default() -> Self {
|
||||
ChatState::Normal
|
||||
}
|
||||
}
|
||||
|
||||
impl ChatState {
|
||||
/// Проверка: находимся в режиме выбора сообщения
|
||||
pub fn is_message_selection(&self) -> bool {
|
||||
@@ -134,11 +137,6 @@ impl ChatState {
|
||||
matches!(self, ChatState::PinnedMessages { .. })
|
||||
}
|
||||
|
||||
/// Проверка: находимся в обычном режиме
|
||||
pub fn is_normal(&self) -> bool {
|
||||
matches!(self, ChatState::Normal)
|
||||
}
|
||||
|
||||
/// Возвращает ID выбранного сообщения (если есть)
|
||||
pub fn selected_message_id(&self) -> Option<MessageId> {
|
||||
match self {
|
||||
|
||||
247
src/app/compose_state.rs
Normal file
247
src/app/compose_state.rs
Normal file
@@ -0,0 +1,247 @@
|
||||
/// Состояние написания сообщения
|
||||
///
|
||||
/// Отвечает за:
|
||||
/// - Текст сообщения
|
||||
/// - Позицию курсора
|
||||
/// - Typing indicator
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
/// Состояние написания сообщения
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ComposeState {
|
||||
/// Текст вводимого сообщения
|
||||
pub message_input: String,
|
||||
|
||||
/// Позиция курсора в message_input (в символах, не байтах)
|
||||
pub cursor_position: usize,
|
||||
|
||||
/// Время последней отправки typing status (для throttling)
|
||||
pub last_typing_sent: Option<Instant>,
|
||||
}
|
||||
|
||||
impl Default for ComposeState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
message_input: String::new(),
|
||||
cursor_position: 0,
|
||||
last_typing_sent: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ComposeState {
|
||||
/// Создать новое состояние написания сообщения
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
// === Message input ===
|
||||
|
||||
pub fn message_input(&self) -> &str {
|
||||
&self.message_input
|
||||
}
|
||||
|
||||
pub fn message_input_mut(&mut self) -> &mut String {
|
||||
&mut self.message_input
|
||||
}
|
||||
|
||||
pub fn set_message_input(&mut self, input: String) {
|
||||
self.message_input = input;
|
||||
self.cursor_position = self.message_input.chars().count();
|
||||
}
|
||||
|
||||
pub fn clear_message_input(&mut self) {
|
||||
self.message_input.clear();
|
||||
self.cursor_position = 0;
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.message_input.is_empty()
|
||||
}
|
||||
|
||||
// === Cursor position ===
|
||||
|
||||
pub fn cursor_position(&self) -> usize {
|
||||
self.cursor_position
|
||||
}
|
||||
|
||||
pub fn set_cursor_position(&mut self, pos: usize) {
|
||||
let max_pos = self.message_input.chars().count();
|
||||
self.cursor_position = pos.min(max_pos);
|
||||
}
|
||||
|
||||
pub fn move_cursor_left(&mut self) {
|
||||
if self.cursor_position > 0 {
|
||||
self.cursor_position -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn move_cursor_right(&mut self) {
|
||||
let max_pos = self.message_input.chars().count();
|
||||
if self.cursor_position < max_pos {
|
||||
self.cursor_position += 1;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn move_cursor_to_start(&mut self) {
|
||||
self.cursor_position = 0;
|
||||
}
|
||||
|
||||
pub fn move_cursor_to_end(&mut self) {
|
||||
self.cursor_position = self.message_input.chars().count();
|
||||
}
|
||||
|
||||
// === Typing indicator ===
|
||||
|
||||
pub fn last_typing_sent(&self) -> Option<Instant> {
|
||||
self.last_typing_sent
|
||||
}
|
||||
|
||||
pub fn set_last_typing_sent(&mut self, time: Option<Instant>) {
|
||||
self.last_typing_sent = time;
|
||||
}
|
||||
|
||||
pub fn update_last_typing_sent(&mut self) {
|
||||
self.last_typing_sent = Some(Instant::now());
|
||||
}
|
||||
|
||||
pub fn clear_typing_indicator(&mut self) {
|
||||
self.last_typing_sent = None;
|
||||
}
|
||||
|
||||
/// Проверить, нужно ли отправить typing indicator
|
||||
/// (если прошло больше 5 секунд с последней отправки)
|
||||
pub fn should_send_typing(&self) -> bool {
|
||||
match self.last_typing_sent {
|
||||
None => true,
|
||||
Some(last) => last.elapsed().as_secs() >= 5,
|
||||
}
|
||||
}
|
||||
|
||||
// === Text editing ===
|
||||
|
||||
/// Вставить символ в текущую позицию курсора
|
||||
pub fn insert_char(&mut self, c: char) {
|
||||
let char_indices: Vec<usize> = self.message_input.char_indices().map(|(i, _)| i).collect();
|
||||
|
||||
let byte_pos = if self.cursor_position >= char_indices.len() {
|
||||
self.message_input.len()
|
||||
} else {
|
||||
char_indices[self.cursor_position]
|
||||
};
|
||||
|
||||
self.message_input.insert(byte_pos, c);
|
||||
self.cursor_position += 1;
|
||||
}
|
||||
|
||||
/// Удалить символ перед курсором (Backspace)
|
||||
pub fn delete_char_before_cursor(&mut self) {
|
||||
if self.cursor_position > 0 {
|
||||
let char_indices: Vec<usize> = self.message_input.char_indices().map(|(i, _)| i).collect();
|
||||
let byte_pos = char_indices[self.cursor_position - 1];
|
||||
self.message_input.remove(byte_pos);
|
||||
self.cursor_position -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Удалить символ после курсора (Delete)
|
||||
pub fn delete_char_after_cursor(&mut self) {
|
||||
let char_indices: Vec<usize> = self.message_input.char_indices().map(|(i, _)| i).collect();
|
||||
|
||||
if self.cursor_position < char_indices.len() {
|
||||
let byte_pos = char_indices[self.cursor_position];
|
||||
self.message_input.remove(byte_pos);
|
||||
}
|
||||
}
|
||||
|
||||
/// Удалить слово перед курсором (Ctrl+Backspace)
|
||||
pub fn delete_word_before_cursor(&mut self) {
|
||||
if self.cursor_position == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let chars: Vec<char> = self.message_input.chars().collect();
|
||||
let mut pos = self.cursor_position;
|
||||
|
||||
// Пропустить пробелы
|
||||
while pos > 0 && chars[pos - 1].is_whitespace() {
|
||||
pos -= 1;
|
||||
}
|
||||
|
||||
// Удалить символы слова
|
||||
while pos > 0 && !chars[pos - 1].is_whitespace() {
|
||||
pos -= 1;
|
||||
}
|
||||
|
||||
let removed_count = self.cursor_position - pos;
|
||||
if removed_count > 0 {
|
||||
let char_indices: Vec<usize> = self.message_input.char_indices().map(|(i, _)| i).collect();
|
||||
let start_byte = char_indices[pos];
|
||||
let end_byte = if self.cursor_position >= char_indices.len() {
|
||||
self.message_input.len()
|
||||
} else {
|
||||
char_indices[self.cursor_position]
|
||||
};
|
||||
|
||||
self.message_input.drain(start_byte..end_byte);
|
||||
self.cursor_position = pos;
|
||||
}
|
||||
}
|
||||
|
||||
/// Очистить всё и сбросить состояние
|
||||
pub fn reset(&mut self) {
|
||||
self.message_input.clear();
|
||||
self.cursor_position = 0;
|
||||
self.last_typing_sent = None;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_insert_char() {
|
||||
let mut state = ComposeState::new();
|
||||
state.insert_char('H');
|
||||
state.insert_char('i');
|
||||
assert_eq!(state.message_input(), "Hi");
|
||||
assert_eq!(state.cursor_position(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_char_before_cursor() {
|
||||
let mut state = ComposeState::new();
|
||||
state.set_message_input("Hello".to_string());
|
||||
state.delete_char_before_cursor();
|
||||
assert_eq!(state.message_input(), "Hell");
|
||||
assert_eq!(state.cursor_position(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cursor_movement() {
|
||||
let mut state = ComposeState::new();
|
||||
state.set_message_input("Hello".to_string());
|
||||
|
||||
state.move_cursor_to_start();
|
||||
assert_eq!(state.cursor_position(), 0);
|
||||
|
||||
state.move_cursor_right();
|
||||
assert_eq!(state.cursor_position(), 1);
|
||||
|
||||
state.move_cursor_to_end();
|
||||
assert_eq!(state.cursor_position(), 5);
|
||||
|
||||
state.move_cursor_left();
|
||||
assert_eq!(state.cursor_position(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_word() {
|
||||
let mut state = ComposeState::new();
|
||||
state.set_message_input("Hello World".to_string());
|
||||
state.delete_word_before_cursor();
|
||||
assert_eq!(state.message_input(), "Hello ");
|
||||
}
|
||||
}
|
||||
512
src/app/message_service.rs
Normal file
512
src/app/message_service.rs
Normal file
@@ -0,0 +1,512 @@
|
||||
/// Модуль для бизнес-логики работы с сообщениями
|
||||
///
|
||||
/// Чёткое разделение ответственности:
|
||||
/// - `tdlib/messages.rs` - только получение и преобразование из TDLib
|
||||
/// - `app/message_service.rs` (этот модуль) - бизнес-логика и операции
|
||||
/// - `ui/messages.rs` - только рендеринг
|
||||
///
|
||||
/// Этот модуль отвечает за:
|
||||
/// - Группировку сообщений по дате и отправителю
|
||||
/// - Фильтрацию сообщений
|
||||
/// - Поиск внутри сообщений
|
||||
/// - Навигацию по сообщениям
|
||||
/// - Операции над сообщениями (edit, delete, reply и т.д.)
|
||||
|
||||
use crate::tdlib::MessageInfo;
|
||||
use crate::types::MessageId;
|
||||
use chrono::{DateTime, Local};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Группа сообщений по дате
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MessageGroup {
|
||||
/// Дата группы (отображаемая строка, например "Сегодня", "Вчера", "1 января")
|
||||
pub date_label: String,
|
||||
|
||||
/// Сообщения в этой группе (отсортированы по времени)
|
||||
pub messages: Vec<MessageId>,
|
||||
}
|
||||
|
||||
/// Подгруппа сообщений от одного отправителя
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SenderGroup {
|
||||
/// ID первого сообщения в группе
|
||||
pub first_message_id: MessageId,
|
||||
|
||||
/// Имя отправителя
|
||||
pub sender_name: String,
|
||||
|
||||
/// Список ID сообщений от этого отправителя подряд
|
||||
pub message_ids: Vec<MessageId>,
|
||||
}
|
||||
|
||||
/// Результат поиска сообщений
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MessageSearchResult {
|
||||
/// ID сообщения
|
||||
pub message_id: MessageId,
|
||||
|
||||
/// Позиция в списке сообщений
|
||||
pub index: usize,
|
||||
|
||||
/// Фрагмент текста с совпадением
|
||||
pub snippet: String,
|
||||
|
||||
/// Позиция совпадения в тексте
|
||||
pub match_position: usize,
|
||||
}
|
||||
|
||||
/// Сервис для работы с сообщениями
|
||||
pub struct MessageService;
|
||||
|
||||
impl MessageService {
|
||||
/// Группирует сообщения по дате
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `messages` - Список сообщений (должен быть отсортирован по времени)
|
||||
/// * `timezone_offset` - Смещение часового пояса в секундах
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Список групп сообщений по датам
|
||||
pub fn group_by_date(
|
||||
messages: &[MessageInfo],
|
||||
timezone_offset: i32,
|
||||
) -> Vec<MessageGroup> {
|
||||
let mut groups: Vec<MessageGroup> = Vec::new();
|
||||
let mut current_date: Option<String> = None;
|
||||
let mut current_messages: Vec<MessageId> = Vec::new();
|
||||
|
||||
for msg in messages {
|
||||
let date_label = Self::get_date_label(msg.date(), timezone_offset);
|
||||
|
||||
if current_date.as_ref() != Some(&date_label) {
|
||||
// Начинается новая дата - сохраняем предыдущую группу
|
||||
if let Some(date) = current_date {
|
||||
groups.push(MessageGroup {
|
||||
date_label: date,
|
||||
messages: current_messages.clone(),
|
||||
});
|
||||
current_messages.clear();
|
||||
}
|
||||
current_date = Some(date_label);
|
||||
}
|
||||
|
||||
current_messages.push(msg.id());
|
||||
}
|
||||
|
||||
// Добавляем последнюю группу
|
||||
if let Some(date) = current_date {
|
||||
groups.push(MessageGroup {
|
||||
date_label: date,
|
||||
messages: current_messages,
|
||||
});
|
||||
}
|
||||
|
||||
groups
|
||||
}
|
||||
|
||||
/// Группирует сообщения по отправителю внутри одной даты
|
||||
///
|
||||
/// Последовательные сообщения от одного отправителя объединяются в группу.
|
||||
pub fn group_by_sender(messages: &[MessageInfo]) -> Vec<SenderGroup> {
|
||||
let mut groups: Vec<SenderGroup> = Vec::new();
|
||||
let mut current_sender: Option<String> = None;
|
||||
let mut current_ids: Vec<MessageId> = Vec::new();
|
||||
let mut first_id: Option<MessageId> = None;
|
||||
|
||||
for msg in messages {
|
||||
let sender = msg.sender_name().to_string();
|
||||
|
||||
if current_sender.as_ref() != Some(&sender) {
|
||||
// Новый отправитель - сохраняем предыдущую группу
|
||||
if let (Some(name), Some(first)) = (current_sender, first_id) {
|
||||
groups.push(SenderGroup {
|
||||
first_message_id: first,
|
||||
sender_name: name,
|
||||
message_ids: current_ids.clone(),
|
||||
});
|
||||
current_ids.clear();
|
||||
}
|
||||
current_sender = Some(sender);
|
||||
first_id = Some(msg.id());
|
||||
}
|
||||
|
||||
current_ids.push(msg.id());
|
||||
}
|
||||
|
||||
// Добавляем последнюю группу
|
||||
if let (Some(name), Some(first)) = (current_sender, first_id) {
|
||||
groups.push(SenderGroup {
|
||||
first_message_id: first,
|
||||
sender_name: name,
|
||||
message_ids: current_ids,
|
||||
});
|
||||
}
|
||||
|
||||
groups
|
||||
}
|
||||
|
||||
/// Получает человекочитаемую метку даты
|
||||
///
|
||||
/// Возвращает "Сегодня", "Вчера" или дату в формате "1 января 2024"
|
||||
fn get_date_label(timestamp: i32, _timezone_offset: i32) -> String {
|
||||
let dt = DateTime::from_timestamp(timestamp as i64, 0)
|
||||
.map(|dt| dt.with_timezone(&Local))
|
||||
.unwrap_or_else(|| Local::now());
|
||||
|
||||
let msg_date = dt.date_naive();
|
||||
let today = Local::now().date_naive();
|
||||
let yesterday = today.pred_opt().unwrap_or(today);
|
||||
|
||||
if msg_date == today {
|
||||
"Сегодня".to_string()
|
||||
} else if msg_date == yesterday {
|
||||
"Вчера".to_string()
|
||||
} else {
|
||||
msg_date.format("%d %B %Y").to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Ищет сообщения по текстовому запросу
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `messages` - Список сообщений для поиска
|
||||
/// * `query` - Поисковый запрос (case-insensitive)
|
||||
/// * `max_results` - Максимальное количество результатов (0 = без ограничений)
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Список результатов поиска с контекстом
|
||||
pub fn search(
|
||||
messages: &[MessageInfo],
|
||||
query: &str,
|
||||
max_results: usize,
|
||||
) -> Vec<MessageSearchResult> {
|
||||
if query.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let query_lower = query.to_lowercase();
|
||||
let mut results = Vec::new();
|
||||
|
||||
for (index, msg) in messages.iter().enumerate() {
|
||||
let text = msg.text().to_lowercase();
|
||||
|
||||
if let Some(pos) = text.find(&query_lower) {
|
||||
// Создаём snippet с контекстом
|
||||
let start = pos.saturating_sub(20);
|
||||
let end = (pos + query.len() + 20).min(text.len());
|
||||
let snippet = msg.text()[start..end].to_string();
|
||||
|
||||
results.push(MessageSearchResult {
|
||||
message_id: msg.id(),
|
||||
index,
|
||||
snippet,
|
||||
match_position: pos,
|
||||
});
|
||||
|
||||
if max_results > 0 && results.len() >= max_results {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results
|
||||
}
|
||||
|
||||
/// Находит следующее сообщение по запросу
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `messages` - Список сообщений
|
||||
/// * `current_index` - Текущая позиция
|
||||
/// * `query` - Поисковый запрос
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Индекс следующего найденного сообщения или None
|
||||
pub fn find_next(
|
||||
messages: &[MessageInfo],
|
||||
current_index: usize,
|
||||
query: &str,
|
||||
) -> Option<usize> {
|
||||
if query.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let query_lower = query.to_lowercase();
|
||||
|
||||
for (index, msg) in messages.iter().enumerate().skip(current_index + 1) {
|
||||
if msg.text().to_lowercase().contains(&query_lower) {
|
||||
return Some(index);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Находит предыдущее сообщение по запросу
|
||||
pub fn find_previous(
|
||||
messages: &[MessageInfo],
|
||||
current_index: usize,
|
||||
query: &str,
|
||||
) -> Option<usize> {
|
||||
if query.is_empty() || current_index == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let query_lower = query.to_lowercase();
|
||||
|
||||
for (index, msg) in messages.iter().enumerate().take(current_index).rev() {
|
||||
if msg.text().to_lowercase().contains(&query_lower) {
|
||||
return Some(index);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Фильтрует сообщения по отправителю
|
||||
pub fn filter_by_sender<'a>(
|
||||
messages: &'a [MessageInfo],
|
||||
sender_name: &str,
|
||||
) -> Vec<&'a MessageInfo> {
|
||||
messages
|
||||
.iter()
|
||||
.filter(|msg| msg.sender_name() == sender_name)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Фильтрует только непрочитанные сообщения
|
||||
pub fn filter_unread<'a>(
|
||||
messages: &'a [MessageInfo],
|
||||
last_read_id: MessageId,
|
||||
) -> Vec<&'a MessageInfo> {
|
||||
messages
|
||||
.iter()
|
||||
.filter(|msg| msg.id().as_i64() > last_read_id.as_i64())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Находит сообщение по ID
|
||||
pub fn find_by_id<'a>(
|
||||
messages: &'a [MessageInfo],
|
||||
id: MessageId,
|
||||
) -> Option<&'a MessageInfo> {
|
||||
messages.iter().find(|msg| msg.id() == id)
|
||||
}
|
||||
|
||||
/// Находит индекс сообщения по ID
|
||||
pub fn find_index_by_id(
|
||||
messages: &[MessageInfo],
|
||||
id: MessageId,
|
||||
) -> Option<usize> {
|
||||
messages.iter().position(|msg| msg.id() == id)
|
||||
}
|
||||
|
||||
/// Получает N последних сообщений
|
||||
pub fn get_last_n<'a>(
|
||||
messages: &'a [MessageInfo],
|
||||
n: usize,
|
||||
) -> &'a [MessageInfo] {
|
||||
let start = messages.len().saturating_sub(n);
|
||||
&messages[start..]
|
||||
}
|
||||
|
||||
/// Получает сообщения в диапазоне дат
|
||||
pub fn get_in_date_range<'a>(
|
||||
messages: &'a [MessageInfo],
|
||||
start_date: i32,
|
||||
end_date: i32,
|
||||
) -> Vec<&'a MessageInfo> {
|
||||
messages
|
||||
.iter()
|
||||
.filter(|msg| {
|
||||
let date = msg.date();
|
||||
date >= start_date && date <= end_date
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Подсчитывает сообщения по типу отправителя
|
||||
pub fn count_by_sender_type(messages: &[MessageInfo]) -> (usize, usize) {
|
||||
let mut incoming = 0;
|
||||
let mut outgoing = 0;
|
||||
|
||||
for msg in messages {
|
||||
if msg.is_outgoing() {
|
||||
outgoing += 1;
|
||||
} else {
|
||||
incoming += 1;
|
||||
}
|
||||
}
|
||||
|
||||
(incoming, outgoing)
|
||||
}
|
||||
|
||||
/// Создаёт индекс сообщений по ID для быстрого доступа
|
||||
pub fn create_index(messages: &[MessageInfo]) -> HashMap<MessageId, usize> {
|
||||
messages
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, msg)| (msg.id(), index))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::tdlib::MessageInfo;
|
||||
use crate::types::MessageId;
|
||||
|
||||
fn create_test_message(
|
||||
id: i64,
|
||||
text: &str,
|
||||
sender: &str,
|
||||
date: i32,
|
||||
is_outgoing: bool,
|
||||
) -> MessageInfo {
|
||||
MessageInfo::new(
|
||||
MessageId::new(id),
|
||||
sender.to_string(),
|
||||
is_outgoing,
|
||||
text.to_string(),
|
||||
Vec::new(), // entities
|
||||
date,
|
||||
0, // edit_date
|
||||
true, // is_read
|
||||
is_outgoing, // can_be_edited only for outgoing
|
||||
true, // can_be_deleted_only_for_self
|
||||
is_outgoing, // can_be_deleted_for_all_users only for outgoing
|
||||
None, // reply_to
|
||||
None, // forward_from
|
||||
Vec::new(), // reactions
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_search() {
|
||||
let messages = vec![
|
||||
create_test_message(1, "Hello world", "Alice", 1000, false),
|
||||
create_test_message(2, "How are you?", "Bob", 1010, false),
|
||||
create_test_message(3, "Hello there", "Alice", 1020, false),
|
||||
];
|
||||
|
||||
let results = MessageService::search(&messages, "hello", 0);
|
||||
assert_eq!(results.len(), 2);
|
||||
assert_eq!(results[0].message_id.as_i64(), 1);
|
||||
assert_eq!(results[1].message_id.as_i64(), 3);
|
||||
|
||||
// Case-insensitive
|
||||
let results = MessageService::search(&messages, "HELLO", 0);
|
||||
assert_eq!(results.len(), 2);
|
||||
|
||||
// Max results
|
||||
let results = MessageService::search(&messages, "hello", 1);
|
||||
assert_eq!(results.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_next_previous() {
|
||||
let messages = vec![
|
||||
create_test_message(1, "test 1", "Alice", 1000, false),
|
||||
create_test_message(2, "message", "Bob", 1010, false),
|
||||
create_test_message(3, "test 2", "Alice", 1020, false),
|
||||
create_test_message(4, "test 3", "Bob", 1030, false),
|
||||
];
|
||||
|
||||
// Find next
|
||||
let next = MessageService::find_next(&messages, 0, "test");
|
||||
assert_eq!(next, Some(2));
|
||||
|
||||
let next = MessageService::find_next(&messages, 2, "test");
|
||||
assert_eq!(next, Some(3));
|
||||
|
||||
// Find previous
|
||||
let prev = MessageService::find_previous(&messages, 3, "test");
|
||||
assert_eq!(prev, Some(2));
|
||||
|
||||
let prev = MessageService::find_previous(&messages, 2, "test");
|
||||
assert_eq!(prev, Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filter_by_sender() {
|
||||
let messages = vec![
|
||||
create_test_message(1, "msg1", "Alice", 1000, false),
|
||||
create_test_message(2, "msg2", "Bob", 1010, false),
|
||||
create_test_message(3, "msg3", "Alice", 1020, false),
|
||||
];
|
||||
|
||||
let filtered = MessageService::filter_by_sender(&messages, "Alice");
|
||||
assert_eq!(filtered.len(), 2);
|
||||
assert_eq!(filtered[0].id().as_i64(), 1);
|
||||
assert_eq!(filtered[1].id().as_i64(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_by_id() {
|
||||
let messages = vec![
|
||||
create_test_message(1, "msg1", "Alice", 1000, false),
|
||||
create_test_message(2, "msg2", "Bob", 1010, false),
|
||||
];
|
||||
|
||||
let found = MessageService::find_by_id(&messages, MessageId::new(2));
|
||||
assert!(found.is_some());
|
||||
assert_eq!(found.unwrap().text(), "msg2");
|
||||
|
||||
let not_found = MessageService::find_by_id(&messages, MessageId::new(999));
|
||||
assert!(not_found.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_count_by_sender_type() {
|
||||
let messages = vec![
|
||||
create_test_message(1, "msg1", "Alice", 1000, false),
|
||||
create_test_message(2, "msg2", "Me", 1010, true),
|
||||
create_test_message(3, "msg3", "Bob", 1020, false),
|
||||
create_test_message(4, "msg4", "Me", 1030, true),
|
||||
];
|
||||
|
||||
let (incoming, outgoing) = MessageService::count_by_sender_type(&messages);
|
||||
assert_eq!(incoming, 2);
|
||||
assert_eq!(outgoing, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_last_n() {
|
||||
let messages = vec![
|
||||
create_test_message(1, "msg1", "Alice", 1000, false),
|
||||
create_test_message(2, "msg2", "Bob", 1010, false),
|
||||
create_test_message(3, "msg3", "Alice", 1020, false),
|
||||
];
|
||||
|
||||
let last_2 = MessageService::get_last_n(&messages, 2);
|
||||
assert_eq!(last_2.len(), 2);
|
||||
assert_eq!(last_2[0].id().as_i64(), 2);
|
||||
assert_eq!(last_2[1].id().as_i64(), 3);
|
||||
|
||||
// Request more than available
|
||||
let last_10 = MessageService::get_last_n(&messages, 10);
|
||||
assert_eq!(last_10.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_index() {
|
||||
let messages = vec![
|
||||
create_test_message(1, "msg1", "Alice", 1000, false),
|
||||
create_test_message(2, "msg2", "Bob", 1010, false),
|
||||
create_test_message(3, "msg3", "Alice", 1020, false),
|
||||
];
|
||||
|
||||
let index = MessageService::create_index(&messages);
|
||||
assert_eq!(index.len(), 3);
|
||||
assert_eq!(index.get(&MessageId::new(1)), Some(&0));
|
||||
assert_eq!(index.get(&MessageId::new(2)), Some(&1));
|
||||
assert_eq!(index.get(&MessageId::new(3)), Some(&2));
|
||||
}
|
||||
}
|
||||
277
src/app/message_view_state.rs
Normal file
277
src/app/message_view_state.rs
Normal file
@@ -0,0 +1,277 @@
|
||||
/// Состояние просмотра сообщений
|
||||
///
|
||||
/// Отвечает за:
|
||||
/// - Текущий открытый чат
|
||||
/// - Скроллинг сообщений
|
||||
/// - Состояние чата (редактирование, ответ, и т.д.)
|
||||
|
||||
use crate::app::ChatState;
|
||||
use crate::types::{ChatId, MessageId};
|
||||
|
||||
/// Состояние просмотра сообщений
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MessageViewState {
|
||||
/// ID текущего открытого чата
|
||||
pub selected_chat_id: Option<ChatId>,
|
||||
|
||||
/// Оффсет скроллинга для сообщений
|
||||
pub message_scroll_offset: usize,
|
||||
|
||||
/// Состояние чата (Normal, Editing, Reply, и т.д.)
|
||||
pub chat_state: ChatState,
|
||||
}
|
||||
|
||||
impl Default for MessageViewState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
selected_chat_id: None,
|
||||
message_scroll_offset: 0,
|
||||
chat_state: ChatState::Normal,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MessageViewState {
|
||||
/// Создать новое состояние просмотра сообщений
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
// === Selected chat ===
|
||||
|
||||
pub fn selected_chat_id(&self) -> Option<ChatId> {
|
||||
self.selected_chat_id
|
||||
}
|
||||
|
||||
pub fn set_selected_chat_id(&mut self, id: Option<ChatId>) {
|
||||
self.selected_chat_id = id;
|
||||
}
|
||||
|
||||
pub fn has_open_chat(&self) -> bool {
|
||||
self.selected_chat_id.is_some()
|
||||
}
|
||||
|
||||
pub fn close_chat(&mut self) {
|
||||
self.selected_chat_id = None;
|
||||
self.message_scroll_offset = 0;
|
||||
self.chat_state = ChatState::Normal;
|
||||
}
|
||||
|
||||
// === Scroll offset ===
|
||||
|
||||
pub fn message_scroll_offset(&self) -> usize {
|
||||
self.message_scroll_offset
|
||||
}
|
||||
|
||||
pub fn set_message_scroll_offset(&mut self, offset: usize) {
|
||||
self.message_scroll_offset = offset;
|
||||
}
|
||||
|
||||
pub fn reset_scroll(&mut self) {
|
||||
self.message_scroll_offset = 0;
|
||||
}
|
||||
|
||||
// === Chat state ===
|
||||
|
||||
pub fn chat_state(&self) -> &ChatState {
|
||||
&self.chat_state
|
||||
}
|
||||
|
||||
pub fn chat_state_mut(&mut self) -> &mut ChatState {
|
||||
&mut self.chat_state
|
||||
}
|
||||
|
||||
pub fn set_chat_state(&mut self, state: ChatState) {
|
||||
self.chat_state = state;
|
||||
}
|
||||
|
||||
pub fn reset_chat_state(&mut self) {
|
||||
self.chat_state = ChatState::Normal;
|
||||
}
|
||||
|
||||
// === Message selection ===
|
||||
|
||||
pub fn is_selecting_message(&self) -> bool {
|
||||
self.chat_state.is_message_selection()
|
||||
}
|
||||
|
||||
pub fn start_message_selection(&mut self, total_messages: usize) {
|
||||
if total_messages == 0 {
|
||||
return;
|
||||
}
|
||||
self.chat_state = ChatState::MessageSelection {
|
||||
selected_index: total_messages - 1,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn select_previous_message(&mut self) {
|
||||
if let ChatState::MessageSelection { selected_index } = &mut self.chat_state {
|
||||
if *selected_index > 0 {
|
||||
*selected_index -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn select_next_message(&mut self, total_messages: usize) {
|
||||
if total_messages == 0 {
|
||||
return;
|
||||
}
|
||||
if let ChatState::MessageSelection { selected_index } = &mut self.chat_state {
|
||||
if *selected_index < total_messages - 1 {
|
||||
*selected_index += 1;
|
||||
} else {
|
||||
self.chat_state = ChatState::Normal;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_selected_message_index(&self) -> Option<usize> {
|
||||
self.chat_state.selected_message_index()
|
||||
}
|
||||
|
||||
// === Editing ===
|
||||
|
||||
pub fn is_editing(&self) -> bool {
|
||||
self.chat_state.is_editing()
|
||||
}
|
||||
|
||||
pub fn start_editing(&mut self, message_id: MessageId, selected_index: usize) {
|
||||
self.chat_state = ChatState::Editing {
|
||||
message_id,
|
||||
selected_index,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn cancel_editing(&mut self) {
|
||||
self.chat_state = ChatState::Normal;
|
||||
}
|
||||
|
||||
pub fn get_editing_message_id(&self) -> Option<MessageId> {
|
||||
if let ChatState::Editing { message_id, .. } = &self.chat_state {
|
||||
Some(*message_id)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
// === Reply ===
|
||||
|
||||
pub fn is_replying(&self) -> bool {
|
||||
self.chat_state.is_reply()
|
||||
}
|
||||
|
||||
pub fn start_reply(&mut self, message_id: MessageId) {
|
||||
self.chat_state = ChatState::Reply { message_id };
|
||||
}
|
||||
|
||||
pub fn cancel_reply(&mut self) {
|
||||
self.chat_state = ChatState::Normal;
|
||||
}
|
||||
|
||||
pub fn get_replying_to_message_id(&self) -> Option<MessageId> {
|
||||
if let ChatState::Reply { message_id } = &self.chat_state {
|
||||
Some(*message_id)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
// === Forward ===
|
||||
|
||||
pub fn is_forwarding(&self) -> bool {
|
||||
self.chat_state.is_forward()
|
||||
}
|
||||
|
||||
pub fn start_forward(&mut self, message_id: MessageId) {
|
||||
self.chat_state = ChatState::Forward {
|
||||
message_id,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn cancel_forward(&mut self) {
|
||||
self.chat_state = ChatState::Normal;
|
||||
}
|
||||
|
||||
// === Delete confirmation ===
|
||||
|
||||
pub fn is_confirm_delete_shown(&self) -> bool {
|
||||
self.chat_state.is_delete_confirmation()
|
||||
}
|
||||
|
||||
// === Pinned messages ===
|
||||
|
||||
pub fn is_pinned_mode(&self) -> bool {
|
||||
self.chat_state.is_pinned_mode()
|
||||
}
|
||||
|
||||
pub fn enter_pinned_mode(&mut self, messages: Vec<crate::tdlib::MessageInfo>) {
|
||||
if !messages.is_empty() {
|
||||
self.chat_state = ChatState::PinnedMessages {
|
||||
messages,
|
||||
selected_index: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
pub fn exit_pinned_mode(&mut self) {
|
||||
self.chat_state = ChatState::Normal;
|
||||
}
|
||||
|
||||
// === Search in chat ===
|
||||
|
||||
pub fn is_message_search_mode(&self) -> bool {
|
||||
self.chat_state.is_search_in_chat()
|
||||
}
|
||||
|
||||
pub fn enter_message_search_mode(&mut self) {
|
||||
self.chat_state = ChatState::SearchInChat {
|
||||
query: String::new(),
|
||||
results: Vec::new(),
|
||||
selected_index: 0,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn exit_message_search_mode(&mut self) {
|
||||
self.chat_state = ChatState::Normal;
|
||||
}
|
||||
|
||||
// === Profile ===
|
||||
|
||||
pub fn is_profile_mode(&self) -> bool {
|
||||
self.chat_state.is_profile()
|
||||
}
|
||||
|
||||
pub fn enter_profile_mode(&mut self, info: crate::tdlib::ProfileInfo) {
|
||||
self.chat_state = ChatState::Profile {
|
||||
info,
|
||||
selected_action: 0,
|
||||
leave_group_confirmation_step: 0,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn exit_profile_mode(&mut self) {
|
||||
self.chat_state = ChatState::Normal;
|
||||
}
|
||||
|
||||
// === Reaction picker ===
|
||||
|
||||
pub fn is_reaction_picker_mode(&self) -> bool {
|
||||
self.chat_state.is_reaction_picker()
|
||||
}
|
||||
|
||||
pub fn enter_reaction_picker_mode(
|
||||
&mut self,
|
||||
message_id: MessageId,
|
||||
available_reactions: Vec<String>,
|
||||
) {
|
||||
self.chat_state = ChatState::ReactionPicker {
|
||||
message_id,
|
||||
available_reactions,
|
||||
selected_index: 0,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn exit_reaction_picker_mode(&mut self) {
|
||||
self.chat_state = ChatState::Normal;
|
||||
}
|
||||
}
|
||||
117
src/app/methods/compose.rs
Normal file
117
src/app/methods/compose.rs
Normal file
@@ -0,0 +1,117 @@
|
||||
//! Compose methods for App
|
||||
//!
|
||||
//! Handles reply, forward, and draft functionality
|
||||
|
||||
use crate::app::methods::messages::MessageMethods;
|
||||
use crate::app::{App, ChatState};
|
||||
use crate::tdlib::{MessageInfo, TdClientTrait};
|
||||
|
||||
/// Compose methods for reply/forward/draft
|
||||
pub trait ComposeMethods<T: TdClientTrait> {
|
||||
/// Start replying to the selected message
|
||||
/// Returns true if reply mode started, false if no message selected
|
||||
fn start_reply_to_selected(&mut self) -> bool;
|
||||
|
||||
/// Cancel reply mode
|
||||
fn cancel_reply(&mut self);
|
||||
|
||||
/// Check if currently in reply mode
|
||||
fn is_replying(&self) -> bool;
|
||||
|
||||
/// Get the message being replied to
|
||||
fn get_replying_to_message(&self) -> Option<MessageInfo>;
|
||||
|
||||
/// Start forwarding the selected message
|
||||
/// Returns true if forward mode started, false if no message selected
|
||||
fn start_forward_selected(&mut self) -> bool;
|
||||
|
||||
/// Cancel forward mode
|
||||
fn cancel_forward(&mut self);
|
||||
|
||||
/// Check if currently in forward mode (selecting target chat)
|
||||
fn is_forwarding(&self) -> bool;
|
||||
|
||||
/// Get the message being forwarded
|
||||
fn get_forwarding_message(&self) -> Option<MessageInfo>;
|
||||
|
||||
/// Get draft for the currently selected chat
|
||||
fn get_current_draft(&self) -> Option<String>;
|
||||
|
||||
/// Load draft into message_input (called when opening chat)
|
||||
fn load_draft(&mut self);
|
||||
}
|
||||
|
||||
impl<T: TdClientTrait> ComposeMethods<T> for App<T> {
|
||||
fn start_reply_to_selected(&mut self) -> bool {
|
||||
if let Some(msg) = self.get_selected_message() {
|
||||
self.chat_state = ChatState::Reply { message_id: msg.id() };
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn cancel_reply(&mut self) {
|
||||
self.chat_state = ChatState::Normal;
|
||||
}
|
||||
|
||||
fn is_replying(&self) -> bool {
|
||||
self.chat_state.is_reply()
|
||||
}
|
||||
|
||||
fn get_replying_to_message(&self) -> Option<MessageInfo> {
|
||||
self.chat_state.selected_message_id().and_then(|id| {
|
||||
self.td_client
|
||||
.current_chat_messages()
|
||||
.iter()
|
||||
.find(|m| m.id() == id)
|
||||
.cloned()
|
||||
})
|
||||
}
|
||||
|
||||
fn start_forward_selected(&mut self) -> bool {
|
||||
if let Some(msg) = self.get_selected_message() {
|
||||
self.chat_state = ChatState::Forward { message_id: msg.id() };
|
||||
// Сбрасываем выбор чата на первый
|
||||
self.chat_list_state.select(Some(0));
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn cancel_forward(&mut self) {
|
||||
self.chat_state = ChatState::Normal;
|
||||
}
|
||||
|
||||
fn is_forwarding(&self) -> bool {
|
||||
self.chat_state.is_forward()
|
||||
}
|
||||
|
||||
fn get_forwarding_message(&self) -> Option<MessageInfo> {
|
||||
if !self.chat_state.is_forward() {
|
||||
return None;
|
||||
}
|
||||
self.chat_state.selected_message_id().and_then(|id| {
|
||||
self.td_client
|
||||
.current_chat_messages()
|
||||
.iter()
|
||||
.find(|m| m.id() == id)
|
||||
.cloned()
|
||||
})
|
||||
}
|
||||
|
||||
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())
|
||||
})
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
175
src/app/methods/messages.rs
Normal file
175
src/app/methods/messages.rs
Normal file
@@ -0,0 +1,175 @@
|
||||
//! Message methods for App
|
||||
//!
|
||||
//! Handles message selection, editing, and operations
|
||||
|
||||
use crate::app::{App, ChatState};
|
||||
use crate::tdlib::{MessageInfo, TdClientTrait};
|
||||
|
||||
/// Message operation methods
|
||||
pub trait MessageMethods<T: TdClientTrait> {
|
||||
/// Start message selection mode (triggered by Up arrow in empty input)
|
||||
fn start_message_selection(&mut self);
|
||||
|
||||
/// Select previous message (up in history = older)
|
||||
fn select_previous_message(&mut self);
|
||||
|
||||
/// Select next message (down in history = newer)
|
||||
fn select_next_message(&mut self);
|
||||
|
||||
/// Get currently selected message
|
||||
fn get_selected_message(&self) -> Option<MessageInfo>;
|
||||
|
||||
/// Start editing the selected message
|
||||
/// Returns true if editing started, false if message cannot be edited
|
||||
fn start_editing_selected(&mut self) -> bool;
|
||||
|
||||
/// Cancel message editing and clear input
|
||||
fn cancel_editing(&mut self);
|
||||
|
||||
/// Check if currently in editing mode
|
||||
fn is_editing(&self) -> bool;
|
||||
|
||||
/// Check if currently in message selection mode
|
||||
fn is_selecting_message(&self) -> bool;
|
||||
}
|
||||
|
||||
impl<T: TdClientTrait> MessageMethods<T> for App<T> {
|
||||
fn start_message_selection(&mut self) {
|
||||
let messages = self.td_client.current_chat_messages();
|
||||
let total = messages.len();
|
||||
if total == 0 {
|
||||
return;
|
||||
}
|
||||
// Начинаем с последнего сообщения (индекс len-1 = самое новое внизу)
|
||||
// Если оно часть альбома — перемещаемся к первому элементу альбома
|
||||
let mut idx = total - 1;
|
||||
let album_id = messages[idx].media_album_id();
|
||||
if album_id != 0 {
|
||||
while idx > 0 && messages[idx - 1].media_album_id() == album_id {
|
||||
idx -= 1;
|
||||
}
|
||||
}
|
||||
self.chat_state = ChatState::MessageSelection { selected_index: idx };
|
||||
}
|
||||
|
||||
fn select_previous_message(&mut self) {
|
||||
if let ChatState::MessageSelection { selected_index } = &mut self.chat_state {
|
||||
if *selected_index > 0 {
|
||||
let messages = self.td_client.current_chat_messages();
|
||||
let current_album_id = messages[*selected_index].media_album_id();
|
||||
|
||||
// Перескакиваем через все сообщения текущего альбома назад
|
||||
let mut new_index = *selected_index - 1;
|
||||
if current_album_id != 0 {
|
||||
while new_index > 0 && messages[new_index].media_album_id() == current_album_id
|
||||
{
|
||||
new_index -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Если попали в середину другого альбома — перемещаемся к его первому элементу
|
||||
let target_album_id = messages[new_index].media_album_id();
|
||||
if target_album_id != 0 {
|
||||
while new_index > 0
|
||||
&& messages[new_index - 1].media_album_id() == target_album_id
|
||||
{
|
||||
new_index -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
*selected_index = new_index;
|
||||
self.stop_playback();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn select_next_message(&mut self) {
|
||||
let total = self.td_client.current_chat_messages().len();
|
||||
if total == 0 {
|
||||
return;
|
||||
}
|
||||
if let ChatState::MessageSelection { selected_index } = &mut self.chat_state {
|
||||
if *selected_index < total - 1 {
|
||||
let messages = self.td_client.current_chat_messages();
|
||||
let current_album_id = messages[*selected_index].media_album_id();
|
||||
|
||||
// Перескакиваем через все сообщения текущего альбома вперёд
|
||||
let mut new_index = *selected_index + 1;
|
||||
if current_album_id != 0 {
|
||||
while new_index < total - 1
|
||||
&& messages[new_index].media_album_id() == current_album_id
|
||||
{
|
||||
new_index += 1;
|
||||
}
|
||||
// Если мы ещё на последнем элементе альбома — нужно шагнуть на следующее
|
||||
if messages[new_index].media_album_id() == current_album_id
|
||||
&& new_index < total - 1
|
||||
{
|
||||
new_index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if new_index < total {
|
||||
*selected_index = new_index;
|
||||
self.stop_playback();
|
||||
}
|
||||
// Если new_index >= total — остаёмся на текущем
|
||||
}
|
||||
// Если уже на последнем — ничего не делаем, остаёмся на месте
|
||||
}
|
||||
}
|
||||
|
||||
fn get_selected_message(&self) -> Option<MessageInfo> {
|
||||
self.chat_state
|
||||
.selected_message_index()
|
||||
.and_then(|idx| self.td_client.current_chat_messages().get(idx).cloned())
|
||||
}
|
||||
|
||||
fn start_editing_selected(&mut self) -> bool {
|
||||
// Получаем selected_index из текущего состояния
|
||||
let selected_idx = match &self.chat_state {
|
||||
ChatState::MessageSelection { selected_index } => Some(*selected_index),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
if selected_idx.is_none() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Сначала извлекаем данные из сообщения
|
||||
let msg_data = self.get_selected_message().and_then(|msg| {
|
||||
// Проверяем:
|
||||
// 1. Можно редактировать
|
||||
// 2. Это исходящее сообщение
|
||||
// 3. ID не временный (временные ID в TDLib отрицательные)
|
||||
if msg.can_be_edited() && msg.is_outgoing() && msg.id().as_i64() > 0 {
|
||||
Some((msg.id(), msg.text().to_string(), selected_idx.unwrap()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
// Затем присваиваем
|
||||
if let Some((id, content, idx)) = msg_data {
|
||||
self.cursor_position = content.chars().count();
|
||||
self.message_input = content;
|
||||
self.chat_state = ChatState::Editing { message_id: id, selected_index: idx };
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn cancel_editing(&mut self) {
|
||||
self.chat_state = ChatState::Normal;
|
||||
self.message_input.clear();
|
||||
self.cursor_position = 0;
|
||||
}
|
||||
|
||||
fn is_editing(&self) -> bool {
|
||||
self.chat_state.is_editing()
|
||||
}
|
||||
|
||||
fn is_selecting_message(&self) -> bool {
|
||||
self.chat_state.is_message_selection()
|
||||
}
|
||||
}
|
||||
25
src/app/methods/mod.rs
Normal file
25
src/app/methods/mod.rs
Normal file
@@ -0,0 +1,25 @@
|
||||
//! App methods organized by functionality
|
||||
//!
|
||||
//! This module contains traits that organize App methods into logical groups:
|
||||
//! - navigation: Chat list navigation
|
||||
//! - messages: Message operations and selection
|
||||
//! - compose: Reply/Forward/Draft functionality
|
||||
//! - search: Search in chats and messages
|
||||
//! - modal: Modal dialogs (Profile, Pinned, Reactions, Delete)
|
||||
|
||||
pub mod compose;
|
||||
pub mod messages;
|
||||
pub mod modal;
|
||||
pub mod navigation;
|
||||
pub mod search;
|
||||
|
||||
#[allow(unused_imports)]
|
||||
pub use compose::ComposeMethods;
|
||||
#[allow(unused_imports)]
|
||||
pub use messages::MessageMethods;
|
||||
#[allow(unused_imports)]
|
||||
pub use modal::ModalMethods;
|
||||
#[allow(unused_imports)]
|
||||
pub use navigation::NavigationMethods;
|
||||
#[allow(unused_imports)]
|
||||
pub use search::SearchMethods;
|
||||
266
src/app/methods/modal.rs
Normal file
266
src/app/methods/modal.rs
Normal file
@@ -0,0 +1,266 @@
|
||||
//! Modal methods for App
|
||||
//!
|
||||
//! Handles modal dialogs: Profile, Pinned Messages, Reactions, Delete Confirmation
|
||||
|
||||
use crate::app::{App, ChatState};
|
||||
use crate::tdlib::{MessageInfo, ProfileInfo, TdClientTrait};
|
||||
use crate::types::MessageId;
|
||||
|
||||
/// Modal dialog methods
|
||||
pub trait ModalMethods<T: TdClientTrait> {
|
||||
// === Delete Confirmation ===
|
||||
|
||||
/// Check if delete confirmation modal is shown
|
||||
fn is_confirm_delete_shown(&self) -> bool;
|
||||
|
||||
// === Pinned Messages ===
|
||||
|
||||
/// Check if in pinned messages mode
|
||||
fn is_pinned_mode(&self) -> bool;
|
||||
|
||||
/// Enter pinned messages mode
|
||||
fn enter_pinned_mode(&mut self, messages: Vec<MessageInfo>);
|
||||
|
||||
/// Exit pinned messages mode
|
||||
fn exit_pinned_mode(&mut self);
|
||||
|
||||
/// Select previous pinned message (up = older)
|
||||
fn select_previous_pinned(&mut self);
|
||||
|
||||
/// Select next pinned message (down = newer)
|
||||
fn select_next_pinned(&mut self);
|
||||
|
||||
/// Get currently selected pinned message
|
||||
fn get_selected_pinned(&self) -> Option<&MessageInfo>;
|
||||
|
||||
/// Get ID of selected pinned message for navigation
|
||||
fn get_selected_pinned_id(&self) -> Option<i64>;
|
||||
|
||||
// === Profile ===
|
||||
|
||||
/// Check if in profile mode
|
||||
fn is_profile_mode(&self) -> bool;
|
||||
|
||||
/// Enter profile mode
|
||||
fn enter_profile_mode(&mut self, info: ProfileInfo);
|
||||
|
||||
/// Exit profile mode
|
||||
fn exit_profile_mode(&mut self);
|
||||
|
||||
/// Select previous profile action
|
||||
fn select_previous_profile_action(&mut self);
|
||||
|
||||
/// Select next profile action
|
||||
fn select_next_profile_action(&mut self, max_actions: usize);
|
||||
|
||||
/// Show first leave group confirmation
|
||||
fn show_leave_group_confirmation(&mut self);
|
||||
|
||||
/// Show second leave group confirmation
|
||||
fn show_leave_group_final_confirmation(&mut self);
|
||||
|
||||
/// Cancel leave group confirmation
|
||||
fn cancel_leave_group(&mut self);
|
||||
|
||||
/// Get current leave group confirmation step (0, 1, or 2)
|
||||
fn get_leave_group_confirmation_step(&self) -> u8;
|
||||
|
||||
/// Get profile info
|
||||
fn get_profile_info(&self) -> Option<&ProfileInfo>;
|
||||
|
||||
/// Get selected profile action index
|
||||
fn get_selected_profile_action(&self) -> Option<usize>;
|
||||
|
||||
// === Reactions ===
|
||||
|
||||
/// Check if in reaction picker mode
|
||||
fn is_reaction_picker_mode(&self) -> bool;
|
||||
|
||||
/// Enter reaction picker mode
|
||||
fn enter_reaction_picker_mode(&mut self, message_id: i64, available_reactions: Vec<String>);
|
||||
|
||||
/// Exit reaction picker mode
|
||||
fn exit_reaction_picker_mode(&mut self);
|
||||
|
||||
/// Select previous reaction
|
||||
fn select_previous_reaction(&mut self);
|
||||
|
||||
/// Select next reaction
|
||||
fn select_next_reaction(&mut self);
|
||||
|
||||
/// Get currently selected reaction emoji
|
||||
fn get_selected_reaction(&self) -> Option<&String>;
|
||||
|
||||
/// Get message ID for which reaction is being selected
|
||||
fn get_selected_message_for_reaction(&self) -> Option<i64>;
|
||||
}
|
||||
|
||||
impl<T: TdClientTrait> ModalMethods<T> for App<T> {
|
||||
fn is_confirm_delete_shown(&self) -> bool {
|
||||
self.chat_state.is_delete_confirmation()
|
||||
}
|
||||
|
||||
fn is_pinned_mode(&self) -> bool {
|
||||
self.chat_state.is_pinned_mode()
|
||||
}
|
||||
|
||||
fn enter_pinned_mode(&mut self, messages: Vec<MessageInfo>) {
|
||||
if !messages.is_empty() {
|
||||
self.chat_state = ChatState::PinnedMessages { messages, selected_index: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
fn exit_pinned_mode(&mut self) {
|
||||
self.chat_state = ChatState::Normal;
|
||||
}
|
||||
|
||||
fn select_previous_pinned(&mut self) {
|
||||
if let ChatState::PinnedMessages { selected_index, messages } = &mut self.chat_state {
|
||||
if *selected_index + 1 < messages.len() {
|
||||
*selected_index += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn select_next_pinned(&mut self) {
|
||||
if let ChatState::PinnedMessages { selected_index, .. } = &mut self.chat_state {
|
||||
if *selected_index > 0 {
|
||||
*selected_index -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_selected_pinned(&self) -> Option<&MessageInfo> {
|
||||
if let ChatState::PinnedMessages { messages, selected_index } = &self.chat_state {
|
||||
messages.get(*selected_index)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn get_selected_pinned_id(&self) -> Option<i64> {
|
||||
self.get_selected_pinned().map(|m| m.id().as_i64())
|
||||
}
|
||||
|
||||
fn is_profile_mode(&self) -> bool {
|
||||
self.chat_state.is_profile()
|
||||
}
|
||||
|
||||
fn enter_profile_mode(&mut self, info: ProfileInfo) {
|
||||
self.chat_state = ChatState::Profile {
|
||||
info,
|
||||
selected_action: 0,
|
||||
leave_group_confirmation_step: 0,
|
||||
};
|
||||
}
|
||||
|
||||
fn exit_profile_mode(&mut self) {
|
||||
self.chat_state = ChatState::Normal;
|
||||
}
|
||||
|
||||
fn select_previous_profile_action(&mut self) {
|
||||
if let ChatState::Profile { selected_action, .. } = &mut self.chat_state {
|
||||
if *selected_action > 0 {
|
||||
*selected_action -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn select_next_profile_action(&mut self, max_actions: usize) {
|
||||
if let ChatState::Profile { selected_action, .. } = &mut self.chat_state {
|
||||
if *selected_action < max_actions.saturating_sub(1) {
|
||||
*selected_action += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn show_leave_group_confirmation(&mut self) {
|
||||
if let ChatState::Profile { leave_group_confirmation_step, .. } = &mut self.chat_state {
|
||||
*leave_group_confirmation_step = 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn show_leave_group_final_confirmation(&mut self) {
|
||||
if let ChatState::Profile { leave_group_confirmation_step, .. } = &mut self.chat_state {
|
||||
*leave_group_confirmation_step = 2;
|
||||
}
|
||||
}
|
||||
|
||||
fn cancel_leave_group(&mut self) {
|
||||
if let ChatState::Profile { leave_group_confirmation_step, .. } = &mut self.chat_state {
|
||||
*leave_group_confirmation_step = 0;
|
||||
}
|
||||
}
|
||||
|
||||
fn get_leave_group_confirmation_step(&self) -> u8 {
|
||||
if let ChatState::Profile { leave_group_confirmation_step, .. } = &self.chat_state {
|
||||
*leave_group_confirmation_step
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
fn get_profile_info(&self) -> Option<&ProfileInfo> {
|
||||
if let ChatState::Profile { info, .. } = &self.chat_state {
|
||||
Some(info)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn get_selected_profile_action(&self) -> Option<usize> {
|
||||
if let ChatState::Profile { selected_action, .. } = &self.chat_state {
|
||||
Some(*selected_action)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn is_reaction_picker_mode(&self) -> bool {
|
||||
self.chat_state.is_reaction_picker()
|
||||
}
|
||||
|
||||
fn enter_reaction_picker_mode(&mut self, message_id: i64, available_reactions: Vec<String>) {
|
||||
self.chat_state = ChatState::ReactionPicker {
|
||||
message_id: MessageId::new(message_id),
|
||||
available_reactions,
|
||||
selected_index: 0,
|
||||
};
|
||||
}
|
||||
|
||||
fn exit_reaction_picker_mode(&mut self) {
|
||||
self.chat_state = ChatState::Normal;
|
||||
}
|
||||
|
||||
fn select_previous_reaction(&mut self) {
|
||||
if let ChatState::ReactionPicker { selected_index, .. } = &mut self.chat_state {
|
||||
if *selected_index > 0 {
|
||||
*selected_index -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn select_next_reaction(&mut self) {
|
||||
if let ChatState::ReactionPicker { selected_index, available_reactions, .. } =
|
||||
&mut self.chat_state
|
||||
{
|
||||
if *selected_index + 1 < available_reactions.len() {
|
||||
*selected_index += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_selected_reaction(&self) -> Option<&String> {
|
||||
if let ChatState::ReactionPicker { available_reactions, selected_index, .. } =
|
||||
&self.chat_state
|
||||
{
|
||||
available_reactions.get(*selected_index)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn get_selected_message_for_reaction(&self) -> Option<i64> {
|
||||
self.chat_state.selected_message_id().map(|id| id.as_i64())
|
||||
}
|
||||
}
|
||||
147
src/app/methods/navigation.rs
Normal file
147
src/app/methods/navigation.rs
Normal file
@@ -0,0 +1,147 @@
|
||||
//! Navigation methods for App
|
||||
//!
|
||||
//! Handles chat list navigation and selection
|
||||
|
||||
use crate::app::methods::search::SearchMethods;
|
||||
use crate::app::{App, ChatState, InputMode};
|
||||
use crate::tdlib::TdClientTrait;
|
||||
|
||||
/// Navigation methods for chat list
|
||||
pub trait NavigationMethods<T: TdClientTrait> {
|
||||
/// Move to next chat in the list (wraps around)
|
||||
fn next_chat(&mut self);
|
||||
|
||||
/// Move to previous chat in the list (wraps around)
|
||||
fn previous_chat(&mut self);
|
||||
|
||||
/// Select currently highlighted chat
|
||||
fn select_current_chat(&mut self);
|
||||
|
||||
/// Close currently open chat and reset state
|
||||
fn close_chat(&mut self);
|
||||
|
||||
/// Move to next filtered chat (considering search query)
|
||||
fn next_filtered_chat(&mut self);
|
||||
|
||||
/// Move to previous filtered chat (considering search query)
|
||||
fn previous_filtered_chat(&mut self);
|
||||
|
||||
/// Select currently highlighted filtered chat
|
||||
fn select_filtered_chat(&mut self);
|
||||
}
|
||||
|
||||
impl<T: TdClientTrait> NavigationMethods<T> for App<T> {
|
||||
fn next_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));
|
||||
}
|
||||
|
||||
fn previous_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));
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn close_chat(&mut self) {
|
||||
self.selected_chat_id = None;
|
||||
self.message_input.clear();
|
||||
self.cursor_position = 0;
|
||||
self.message_scroll_offset = 0;
|
||||
self.last_typing_sent = None;
|
||||
self.pending_chat_init = None;
|
||||
// Останавливаем фоновую загрузку фото (drop receiver)
|
||||
#[cfg(feature = "images")]
|
||||
{
|
||||
self.photo_download_rx = None;
|
||||
self.pending_image_open = None;
|
||||
}
|
||||
// Сбрасываем состояние чата в нормальный режим
|
||||
self.chat_state = ChatState::Normal;
|
||||
self.input_mode = InputMode::Normal;
|
||||
// Очищаем данные в TdClient
|
||||
self.td_client.set_current_chat_id(None);
|
||||
self.td_client.clear_current_chat_messages();
|
||||
self.td_client.set_typing_status(None);
|
||||
self.td_client.set_current_pinned_message(None);
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
165
src/app/methods/search.rs
Normal file
165
src/app/methods/search.rs
Normal file
@@ -0,0 +1,165 @@
|
||||
//! Search methods for App
|
||||
//!
|
||||
//! Handles chat list search and message search within chat
|
||||
|
||||
use crate::app::{App, ChatFilter, ChatFilterCriteria, ChatState};
|
||||
use crate::tdlib::{ChatInfo, MessageInfo, TdClientTrait};
|
||||
|
||||
/// Search methods for chats and messages
|
||||
pub trait SearchMethods<T: TdClientTrait> {
|
||||
// === Chat Search ===
|
||||
|
||||
/// Start search mode in chat list
|
||||
fn start_search(&mut self);
|
||||
|
||||
/// Cancel search mode and reset query
|
||||
fn cancel_search(&mut self);
|
||||
|
||||
/// Get filtered chats based on search query and selected folder
|
||||
fn get_filtered_chats(&self) -> Vec<&ChatInfo>;
|
||||
|
||||
// === Message Search ===
|
||||
|
||||
/// Check if message search mode is active
|
||||
fn is_message_search_mode(&self) -> bool;
|
||||
|
||||
/// Enter message search mode within chat
|
||||
fn enter_message_search_mode(&mut self);
|
||||
|
||||
/// Exit message search mode
|
||||
fn exit_message_search_mode(&mut self);
|
||||
|
||||
/// Set search results
|
||||
fn set_search_results(&mut self, results: Vec<MessageInfo>);
|
||||
|
||||
/// Select previous search result (up)
|
||||
fn select_previous_search_result(&mut self);
|
||||
|
||||
/// Select next search result (down)
|
||||
fn select_next_search_result(&mut self);
|
||||
|
||||
/// Get currently selected search result
|
||||
fn get_selected_search_result(&self) -> Option<&MessageInfo>;
|
||||
|
||||
/// Get ID of selected search result for navigation
|
||||
fn get_selected_search_result_id(&self) -> Option<i64>;
|
||||
|
||||
/// Get current search query
|
||||
fn get_search_query(&self) -> Option<&str>;
|
||||
|
||||
/// Update search query
|
||||
fn update_search_query(&mut self, new_query: String);
|
||||
|
||||
/// Get index of selected search result
|
||||
#[allow(dead_code)]
|
||||
fn get_search_selected_index(&self) -> Option<usize>;
|
||||
|
||||
/// Get all search results
|
||||
#[allow(dead_code)]
|
||||
fn get_search_results(&self) -> Option<&[MessageInfo]>;
|
||||
}
|
||||
|
||||
impl<T: TdClientTrait> SearchMethods<T> for App<T> {
|
||||
fn start_search(&mut self) {
|
||||
self.is_searching = true;
|
||||
self.search_query.clear();
|
||||
}
|
||||
|
||||
fn cancel_search(&mut self) {
|
||||
self.is_searching = false;
|
||||
self.search_query.clear();
|
||||
self.chat_list_state.select(Some(0));
|
||||
}
|
||||
|
||||
fn get_filtered_chats(&self) -> Vec<&ChatInfo> {
|
||||
// Используем ChatFilter для централизованной фильтрации
|
||||
let mut criteria = ChatFilterCriteria::new().with_folder(self.selected_folder_id);
|
||||
|
||||
if !self.search_query.is_empty() {
|
||||
criteria = criteria.with_search(self.search_query.clone());
|
||||
}
|
||||
|
||||
ChatFilter::filter(&self.chats, &criteria)
|
||||
}
|
||||
|
||||
fn is_message_search_mode(&self) -> bool {
|
||||
self.chat_state.is_search_in_chat()
|
||||
}
|
||||
|
||||
fn enter_message_search_mode(&mut self) {
|
||||
self.chat_state = ChatState::SearchInChat {
|
||||
query: String::new(),
|
||||
results: Vec::new(),
|
||||
selected_index: 0,
|
||||
};
|
||||
}
|
||||
|
||||
fn exit_message_search_mode(&mut self) {
|
||||
self.chat_state = ChatState::Normal;
|
||||
}
|
||||
|
||||
fn set_search_results(&mut self, results: Vec<MessageInfo>) {
|
||||
if let ChatState::SearchInChat { results: r, selected_index, .. } = &mut self.chat_state {
|
||||
*r = results;
|
||||
*selected_index = 0;
|
||||
}
|
||||
}
|
||||
|
||||
fn select_previous_search_result(&mut self) {
|
||||
if let ChatState::SearchInChat { selected_index, .. } = &mut self.chat_state {
|
||||
if *selected_index > 0 {
|
||||
*selected_index -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn select_next_search_result(&mut self) {
|
||||
if let ChatState::SearchInChat { selected_index, results, .. } = &mut self.chat_state {
|
||||
if *selected_index + 1 < results.len() {
|
||||
*selected_index += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_selected_search_result(&self) -> Option<&MessageInfo> {
|
||||
if let ChatState::SearchInChat { results, selected_index, .. } = &self.chat_state {
|
||||
results.get(*selected_index)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn get_selected_search_result_id(&self) -> Option<i64> {
|
||||
self.get_selected_search_result().map(|m| m.id().as_i64())
|
||||
}
|
||||
|
||||
fn get_search_query(&self) -> Option<&str> {
|
||||
if let ChatState::SearchInChat { query, .. } = &self.chat_state {
|
||||
Some(query.as_str())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn update_search_query(&mut self, new_query: String) {
|
||||
if let ChatState::SearchInChat { query, .. } = &mut self.chat_state {
|
||||
*query = new_query;
|
||||
}
|
||||
}
|
||||
|
||||
fn get_search_selected_index(&self) -> Option<usize> {
|
||||
if let ChatState::SearchInChat { selected_index, .. } = &self.chat_state {
|
||||
Some(*selected_index)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn get_search_results(&self) -> Option<&[MessageInfo]> {
|
||||
if let ChatState::SearchInChat { results, .. } = &self.chat_state {
|
||||
Some(results.as_slice())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
1001
src/app/mod.rs
1001
src/app/mod.rs
File diff suppressed because it is too large
Load Diff
128
src/app/ui_state.rs
Normal file
128
src/app/ui_state.rs
Normal file
@@ -0,0 +1,128 @@
|
||||
/// UI состояние приложения
|
||||
///
|
||||
/// Отвечает за общее состояние интерфейса:
|
||||
/// - Текущий экран (screen)
|
||||
/// - Сообщения об ошибках и статусе
|
||||
/// - Флаги загрузки и перерисовки
|
||||
|
||||
use crate::app::AppScreen;
|
||||
|
||||
/// Состояние UI приложения
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UIState {
|
||||
/// Текущий экран приложения
|
||||
pub screen: AppScreen,
|
||||
|
||||
/// Сообщение об ошибке (если есть)
|
||||
pub error_message: Option<String>,
|
||||
|
||||
/// Статусное сообщение (загрузка, прогресс, и т.д.)
|
||||
pub status_message: Option<String>,
|
||||
|
||||
/// Флаг необходимости перерисовки
|
||||
pub needs_redraw: bool,
|
||||
|
||||
/// Флаг загрузки (общий)
|
||||
pub is_loading: bool,
|
||||
}
|
||||
|
||||
impl Default for UIState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
screen: AppScreen::Loading,
|
||||
error_message: None,
|
||||
status_message: Some("Инициализация TDLib...".to_string()),
|
||||
needs_redraw: true,
|
||||
is_loading: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl UIState {
|
||||
/// Создать новое UI состояние
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
// === Screen ===
|
||||
|
||||
pub fn screen(&self) -> &AppScreen {
|
||||
&self.screen
|
||||
}
|
||||
|
||||
pub fn set_screen(&mut self, screen: AppScreen) {
|
||||
self.screen = screen;
|
||||
self.mark_for_redraw();
|
||||
}
|
||||
|
||||
// === Error message ===
|
||||
|
||||
pub fn error_message(&self) -> Option<&str> {
|
||||
self.error_message.as_deref()
|
||||
}
|
||||
|
||||
pub fn set_error_message(&mut self, message: Option<String>) {
|
||||
self.error_message = message;
|
||||
self.mark_for_redraw();
|
||||
}
|
||||
|
||||
pub fn clear_error(&mut self) {
|
||||
self.error_message = None;
|
||||
self.mark_for_redraw();
|
||||
}
|
||||
|
||||
// === Status message ===
|
||||
|
||||
pub fn status_message(&self) -> Option<&str> {
|
||||
self.status_message.as_deref()
|
||||
}
|
||||
|
||||
pub fn set_status_message(&mut self, message: Option<String>) {
|
||||
self.status_message = message;
|
||||
self.mark_for_redraw();
|
||||
}
|
||||
|
||||
pub fn clear_status(&mut self) {
|
||||
self.status_message = None;
|
||||
self.mark_for_redraw();
|
||||
}
|
||||
|
||||
// === Redraw flag ===
|
||||
|
||||
pub fn needs_redraw(&self) -> bool {
|
||||
self.needs_redraw
|
||||
}
|
||||
|
||||
pub fn set_needs_redraw(&mut self, redraw: bool) {
|
||||
self.needs_redraw = redraw;
|
||||
}
|
||||
|
||||
pub fn mark_for_redraw(&mut self) {
|
||||
self.needs_redraw = true;
|
||||
}
|
||||
|
||||
pub fn clear_redraw_flag(&mut self) {
|
||||
self.needs_redraw = false;
|
||||
}
|
||||
|
||||
// === Loading flag ===
|
||||
|
||||
pub fn is_loading(&self) -> bool {
|
||||
self.is_loading
|
||||
}
|
||||
|
||||
pub fn set_loading(&mut self, loading: bool) {
|
||||
self.is_loading = loading;
|
||||
if loading {
|
||||
self.mark_for_redraw();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start_loading(&mut self) {
|
||||
self.set_loading(true);
|
||||
}
|
||||
|
||||
pub fn stop_loading(&mut self) {
|
||||
self.set_loading(false);
|
||||
}
|
||||
}
|
||||
155
src/audio/cache.rs
Normal file
155
src/audio/cache.rs
Normal file
@@ -0,0 +1,155 @@
|
||||
//! Voice message cache management.
|
||||
//!
|
||||
//! Caches downloaded OGG voice files in ~/.cache/tele-tui/voice/
|
||||
//! with LRU eviction when cache size exceeds limit.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Cache for voice message files
|
||||
pub struct VoiceCache {
|
||||
cache_dir: PathBuf,
|
||||
/// file_id -> (path, size_bytes, access_count)
|
||||
files: HashMap<String, (PathBuf, u64, usize)>,
|
||||
access_counter: usize,
|
||||
max_size_bytes: u64,
|
||||
}
|
||||
|
||||
impl VoiceCache {
|
||||
/// Creates a new VoiceCache with the given max size in MB
|
||||
pub fn new(max_size_mb: u64) -> Result<Self, String> {
|
||||
let cache_dir = dirs::cache_dir()
|
||||
.ok_or("Failed to get cache directory")?
|
||||
.join("tele-tui")
|
||||
.join("voice");
|
||||
|
||||
fs::create_dir_all(&cache_dir)
|
||||
.map_err(|e| format!("Failed to create cache directory: {}", e))?;
|
||||
|
||||
Ok(Self {
|
||||
cache_dir,
|
||||
files: HashMap::new(),
|
||||
access_counter: 0,
|
||||
max_size_bytes: max_size_mb * 1024 * 1024,
|
||||
})
|
||||
}
|
||||
|
||||
/// Gets the path for a cached voice file, if it exists
|
||||
pub fn get(&mut self, file_id: &str) -> Option<PathBuf> {
|
||||
if let Some((path, _, access)) = self.files.get_mut(file_id) {
|
||||
// Update access count for LRU
|
||||
self.access_counter += 1;
|
||||
*access = self.access_counter;
|
||||
Some(path.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Stores a voice file in the cache
|
||||
pub fn store(&mut self, file_id: &str, source_path: &Path) -> Result<PathBuf, String> {
|
||||
// Copy file to cache
|
||||
let filename = format!("{}.ogg", file_id.replace('/', "_"));
|
||||
let dest_path = self.cache_dir.join(&filename);
|
||||
|
||||
fs::copy(source_path, &dest_path)
|
||||
.map_err(|e| format!("Failed to copy voice file to cache: {}", e))?;
|
||||
|
||||
// Get file size
|
||||
let size = fs::metadata(&dest_path)
|
||||
.map_err(|e| format!("Failed to get file size: {}", e))?
|
||||
.len();
|
||||
|
||||
// Store in cache
|
||||
self.access_counter += 1;
|
||||
self.files
|
||||
.insert(file_id.to_string(), (dest_path.clone(), size, self.access_counter));
|
||||
|
||||
// Check if we need to evict
|
||||
self.evict_if_needed()?;
|
||||
|
||||
Ok(dest_path)
|
||||
}
|
||||
|
||||
/// Returns the total size of all cached files
|
||||
pub fn total_size(&self) -> u64 {
|
||||
self.files.values().map(|(_, size, _)| size).sum()
|
||||
}
|
||||
|
||||
/// Evicts oldest files until cache is under max size
|
||||
fn evict_if_needed(&mut self) -> Result<(), String> {
|
||||
while self.total_size() > self.max_size_bytes && !self.files.is_empty() {
|
||||
// Find least recently accessed file
|
||||
let oldest_id = self
|
||||
.files
|
||||
.iter()
|
||||
.min_by_key(|(_, (_, _, access))| access)
|
||||
.map(|(id, _)| id.clone());
|
||||
|
||||
if let Some(id) = oldest_id {
|
||||
self.evict(&id)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Evicts a specific file from cache
|
||||
fn evict(&mut self, file_id: &str) -> Result<(), String> {
|
||||
if let Some((path, _, _)) = self.files.remove(file_id) {
|
||||
fs::remove_file(&path).map_err(|e| format!("Failed to remove cached file: {}", e))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Clears all cached files
|
||||
#[allow(dead_code)]
|
||||
pub fn clear(&mut self) -> Result<(), String> {
|
||||
for (path, _, _) in self.files.values() {
|
||||
let _ = fs::remove_file(path); // Ignore errors
|
||||
}
|
||||
self.files.clear();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
|
||||
#[test]
|
||||
fn test_voice_cache_creation() {
|
||||
let cache = VoiceCache::new(100);
|
||||
assert!(cache.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cache_get_nonexistent() {
|
||||
let mut cache = VoiceCache::new(100).unwrap();
|
||||
assert!(cache.get("nonexistent").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cache_store_and_get() {
|
||||
let mut cache = VoiceCache::new(100).unwrap();
|
||||
|
||||
// Create temporary file
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let temp_file = temp_dir.join("test_voice.ogg");
|
||||
let mut file = fs::File::create(&temp_file).unwrap();
|
||||
file.write_all(b"test audio data").unwrap();
|
||||
|
||||
// Store in cache
|
||||
let result = cache.store("test123", &temp_file);
|
||||
assert!(result.is_ok());
|
||||
|
||||
// Get from cache
|
||||
let cached_path = cache.get("test123");
|
||||
assert!(cached_path.is_some());
|
||||
assert!(cached_path.unwrap().exists());
|
||||
|
||||
// Cleanup
|
||||
fs::remove_file(&temp_file).unwrap();
|
||||
}
|
||||
}
|
||||
11
src/audio/mod.rs
Normal file
11
src/audio/mod.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
//! Audio playback module for voice messages.
|
||||
//!
|
||||
//! Provides:
|
||||
//! - AudioPlayer: rodio-based playback with play/pause/stop/volume controls
|
||||
//! - VoiceCache: LRU cache for downloaded OGG voice files
|
||||
|
||||
pub mod cache;
|
||||
pub mod player;
|
||||
|
||||
pub use cache::VoiceCache;
|
||||
pub use player::AudioPlayer;
|
||||
198
src/audio/player.rs
Normal file
198
src/audio/player.rs
Normal file
@@ -0,0 +1,198 @@
|
||||
//! Audio player for voice messages.
|
||||
//!
|
||||
//! Uses ffplay (from FFmpeg) for reliable Opus/OGG playback.
|
||||
//! Pause/resume implemented via SIGSTOP/SIGCONT signals.
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Audio player state and controls
|
||||
pub struct AudioPlayer {
|
||||
/// PID of current playback process (if any)
|
||||
current_pid: Arc<Mutex<Option<u32>>>,
|
||||
/// Whether the process is currently paused (SIGSTOP)
|
||||
paused: Arc<Mutex<bool>>,
|
||||
/// Path to the currently playing file (for restart with seek)
|
||||
current_path: Arc<Mutex<Option<std::path::PathBuf>>>,
|
||||
/// True between play_from() call and ffplay actually starting (race window)
|
||||
starting: Arc<Mutex<bool>>,
|
||||
}
|
||||
|
||||
impl AudioPlayer {
|
||||
/// Creates a new AudioPlayer
|
||||
pub fn new() -> Result<Self, String> {
|
||||
Command::new("which")
|
||||
.arg("ffplay")
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.output()
|
||||
.map_err(|_| "ffplay not found (install ffmpeg)".to_string())?;
|
||||
|
||||
Ok(Self {
|
||||
current_pid: Arc::new(Mutex::new(None)),
|
||||
paused: Arc::new(Mutex::new(false)),
|
||||
current_path: Arc::new(Mutex::new(None)),
|
||||
starting: Arc::new(Mutex::new(false)),
|
||||
})
|
||||
}
|
||||
|
||||
/// Plays an audio file from the given path
|
||||
pub fn play<P: AsRef<Path>>(&self, path: P) -> Result<(), String> {
|
||||
self.play_from(path, 0.0)
|
||||
}
|
||||
|
||||
/// Plays an audio file starting from the given position (seconds)
|
||||
pub fn play_from<P: AsRef<Path>>(&self, path: P, start_secs: f32) -> Result<(), String> {
|
||||
self.stop();
|
||||
|
||||
let path_owned = path.as_ref().to_path_buf();
|
||||
*self.current_path.lock().unwrap() = Some(path_owned.clone());
|
||||
*self.starting.lock().unwrap() = true;
|
||||
let current_pid = self.current_pid.clone();
|
||||
let paused = self.paused.clone();
|
||||
let starting = self.starting.clone();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let mut cmd = Command::new("ffplay");
|
||||
cmd.arg("-nodisp")
|
||||
.arg("-autoexit")
|
||||
.arg("-loglevel")
|
||||
.arg("quiet");
|
||||
|
||||
if start_secs > 0.0 {
|
||||
cmd.arg("-ss").arg(format!("{:.1}", start_secs));
|
||||
}
|
||||
|
||||
if let Ok(mut child) = cmd
|
||||
.arg(&path_owned)
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn()
|
||||
{
|
||||
let pid = child.id();
|
||||
*current_pid.lock().unwrap() = Some(pid);
|
||||
*paused.lock().unwrap() = false;
|
||||
*starting.lock().unwrap() = false;
|
||||
|
||||
let _ = child.wait();
|
||||
|
||||
// Обнуляем только если это наш pid (новый play мог уже заменить его)
|
||||
let mut pid_guard = current_pid.lock().unwrap();
|
||||
if *pid_guard == Some(pid) {
|
||||
*pid_guard = None;
|
||||
*paused.lock().unwrap() = false;
|
||||
}
|
||||
} else {
|
||||
*starting.lock().unwrap() = false;
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Pauses playback via SIGSTOP
|
||||
pub fn pause(&self) {
|
||||
if let Some(pid) = *self.current_pid.lock().unwrap() {
|
||||
let _ = Command::new("kill")
|
||||
.arg("-STOP")
|
||||
.arg(pid.to_string())
|
||||
.output();
|
||||
*self.paused.lock().unwrap() = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Resumes playback via SIGCONT (from the same position)
|
||||
pub fn resume(&self) {
|
||||
if let Some(pid) = *self.current_pid.lock().unwrap() {
|
||||
let _ = Command::new("kill")
|
||||
.arg("-CONT")
|
||||
.arg(pid.to_string())
|
||||
.output();
|
||||
*self.paused.lock().unwrap() = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Resumes playback from a specific position (restarts ffplay with -ss)
|
||||
pub fn resume_from(&self, position_secs: f32) -> Result<(), String> {
|
||||
let path = self.current_path.lock().unwrap().clone();
|
||||
if let Some(path) = path {
|
||||
self.play_from(&path, position_secs)
|
||||
} else {
|
||||
Err("No file to resume".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Stops playback (kills the process)
|
||||
pub fn stop(&self) {
|
||||
*self.starting.lock().unwrap() = false;
|
||||
if let Some(pid) = self.current_pid.lock().unwrap().take() {
|
||||
// Resume first if paused, then kill
|
||||
let _ = Command::new("kill")
|
||||
.arg("-CONT")
|
||||
.arg(pid.to_string())
|
||||
.output();
|
||||
let _ = Command::new("kill").arg(pid.to_string()).output();
|
||||
}
|
||||
*self.paused.lock().unwrap() = false;
|
||||
}
|
||||
|
||||
/// Returns true if a process is active (playing or paused)
|
||||
#[allow(dead_code)]
|
||||
pub fn is_playing(&self) -> bool {
|
||||
self.current_pid.lock().unwrap().is_some() && !*self.paused.lock().unwrap()
|
||||
}
|
||||
|
||||
/// Returns true if paused
|
||||
#[allow(dead_code)]
|
||||
pub fn is_paused(&self) -> bool {
|
||||
self.current_pid.lock().unwrap().is_some() && *self.paused.lock().unwrap()
|
||||
}
|
||||
|
||||
/// Returns true if no active process and not starting a new one
|
||||
pub fn is_stopped(&self) -> bool {
|
||||
self.current_pid.lock().unwrap().is_none() && !*self.starting.lock().unwrap()
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn set_volume(&self, _volume: f32) {}
|
||||
#[allow(dead_code)]
|
||||
pub fn adjust_volume(&self, _delta: f32) {}
|
||||
|
||||
pub fn volume(&self) -> f32 {
|
||||
1.0
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn seek(&self, _delta: Duration) -> Result<(), String> {
|
||||
Err("Seeking not supported".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AudioPlayer {
|
||||
fn drop(&mut self) {
|
||||
self.stop();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_audio_player_creation() {
|
||||
if let Ok(player) = AudioPlayer::new() {
|
||||
assert!(player.is_stopped());
|
||||
assert!(!player.is_playing());
|
||||
assert!(!player.is_paused());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_volume() {
|
||||
if let Ok(player) = AudioPlayer::new() {
|
||||
assert_eq!(player.volume(), 1.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
892
src/config.rs
892
src/config.rs
@@ -1,892 +0,0 @@
|
||||
use crossterm::event::KeyCode;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Главная конфигурация приложения.
|
||||
///
|
||||
/// Загружается из `~/.config/tele-tui/config.toml` и содержит настройки
|
||||
/// общего поведения, цветовой схемы и горячих клавиш.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// // Загрузка конфигурации
|
||||
/// let config = Config::load();
|
||||
///
|
||||
/// // Доступ к настройкам
|
||||
/// println!("Timezone: {}", config.general.timezone);
|
||||
/// println!("Incoming color: {}", config.colors.incoming_message);
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Config {
|
||||
/// Общие настройки (timezone и т.д.).
|
||||
#[serde(default)]
|
||||
pub general: GeneralConfig,
|
||||
|
||||
/// Цветовая схема интерфейса.
|
||||
#[serde(default)]
|
||||
pub colors: ColorsConfig,
|
||||
|
||||
/// Горячие клавиши.
|
||||
#[serde(default)]
|
||||
pub hotkeys: HotkeysConfig,
|
||||
}
|
||||
|
||||
/// Общие настройки приложения.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GeneralConfig {
|
||||
/// Часовой пояс в формате "+03:00" или "-05:00"
|
||||
#[serde(default = "default_timezone")]
|
||||
pub timezone: String,
|
||||
}
|
||||
|
||||
/// Цветовая схема интерфейса.
|
||||
///
|
||||
/// Поддерживаемые цвета: red, green, blue, yellow, cyan, magenta,
|
||||
/// white, black, gray/grey, а также light-варианты (lightred, lightgreen и т.д.).
|
||||
#[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,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HotkeysConfig {
|
||||
/// Навигация вверх (vim: k, рус: р, стрелка: Up)
|
||||
#[serde(default = "default_up_keys")]
|
||||
pub up: Vec<String>,
|
||||
|
||||
/// Навигация вниз (vim: j, рус: о, стрелка: Down)
|
||||
#[serde(default = "default_down_keys")]
|
||||
pub down: Vec<String>,
|
||||
|
||||
/// Навигация влево (vim: h, рус: р, стрелка: Left)
|
||||
#[serde(default = "default_left_keys")]
|
||||
pub left: Vec<String>,
|
||||
|
||||
/// Навигация вправо (vim: l, рус: д, стрелка: Right)
|
||||
#[serde(default = "default_right_keys")]
|
||||
pub right: Vec<String>,
|
||||
|
||||
/// Reply — ответить на сообщение (англ: r, рус: к)
|
||||
#[serde(default = "default_reply_keys")]
|
||||
pub reply: Vec<String>,
|
||||
|
||||
/// Forward — переслать сообщение (англ: f, рус: а)
|
||||
#[serde(default = "default_forward_keys")]
|
||||
pub forward: Vec<String>,
|
||||
|
||||
/// Delete — удалить сообщение (англ: d, рус: в, Delete key)
|
||||
#[serde(default = "default_delete_keys")]
|
||||
pub delete: Vec<String>,
|
||||
|
||||
/// Copy — копировать сообщение (англ: y, рус: н)
|
||||
#[serde(default = "default_copy_keys")]
|
||||
pub copy: Vec<String>,
|
||||
|
||||
/// React — добавить реакцию (англ: e, рус: у)
|
||||
#[serde(default = "default_react_keys")]
|
||||
pub react: Vec<String>,
|
||||
|
||||
/// Profile — открыть профиль (англ: i, рус: ш)
|
||||
#[serde(default = "default_profile_keys")]
|
||||
pub profile: Vec<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()
|
||||
}
|
||||
|
||||
fn default_up_keys() -> Vec<String> {
|
||||
vec!["k".to_string(), "р".to_string(), "Up".to_string()]
|
||||
}
|
||||
|
||||
fn default_down_keys() -> Vec<String> {
|
||||
vec!["j".to_string(), "о".to_string(), "Down".to_string()]
|
||||
}
|
||||
|
||||
fn default_left_keys() -> Vec<String> {
|
||||
vec!["h".to_string(), "р".to_string(), "Left".to_string()]
|
||||
}
|
||||
|
||||
fn default_right_keys() -> Vec<String> {
|
||||
vec!["l".to_string(), "д".to_string(), "Right".to_string()]
|
||||
}
|
||||
|
||||
fn default_reply_keys() -> Vec<String> {
|
||||
vec!["r".to_string(), "к".to_string()]
|
||||
}
|
||||
|
||||
fn default_forward_keys() -> Vec<String> {
|
||||
vec!["f".to_string(), "а".to_string()]
|
||||
}
|
||||
|
||||
fn default_delete_keys() -> Vec<String> {
|
||||
vec!["d".to_string(), "в".to_string(), "Delete".to_string()]
|
||||
}
|
||||
|
||||
fn default_copy_keys() -> Vec<String> {
|
||||
vec!["y".to_string(), "н".to_string()]
|
||||
}
|
||||
|
||||
fn default_react_keys() -> Vec<String> {
|
||||
vec!["e".to_string(), "у".to_string()]
|
||||
}
|
||||
|
||||
fn default_profile_keys() -> Vec<String> {
|
||||
vec!["i".to_string(), "ш".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 HotkeysConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
up: default_up_keys(),
|
||||
down: default_down_keys(),
|
||||
left: default_left_keys(),
|
||||
right: default_right_keys(),
|
||||
reply: default_reply_keys(),
|
||||
forward: default_forward_keys(),
|
||||
delete: default_delete_keys(),
|
||||
copy: default_copy_keys(),
|
||||
react: default_react_keys(),
|
||||
profile: default_profile_keys(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl HotkeysConfig {
|
||||
/// Проверяет, соответствует ли клавиша указанному действию
|
||||
///
|
||||
/// # Аргументы
|
||||
///
|
||||
/// * `key` - Код нажатой клавиши
|
||||
/// * `action` - Название действия ("up", "down", "reply", "forward", и т.д.)
|
||||
///
|
||||
/// # Возвращает
|
||||
///
|
||||
/// `true` если клавиша соответствует действию, иначе `false`
|
||||
///
|
||||
/// # Примеры
|
||||
///
|
||||
/// ```no_run
|
||||
/// use tele_tui::config::Config;
|
||||
/// use crossterm::event::KeyCode;
|
||||
///
|
||||
/// let config = Config::default();
|
||||
///
|
||||
/// // Проверяем клавишу 'k' для действия "up"
|
||||
/// assert!(config.hotkeys.matches(KeyCode::Char('k'), "up"));
|
||||
///
|
||||
/// // Проверяем русскую клавишу 'р' для действия "up"
|
||||
/// assert!(config.hotkeys.matches(KeyCode::Char('р'), "up"));
|
||||
///
|
||||
/// // Проверяем стрелку вверх
|
||||
/// assert!(config.hotkeys.matches(KeyCode::Up, "up"));
|
||||
///
|
||||
/// // Проверяем клавишу 'r' для действия "reply"
|
||||
/// assert!(config.hotkeys.matches(KeyCode::Char('r'), "reply"));
|
||||
/// ```
|
||||
pub fn matches(&self, key: KeyCode, action: &str) -> bool {
|
||||
let keys = match action {
|
||||
"up" => &self.up,
|
||||
"down" => &self.down,
|
||||
"left" => &self.left,
|
||||
"right" => &self.right,
|
||||
"reply" => &self.reply,
|
||||
"forward" => &self.forward,
|
||||
"delete" => &self.delete,
|
||||
"copy" => &self.copy,
|
||||
"react" => &self.react,
|
||||
"profile" => &self.profile,
|
||||
_ => return false,
|
||||
};
|
||||
|
||||
self.key_matches(key, keys)
|
||||
}
|
||||
|
||||
/// Вспомогательная функция для проверки соответствия KeyCode списку строк
|
||||
fn key_matches(&self, key: KeyCode, keys: &[String]) -> bool {
|
||||
for key_str in keys {
|
||||
match key_str.as_str() {
|
||||
// Специальные клавиши
|
||||
"Up" => {
|
||||
if matches!(key, KeyCode::Up) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
"Down" => {
|
||||
if matches!(key, KeyCode::Down) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
"Left" => {
|
||||
if matches!(key, KeyCode::Left) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
"Right" => {
|
||||
if matches!(key, KeyCode::Right) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
"Delete" => {
|
||||
if matches!(key, KeyCode::Delete) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
"Enter" => {
|
||||
if matches!(key, KeyCode::Enter) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
"Esc" => {
|
||||
if matches!(key, KeyCode::Esc) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
"Backspace" => {
|
||||
if matches!(key, KeyCode::Backspace) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
"Tab" => {
|
||||
if matches!(key, KeyCode::Tab) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Символьные клавиши (буквы, цифры)
|
||||
// Проверяем количество символов, а не байтов (для поддержки UTF-8)
|
||||
key_char if key_char.chars().count() == 1 => {
|
||||
if let KeyCode::Char(ch) = key {
|
||||
if let Some(expected_ch) = key_char.chars().next() {
|
||||
if ch == expected_ch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
general: GeneralConfig::default(),
|
||||
colors: ColorsConfig::default(),
|
||||
hotkeys: HotkeysConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Валидация конфигурации
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
// Проверка timezone
|
||||
if !self.general.timezone.starts_with('+') && !self.general.timezone.starts_with('-') {
|
||||
return Err(format!(
|
||||
"Invalid timezone (must start with + or -): {}",
|
||||
self.general.timezone
|
||||
));
|
||||
}
|
||||
|
||||
// Проверка цветов
|
||||
let valid_colors = [
|
||||
"black",
|
||||
"red",
|
||||
"green",
|
||||
"yellow",
|
||||
"blue",
|
||||
"magenta",
|
||||
"cyan",
|
||||
"gray",
|
||||
"grey",
|
||||
"white",
|
||||
"darkgray",
|
||||
"darkgrey",
|
||||
"lightred",
|
||||
"lightgreen",
|
||||
"lightyellow",
|
||||
"lightblue",
|
||||
"lightmagenta",
|
||||
"lightcyan",
|
||||
];
|
||||
|
||||
for color_name in [
|
||||
&self.colors.incoming_message,
|
||||
&self.colors.outgoing_message,
|
||||
&self.colors.selected_message,
|
||||
&self.colors.reaction_chosen,
|
||||
&self.colors.reaction_other,
|
||||
] {
|
||||
if !valid_colors.contains(&color_name.to_lowercase().as_str()) {
|
||||
return Err(format!("Invalid color: {}", color_name));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Возвращает путь к конфигурационному файлу.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// `Some(PathBuf)` - `~/.config/tele-tui/config.toml`
|
||||
/// `None` - Не удалось определить директорию конфигурации
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
/// Загружает конфигурацию из файла.
|
||||
///
|
||||
/// Ищет конфиг в `~/.config/tele-tui/config.toml`.
|
||||
/// Если файл не существует, создаёт дефолтный.
|
||||
/// Если файл невалиден, возвращает дефолтные значения.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Всегда возвращает валидную конфигурацию.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// let config = Config::load();
|
||||
/// ```
|
||||
pub fn load() -> Self {
|
||||
let config_path = match Self::config_path() {
|
||||
Some(path) => path,
|
||||
None => {
|
||||
tracing::warn!("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() {
|
||||
tracing::warn!("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) => {
|
||||
// Валидируем загруженный конфиг
|
||||
if let Err(e) = config.validate() {
|
||||
tracing::error!("Config validation error: {}", e);
|
||||
tracing::warn!("Using default configuration instead");
|
||||
Self::default()
|
||||
} else {
|
||||
config
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Could not parse config file: {}", e);
|
||||
Self::default()
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::warn!("Could not read config file: {}", e);
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Сохраняет конфигурацию в файл.
|
||||
///
|
||||
/// Создаёт директорию `~/.config/tele-tui/` если её нет.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok(())` - Конфиг сохранен
|
||||
/// * `Err(String)` - Ошибка сохранения
|
||||
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`.
|
||||
///
|
||||
/// Поддерживает стандартные цвета (red, green, blue и т.д.),
|
||||
/// light-варианты (lightred, lightgreen и т.д.) и grey/gray.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `color_str` - Название цвета (case-insensitive)
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// `Color` - Соответствующий цвет или `White` если цвет не распознан
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// let color = config.parse_color("red");
|
||||
/// let color = config.parse_color("LightBlue");
|
||||
/// ```
|
||||
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 для Telegram.
|
||||
///
|
||||
/// Ищет credentials в следующем порядке:
|
||||
/// 1. `~/.config/tele-tui/credentials` файл
|
||||
/// 2. Переменные окружения `API_ID` и `API_HASH`
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok((api_id, api_hash))` - Учетные данные найдены
|
||||
/// * `Err(String)` - Ошибка с инструкциями по настройке
|
||||
///
|
||||
/// # Credentials Format
|
||||
///
|
||||
/// Файл `~/.config/tele-tui/credentials`:
|
||||
/// ```text
|
||||
/// API_ID=12345
|
||||
/// API_HASH=your_api_hash_here
|
||||
/// ```
|
||||
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
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_hotkeys_matches_char_keys() {
|
||||
let hotkeys = HotkeysConfig::default();
|
||||
|
||||
// Test reply keys (r, к)
|
||||
assert!(hotkeys.matches(KeyCode::Char('r'), "reply"));
|
||||
assert!(hotkeys.matches(KeyCode::Char('к'), "reply"));
|
||||
|
||||
// Test forward keys (f, а)
|
||||
assert!(hotkeys.matches(KeyCode::Char('f'), "forward"));
|
||||
assert!(hotkeys.matches(KeyCode::Char('а'), "forward"));
|
||||
|
||||
// Test delete keys (d, в)
|
||||
assert!(hotkeys.matches(KeyCode::Char('d'), "delete"));
|
||||
assert!(hotkeys.matches(KeyCode::Char('в'), "delete"));
|
||||
|
||||
// Test copy keys (y, н)
|
||||
assert!(hotkeys.matches(KeyCode::Char('y'), "copy"));
|
||||
assert!(hotkeys.matches(KeyCode::Char('н'), "copy"));
|
||||
|
||||
// Test react keys (e, у)
|
||||
assert!(hotkeys.matches(KeyCode::Char('e'), "react"));
|
||||
assert!(hotkeys.matches(KeyCode::Char('у'), "react"));
|
||||
|
||||
// Test profile keys (i, ш)
|
||||
assert!(hotkeys.matches(KeyCode::Char('i'), "profile"));
|
||||
assert!(hotkeys.matches(KeyCode::Char('ш'), "profile"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hotkeys_matches_arrow_keys() {
|
||||
let hotkeys = HotkeysConfig::default();
|
||||
|
||||
// Test navigation arrows
|
||||
assert!(hotkeys.matches(KeyCode::Up, "up"));
|
||||
assert!(hotkeys.matches(KeyCode::Down, "down"));
|
||||
assert!(hotkeys.matches(KeyCode::Left, "left"));
|
||||
assert!(hotkeys.matches(KeyCode::Right, "right"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hotkeys_matches_vim_keys() {
|
||||
let hotkeys = HotkeysConfig::default();
|
||||
|
||||
// Test vim navigation keys
|
||||
assert!(hotkeys.matches(KeyCode::Char('k'), "up"));
|
||||
assert!(hotkeys.matches(KeyCode::Char('j'), "down"));
|
||||
assert!(hotkeys.matches(KeyCode::Char('h'), "left"));
|
||||
assert!(hotkeys.matches(KeyCode::Char('l'), "right"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hotkeys_matches_russian_vim_keys() {
|
||||
let hotkeys = HotkeysConfig::default();
|
||||
|
||||
// Test russian vim navigation keys
|
||||
assert!(hotkeys.matches(KeyCode::Char('р'), "up"));
|
||||
assert!(hotkeys.matches(KeyCode::Char('о'), "down"));
|
||||
assert!(hotkeys.matches(KeyCode::Char('р'), "left"));
|
||||
assert!(hotkeys.matches(KeyCode::Char('д'), "right"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hotkeys_matches_special_delete_key() {
|
||||
let hotkeys = HotkeysConfig::default();
|
||||
|
||||
// Test Delete key for delete action
|
||||
assert!(hotkeys.matches(KeyCode::Delete, "delete"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hotkeys_does_not_match_wrong_keys() {
|
||||
let hotkeys = HotkeysConfig::default();
|
||||
|
||||
// Test wrong keys don't match
|
||||
assert!(!hotkeys.matches(KeyCode::Char('x'), "reply"));
|
||||
assert!(!hotkeys.matches(KeyCode::Char('z'), "forward"));
|
||||
assert!(!hotkeys.matches(KeyCode::Char('q'), "delete"));
|
||||
assert!(!hotkeys.matches(KeyCode::Enter, "copy"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hotkeys_does_not_match_wrong_actions() {
|
||||
let hotkeys = HotkeysConfig::default();
|
||||
|
||||
// Test valid keys don't match wrong actions
|
||||
assert!(!hotkeys.matches(KeyCode::Char('r'), "forward"));
|
||||
assert!(!hotkeys.matches(KeyCode::Char('f'), "reply"));
|
||||
assert!(!hotkeys.matches(KeyCode::Char('d'), "copy"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hotkeys_unknown_action() {
|
||||
let hotkeys = HotkeysConfig::default();
|
||||
|
||||
// Unknown actions should return false
|
||||
assert!(!hotkeys.matches(KeyCode::Char('r'), "unknown_action"));
|
||||
assert!(!hotkeys.matches(KeyCode::Enter, "foo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_default_includes_hotkeys() {
|
||||
let config = Config::default();
|
||||
|
||||
// Verify hotkeys are included in default config
|
||||
assert_eq!(config.hotkeys.reply, vec!["r", "к"]);
|
||||
assert_eq!(config.hotkeys.forward, vec!["f", "а"]);
|
||||
assert_eq!(config.hotkeys.delete, vec!["d", "в", "Delete"]);
|
||||
assert_eq!(config.hotkeys.copy, vec!["y", "н"]);
|
||||
assert_eq!(config.hotkeys.react, vec!["e", "у"]);
|
||||
assert_eq!(config.hotkeys.profile, vec!["i", "ш"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_validate_valid() {
|
||||
let config = Config::default();
|
||||
assert!(config.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_validate_invalid_timezone_no_sign() {
|
||||
let mut config = Config::default();
|
||||
config.general.timezone = "03:00".to_string();
|
||||
|
||||
let result = config.validate();
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("timezone"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_validate_valid_negative_timezone() {
|
||||
let mut config = Config::default();
|
||||
config.general.timezone = "-05:00".to_string();
|
||||
|
||||
assert!(config.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_validate_valid_positive_timezone() {
|
||||
let mut config = Config::default();
|
||||
config.general.timezone = "+09:00".to_string();
|
||||
|
||||
assert!(config.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_validate_invalid_color_incoming() {
|
||||
let mut config = Config::default();
|
||||
config.colors.incoming_message = "rainbow".to_string();
|
||||
|
||||
let result = config.validate();
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Invalid color"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_validate_invalid_color_outgoing() {
|
||||
let mut config = Config::default();
|
||||
config.colors.outgoing_message = "purple".to_string();
|
||||
|
||||
let result = config.validate();
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Invalid color"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_validate_invalid_color_selected() {
|
||||
let mut config = Config::default();
|
||||
config.colors.selected_message = "pink".to_string();
|
||||
|
||||
let result = config.validate();
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Invalid color"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_validate_valid_all_standard_colors() {
|
||||
let colors = [
|
||||
"black", "red", "green", "yellow", "blue", "magenta",
|
||||
"cyan", "gray", "grey", "white", "darkgray", "darkgrey",
|
||||
"lightred", "lightgreen", "lightyellow", "lightblue",
|
||||
"lightmagenta", "lightcyan"
|
||||
];
|
||||
|
||||
for color in colors {
|
||||
let mut config = Config::default();
|
||||
config.colors.incoming_message = color.to_string();
|
||||
config.colors.outgoing_message = color.to_string();
|
||||
config.colors.selected_message = color.to_string();
|
||||
config.colors.reaction_chosen = color.to_string();
|
||||
config.colors.reaction_other = color.to_string();
|
||||
|
||||
assert!(
|
||||
config.validate().is_ok(),
|
||||
"Color '{}' should be valid",
|
||||
color
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_validate_case_insensitive_colors() {
|
||||
let mut config = Config::default();
|
||||
config.colors.incoming_message = "RED".to_string();
|
||||
config.colors.outgoing_message = "Green".to_string();
|
||||
config.colors.selected_message = "YELLOW".to_string();
|
||||
|
||||
assert!(config.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_color_standard() {
|
||||
let config = Config::default();
|
||||
|
||||
use ratatui::style::Color;
|
||||
assert_eq!(config.parse_color("red"), Color::Red);
|
||||
assert_eq!(config.parse_color("green"), Color::Green);
|
||||
assert_eq!(config.parse_color("blue"), Color::Blue);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_color_light_variants() {
|
||||
let config = Config::default();
|
||||
|
||||
use ratatui::style::Color;
|
||||
assert_eq!(config.parse_color("lightred"), Color::LightRed);
|
||||
assert_eq!(config.parse_color("lightgreen"), Color::LightGreen);
|
||||
assert_eq!(config.parse_color("lightblue"), Color::LightBlue);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_color_gray_variants() {
|
||||
let config = Config::default();
|
||||
|
||||
use ratatui::style::Color;
|
||||
assert_eq!(config.parse_color("gray"), Color::Gray);
|
||||
assert_eq!(config.parse_color("grey"), Color::Gray);
|
||||
assert_eq!(config.parse_color("darkgray"), Color::DarkGray);
|
||||
assert_eq!(config.parse_color("darkgrey"), Color::DarkGray);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_color_case_insensitive() {
|
||||
let config = Config::default();
|
||||
|
||||
use ratatui::style::Color;
|
||||
assert_eq!(config.parse_color("RED"), Color::Red);
|
||||
assert_eq!(config.parse_color("Green"), Color::Green);
|
||||
assert_eq!(config.parse_color("LIGHTBLUE"), Color::LightBlue);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_color_invalid_fallback() {
|
||||
let config = Config::default();
|
||||
|
||||
use ratatui::style::Color;
|
||||
// Invalid colors should fallback to White
|
||||
assert_eq!(config.parse_color("rainbow"), Color::White);
|
||||
assert_eq!(config.parse_color("purple"), Color::White);
|
||||
assert_eq!(config.parse_color("unknown"), Color::White);
|
||||
}
|
||||
}
|
||||
488
src/config/keybindings.rs
Normal file
488
src/config/keybindings.rs
Normal file
@@ -0,0 +1,488 @@
|
||||
/// Модуль для настраиваемых горячих клавиш
|
||||
///
|
||||
/// Поддерживает:
|
||||
/// - Загрузку из конфигурационного файла
|
||||
/// - Множественные binding для одной команды (EN/RU раскладки)
|
||||
/// - Type-safe команды через enum
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Команды приложения
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Command {
|
||||
// Navigation
|
||||
MoveUp,
|
||||
MoveDown,
|
||||
MoveLeft,
|
||||
MoveRight,
|
||||
PageUp,
|
||||
PageDown,
|
||||
|
||||
// Global
|
||||
Quit,
|
||||
OpenSearch,
|
||||
OpenSearchInChat,
|
||||
Help,
|
||||
|
||||
// Chat list
|
||||
OpenChat,
|
||||
SelectFolder1,
|
||||
SelectFolder2,
|
||||
SelectFolder3,
|
||||
SelectFolder4,
|
||||
SelectFolder5,
|
||||
SelectFolder6,
|
||||
SelectFolder7,
|
||||
SelectFolder8,
|
||||
SelectFolder9,
|
||||
|
||||
// Message actions
|
||||
EditMessage,
|
||||
DeleteMessage,
|
||||
ReplyMessage,
|
||||
ForwardMessage,
|
||||
CopyMessage,
|
||||
ReactMessage,
|
||||
SelectMessage,
|
||||
|
||||
// Media
|
||||
ViewImage, // v - просмотр фото
|
||||
|
||||
// Voice playback
|
||||
TogglePlayback, // Space - play/pause
|
||||
SeekForward, // → - seek +5s
|
||||
SeekBackward, // ← - seek -5s
|
||||
|
||||
// Input
|
||||
SubmitMessage,
|
||||
Cancel,
|
||||
NewLine,
|
||||
DeleteChar,
|
||||
DeleteWord,
|
||||
MoveToStart,
|
||||
MoveToEnd,
|
||||
|
||||
// Vim mode
|
||||
EnterInsertMode,
|
||||
|
||||
// Profile
|
||||
OpenProfile,
|
||||
}
|
||||
|
||||
/// Привязка клавиши к команде
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct KeyBinding {
|
||||
#[serde(with = "key_code_serde")]
|
||||
pub key: KeyCode,
|
||||
#[serde(with = "key_modifiers_serde")]
|
||||
pub modifiers: KeyModifiers,
|
||||
}
|
||||
|
||||
impl KeyBinding {
|
||||
pub fn new(key: KeyCode) -> Self {
|
||||
Self { key, modifiers: KeyModifiers::NONE }
|
||||
}
|
||||
|
||||
pub fn with_ctrl(key: KeyCode) -> Self {
|
||||
Self { key, modifiers: KeyModifiers::CONTROL }
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn with_shift(key: KeyCode) -> Self {
|
||||
Self { key, modifiers: KeyModifiers::SHIFT }
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn with_alt(key: KeyCode) -> Self {
|
||||
Self { key, modifiers: KeyModifiers::ALT }
|
||||
}
|
||||
|
||||
pub fn matches(&self, event: &KeyEvent) -> bool {
|
||||
self.key == event.code && self.modifiers == event.modifiers
|
||||
}
|
||||
}
|
||||
|
||||
/// Конфигурация горячих клавиш
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Keybindings {
|
||||
#[serde(flatten)]
|
||||
bindings: HashMap<Command, Vec<KeyBinding>>,
|
||||
}
|
||||
|
||||
impl Keybindings {
|
||||
/// Ищет команду по клавише
|
||||
pub fn get_command(&self, event: &KeyEvent) -> Option<Command> {
|
||||
for (command, bindings) in &self.bindings {
|
||||
if bindings.iter().any(|binding| binding.matches(event)) {
|
||||
return Some(*command);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Keybindings {
|
||||
fn default() -> Self {
|
||||
let mut bindings = HashMap::new();
|
||||
|
||||
// Navigation
|
||||
bindings.insert(
|
||||
Command::MoveUp,
|
||||
vec![
|
||||
KeyBinding::new(KeyCode::Up),
|
||||
KeyBinding::new(KeyCode::Char('k')),
|
||||
KeyBinding::new(KeyCode::Char('р')), // RU (custom mapping, not standard ЙЦУКЕН)
|
||||
],
|
||||
);
|
||||
bindings.insert(
|
||||
Command::MoveDown,
|
||||
vec![
|
||||
KeyBinding::new(KeyCode::Down),
|
||||
KeyBinding::new(KeyCode::Char('j')),
|
||||
KeyBinding::new(KeyCode::Char('о')), // RU
|
||||
],
|
||||
);
|
||||
bindings.insert(
|
||||
Command::MoveLeft,
|
||||
vec![
|
||||
KeyBinding::new(KeyCode::Left),
|
||||
KeyBinding::new(KeyCode::Char('h')),
|
||||
KeyBinding::new(KeyCode::Char('л')), // RU (custom mapping, not standard ЙЦУКЕН)
|
||||
],
|
||||
);
|
||||
bindings.insert(
|
||||
Command::MoveRight,
|
||||
vec![
|
||||
KeyBinding::new(KeyCode::Right),
|
||||
KeyBinding::new(KeyCode::Char('l')),
|
||||
KeyBinding::new(KeyCode::Char('д')), // RU
|
||||
],
|
||||
);
|
||||
bindings.insert(
|
||||
Command::PageUp,
|
||||
vec![
|
||||
KeyBinding::new(KeyCode::PageUp),
|
||||
KeyBinding::with_ctrl(KeyCode::Char('u')),
|
||||
],
|
||||
);
|
||||
bindings.insert(
|
||||
Command::PageDown,
|
||||
vec![
|
||||
KeyBinding::new(KeyCode::PageDown),
|
||||
KeyBinding::with_ctrl(KeyCode::Char('d')),
|
||||
],
|
||||
);
|
||||
|
||||
// Global
|
||||
bindings.insert(
|
||||
Command::Quit,
|
||||
vec![
|
||||
KeyBinding::new(KeyCode::Char('q')),
|
||||
KeyBinding::new(KeyCode::Char('й')), // RU
|
||||
KeyBinding::with_ctrl(KeyCode::Char('c')),
|
||||
],
|
||||
);
|
||||
bindings.insert(Command::OpenSearch, vec![KeyBinding::with_ctrl(KeyCode::Char('s'))]);
|
||||
bindings.insert(Command::OpenSearchInChat, vec![KeyBinding::with_ctrl(KeyCode::Char('f'))]);
|
||||
bindings.insert(Command::Help, vec![KeyBinding::new(KeyCode::Char('?'))]);
|
||||
|
||||
// Chat list
|
||||
// Note: Enter обрабатывается через Command::SubmitMessage в handle_enter_key()
|
||||
for i in 1..=9 {
|
||||
let cmd = match i {
|
||||
1 => Command::SelectFolder1,
|
||||
2 => Command::SelectFolder2,
|
||||
3 => Command::SelectFolder3,
|
||||
4 => Command::SelectFolder4,
|
||||
5 => Command::SelectFolder5,
|
||||
6 => Command::SelectFolder6,
|
||||
7 => Command::SelectFolder7,
|
||||
8 => Command::SelectFolder8,
|
||||
9 => Command::SelectFolder9,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
bindings.insert(
|
||||
cmd,
|
||||
vec![KeyBinding::new(KeyCode::Char(
|
||||
char::from_digit(i, 10).unwrap(),
|
||||
))],
|
||||
);
|
||||
}
|
||||
|
||||
// Message actions
|
||||
// Note: EditMessage (Up) обрабатывается напрямую в handle_open_chat_keyboard_input
|
||||
// в зависимости от контекста (пустой инпут). Не привязываем здесь, чтобы не
|
||||
// конфликтовать с Command::MoveUp в списке чатов.
|
||||
bindings.insert(
|
||||
Command::DeleteMessage,
|
||||
vec![
|
||||
KeyBinding::new(KeyCode::Delete),
|
||||
KeyBinding::new(KeyCode::Char('d')),
|
||||
KeyBinding::new(KeyCode::Char('в')), // RU
|
||||
],
|
||||
);
|
||||
bindings.insert(
|
||||
Command::ReplyMessage,
|
||||
vec![
|
||||
KeyBinding::new(KeyCode::Char('r')),
|
||||
KeyBinding::new(KeyCode::Char('к')), // RU
|
||||
],
|
||||
);
|
||||
bindings.insert(
|
||||
Command::ForwardMessage,
|
||||
vec![
|
||||
KeyBinding::new(KeyCode::Char('f')),
|
||||
KeyBinding::new(KeyCode::Char('а')), // RU
|
||||
],
|
||||
);
|
||||
bindings.insert(
|
||||
Command::CopyMessage,
|
||||
vec![
|
||||
KeyBinding::new(KeyCode::Char('y')),
|
||||
KeyBinding::new(KeyCode::Char('н')), // RU
|
||||
],
|
||||
);
|
||||
bindings.insert(
|
||||
Command::ReactMessage,
|
||||
vec![
|
||||
KeyBinding::new(KeyCode::Char('e')),
|
||||
KeyBinding::new(KeyCode::Char('у')), // RU
|
||||
],
|
||||
);
|
||||
// Note: SelectMessage обрабатывается через Command::SubmitMessage в handle_enter_key()
|
||||
|
||||
// Media
|
||||
bindings.insert(
|
||||
Command::ViewImage,
|
||||
vec![
|
||||
KeyBinding::new(KeyCode::Char('v')),
|
||||
KeyBinding::new(KeyCode::Char('м')), // RU
|
||||
],
|
||||
);
|
||||
|
||||
// Voice playback
|
||||
bindings.insert(Command::TogglePlayback, vec![KeyBinding::new(KeyCode::Char(' '))]);
|
||||
bindings.insert(Command::SeekForward, vec![KeyBinding::new(KeyCode::Right)]);
|
||||
bindings.insert(Command::SeekBackward, vec![KeyBinding::new(KeyCode::Left)]);
|
||||
|
||||
// Input
|
||||
bindings.insert(Command::SubmitMessage, vec![KeyBinding::new(KeyCode::Enter)]);
|
||||
bindings.insert(Command::Cancel, vec![KeyBinding::new(KeyCode::Esc)]);
|
||||
bindings.insert(Command::NewLine, vec![]);
|
||||
bindings.insert(Command::DeleteChar, vec![KeyBinding::new(KeyCode::Backspace)]);
|
||||
bindings.insert(
|
||||
Command::DeleteWord,
|
||||
vec![
|
||||
KeyBinding::with_ctrl(KeyCode::Backspace),
|
||||
KeyBinding::with_ctrl(KeyCode::Char('w')),
|
||||
],
|
||||
);
|
||||
bindings.insert(
|
||||
Command::MoveToStart,
|
||||
vec![
|
||||
KeyBinding::new(KeyCode::Home),
|
||||
KeyBinding::with_ctrl(KeyCode::Char('a')),
|
||||
],
|
||||
);
|
||||
bindings.insert(
|
||||
Command::MoveToEnd,
|
||||
vec![
|
||||
KeyBinding::new(KeyCode::End),
|
||||
KeyBinding::with_ctrl(KeyCode::Char('e')),
|
||||
],
|
||||
);
|
||||
|
||||
// Vim mode
|
||||
bindings.insert(
|
||||
Command::EnterInsertMode,
|
||||
vec![
|
||||
KeyBinding::new(KeyCode::Char('i')),
|
||||
KeyBinding::new(KeyCode::Char('ш')), // RU
|
||||
],
|
||||
);
|
||||
|
||||
// Profile
|
||||
bindings.insert(
|
||||
Command::OpenProfile,
|
||||
vec![
|
||||
KeyBinding::with_ctrl(KeyCode::Char('u')), // Ctrl+U instead of Ctrl+I
|
||||
KeyBinding::with_ctrl(KeyCode::Char('г')), // RU
|
||||
],
|
||||
);
|
||||
|
||||
Self { bindings }
|
||||
}
|
||||
}
|
||||
|
||||
/// Сериализация KeyModifiers
|
||||
mod key_modifiers_serde {
|
||||
use crossterm::event::KeyModifiers;
|
||||
use serde::{Deserialize, Deserializer, Serializer};
|
||||
|
||||
pub fn serialize<S>(modifiers: &KeyModifiers, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
let mut parts = Vec::new();
|
||||
if modifiers.contains(KeyModifiers::SHIFT) {
|
||||
parts.push("Shift");
|
||||
}
|
||||
if modifiers.contains(KeyModifiers::CONTROL) {
|
||||
parts.push("Ctrl");
|
||||
}
|
||||
if modifiers.contains(KeyModifiers::ALT) {
|
||||
parts.push("Alt");
|
||||
}
|
||||
if modifiers.contains(KeyModifiers::SUPER) {
|
||||
parts.push("Super");
|
||||
}
|
||||
if modifiers.contains(KeyModifiers::HYPER) {
|
||||
parts.push("Hyper");
|
||||
}
|
||||
if modifiers.contains(KeyModifiers::META) {
|
||||
parts.push("Meta");
|
||||
}
|
||||
|
||||
if parts.is_empty() {
|
||||
serializer.serialize_str("None")
|
||||
} else {
|
||||
serializer.serialize_str(&parts.join("+"))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn deserialize<'de, D>(deserializer: D) -> Result<KeyModifiers, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let s = String::deserialize(deserializer)?;
|
||||
|
||||
if s == "None" || s.is_empty() {
|
||||
return Ok(KeyModifiers::NONE);
|
||||
}
|
||||
|
||||
let mut modifiers = KeyModifiers::NONE;
|
||||
for part in s.split('+') {
|
||||
match part.trim() {
|
||||
"Shift" => modifiers |= KeyModifiers::SHIFT,
|
||||
"Ctrl" | "Control" => modifiers |= KeyModifiers::CONTROL,
|
||||
"Alt" => modifiers |= KeyModifiers::ALT,
|
||||
"Super" => modifiers |= KeyModifiers::SUPER,
|
||||
"Hyper" => modifiers |= KeyModifiers::HYPER,
|
||||
"Meta" => modifiers |= KeyModifiers::META,
|
||||
_ => return Err(serde::de::Error::custom(format!("Unknown modifier: {}", part))),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(modifiers)
|
||||
}
|
||||
}
|
||||
|
||||
/// Сериализация KeyCode
|
||||
mod key_code_serde {
|
||||
use crossterm::event::KeyCode;
|
||||
use serde::{Deserialize, Deserializer, Serializer};
|
||||
|
||||
pub fn serialize<S>(key: &KeyCode, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
let s = match key {
|
||||
KeyCode::Char(c) => format!("Char('{}')", c),
|
||||
KeyCode::F(n) => format!("F{}", n),
|
||||
KeyCode::Backspace => "Backspace".to_string(),
|
||||
KeyCode::Enter => "Enter".to_string(),
|
||||
KeyCode::Left => "Left".to_string(),
|
||||
KeyCode::Right => "Right".to_string(),
|
||||
KeyCode::Up => "Up".to_string(),
|
||||
KeyCode::Down => "Down".to_string(),
|
||||
KeyCode::Home => "Home".to_string(),
|
||||
KeyCode::End => "End".to_string(),
|
||||
KeyCode::PageUp => "PageUp".to_string(),
|
||||
KeyCode::PageDown => "PageDown".to_string(),
|
||||
KeyCode::Tab => "Tab".to_string(),
|
||||
KeyCode::BackTab => "BackTab".to_string(),
|
||||
KeyCode::Delete => "Delete".to_string(),
|
||||
KeyCode::Insert => "Insert".to_string(),
|
||||
KeyCode::Esc => "Esc".to_string(),
|
||||
_ => "Unknown".to_string(),
|
||||
};
|
||||
serializer.serialize_str(&s)
|
||||
}
|
||||
|
||||
pub fn deserialize<'de, D>(deserializer: D) -> Result<KeyCode, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let s = String::deserialize(deserializer)?;
|
||||
|
||||
if s.starts_with("Char('") && s.ends_with("')") {
|
||||
let c = s
|
||||
.chars()
|
||||
.nth(6)
|
||||
.ok_or_else(|| serde::de::Error::custom("Invalid Char format"))?;
|
||||
return Ok(KeyCode::Char(c));
|
||||
}
|
||||
|
||||
if let Some(suffix) = s.strip_prefix("F") {
|
||||
let n = suffix.parse().map_err(serde::de::Error::custom)?;
|
||||
return Ok(KeyCode::F(n));
|
||||
}
|
||||
|
||||
match s.as_str() {
|
||||
"Backspace" => Ok(KeyCode::Backspace),
|
||||
"Enter" => Ok(KeyCode::Enter),
|
||||
"Left" => Ok(KeyCode::Left),
|
||||
"Right" => Ok(KeyCode::Right),
|
||||
"Up" => Ok(KeyCode::Up),
|
||||
"Down" => Ok(KeyCode::Down),
|
||||
"Home" => Ok(KeyCode::Home),
|
||||
"End" => Ok(KeyCode::End),
|
||||
"PageUp" => Ok(KeyCode::PageUp),
|
||||
"PageDown" => Ok(KeyCode::PageDown),
|
||||
"Tab" => Ok(KeyCode::Tab),
|
||||
"BackTab" => Ok(KeyCode::BackTab),
|
||||
"Delete" => Ok(KeyCode::Delete),
|
||||
"Insert" => Ok(KeyCode::Insert),
|
||||
"Esc" => Ok(KeyCode::Esc),
|
||||
_ => Err(serde::de::Error::custom(format!("Unknown key: {}", s))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_default_bindings() {
|
||||
let kb = Keybindings::default();
|
||||
|
||||
// Проверяем навигацию
|
||||
assert_eq!(kb.get_command(&KeyEvent::from(KeyCode::Up)), Some(Command::MoveUp));
|
||||
assert_eq!(kb.get_command(&KeyEvent::from(KeyCode::Char('k'))), Some(Command::MoveUp));
|
||||
assert_eq!(kb.get_command(&KeyEvent::from(KeyCode::Char('р'))), Some(Command::MoveUp));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_command() {
|
||||
let kb = Keybindings::default();
|
||||
|
||||
let event = KeyEvent::from(KeyCode::Char('q'));
|
||||
assert_eq!(kb.get_command(&event), Some(Command::Quit));
|
||||
|
||||
let event = KeyEvent::from(KeyCode::Char('й')); // RU
|
||||
assert_eq!(kb.get_command(&event), Some(Command::Quit));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ctrl_modifier() {
|
||||
let kb = Keybindings::default();
|
||||
|
||||
let mut event = KeyEvent::from(KeyCode::Char('s'));
|
||||
event.modifiers = KeyModifiers::CONTROL;
|
||||
|
||||
assert_eq!(kb.get_command(&event), Some(Command::OpenSearch));
|
||||
}
|
||||
}
|
||||
197
src/config/loader.rs
Normal file
197
src/config/loader.rs
Normal file
@@ -0,0 +1,197 @@
|
||||
//! Config file loading, saving, and credentials management.
|
||||
//!
|
||||
//! Searches for config at `~/.config/tele-tui/config.toml`.
|
||||
//! Credentials loaded from file or environment variables.
|
||||
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::Config;
|
||||
|
||||
impl Config {
|
||||
/// Возвращает путь к конфигурационному файлу.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// `Some(PathBuf)` - `~/.config/tele-tui/config.toml`
|
||||
/// `None` - Не удалось определить директорию конфигурации
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
/// Загружает конфигурацию из файла.
|
||||
///
|
||||
/// Ищет конфиг в `~/.config/tele-tui/config.toml`.
|
||||
/// Если файл не существует, создаёт дефолтный.
|
||||
/// Если файл невалиден, возвращает дефолтные значения.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Всегда возвращает валидную конфигурацию.
|
||||
pub fn load() -> Self {
|
||||
let config_path = match Self::config_path() {
|
||||
Some(path) => path,
|
||||
None => {
|
||||
tracing::warn!("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() {
|
||||
tracing::warn!("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) => {
|
||||
// Валидируем загруженный конфиг
|
||||
if let Err(e) = config.validate() {
|
||||
tracing::error!("Config validation error: {}", e);
|
||||
tracing::warn!("Using default configuration instead");
|
||||
Self::default()
|
||||
} else {
|
||||
config
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Could not parse config file: {}", e);
|
||||
Self::default()
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::warn!("Could not read config file: {}", e);
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Сохраняет конфигурацию в файл.
|
||||
///
|
||||
/// Создаёт директорию `~/.config/tele-tui/` если её нет.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok(())` - Конфиг сохранен
|
||||
/// * `Err(String)` - Ошибка сохранения
|
||||
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(())
|
||||
}
|
||||
|
||||
/// Путь к файлу credentials
|
||||
pub fn credentials_path() -> Option<PathBuf> {
|
||||
Self::config_dir().map(|dir| dir.join("credentials"))
|
||||
}
|
||||
|
||||
/// Загружает API_ID и API_HASH для Telegram.
|
||||
///
|
||||
/// Ищет credentials в следующем порядке:
|
||||
/// 1. `~/.config/tele-tui/credentials` файл
|
||||
/// 2. Переменные окружения `API_ID` и `API_HASH`
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok((api_id, api_hash))` - Учетные данные найдены
|
||||
/// * `Err(String)` - Ошибка с инструкциями по настройке
|
||||
pub fn load_credentials() -> Result<(i32, String), String> {
|
||||
// 1. Пробуем загрузить из ~/.config/tele-tui/credentials
|
||||
if let Some(credentials) = Self::load_credentials_from_file() {
|
||||
return Ok(credentials);
|
||||
}
|
||||
|
||||
// 2. Пробуем загрузить из переменных окружения (.env)
|
||||
if let Some(credentials) = Self::load_credentials_from_env() {
|
||||
return Ok(credentials);
|
||||
}
|
||||
|
||||
// 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
|
||||
))
|
||||
}
|
||||
|
||||
/// Загружает credentials из файла ~/.config/tele-tui/credentials
|
||||
fn load_credentials_from_file() -> Option<(i32, String)> {
|
||||
let cred_path = Self::credentials_path()?;
|
||||
|
||||
if !cred_path.exists() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(&cred_path).ok()?;
|
||||
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;
|
||||
}
|
||||
|
||||
let (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()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Some((api_id?, api_hash?))
|
||||
}
|
||||
|
||||
/// Загружает credentials из переменных окружения (.env)
|
||||
fn load_credentials_from_env() -> Option<(i32, String)> {
|
||||
use std::env;
|
||||
|
||||
let api_id_str = env::var("API_ID").ok()?;
|
||||
let api_hash = env::var("API_HASH").ok()?;
|
||||
let api_id = api_id_str.parse::<i32>().ok()?;
|
||||
|
||||
Some((api_id, api_hash))
|
||||
}
|
||||
}
|
||||
450
src/config/mod.rs
Normal file
450
src/config/mod.rs
Normal file
@@ -0,0 +1,450 @@
|
||||
//! Configuration module.
|
||||
//!
|
||||
//! Loads settings from `~/.config/tele-tui/config.toml`.
|
||||
//! Structs: Config, GeneralConfig, ColorsConfig, NotificationsConfig, Keybindings.
|
||||
|
||||
pub mod keybindings;
|
||||
mod loader;
|
||||
mod validation;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub use keybindings::{Command, Keybindings};
|
||||
|
||||
/// Главная конфигурация приложения.
|
||||
///
|
||||
/// Загружается из `~/.config/tele-tui/config.toml` и содержит настройки
|
||||
/// общего поведения, цветовой схемы и горячих клавиш.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// // Загрузка конфигурации
|
||||
/// let config = Config::load();
|
||||
///
|
||||
/// // Доступ к настройкам
|
||||
/// println!("Timezone: {}", config.general.timezone);
|
||||
/// println!("Incoming color: {}", config.colors.incoming_message);
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct Config {
|
||||
/// Общие настройки (timezone и т.д.).
|
||||
#[serde(default)]
|
||||
pub general: GeneralConfig,
|
||||
|
||||
/// Цветовая схема интерфейса.
|
||||
#[serde(default)]
|
||||
pub colors: ColorsConfig,
|
||||
|
||||
/// Горячие клавиши.
|
||||
#[serde(default)]
|
||||
pub keybindings: Keybindings,
|
||||
|
||||
/// Настройки desktop notifications.
|
||||
#[serde(default)]
|
||||
pub notifications: NotificationsConfig,
|
||||
|
||||
/// Настройки отображения изображений.
|
||||
#[serde(default)]
|
||||
pub images: ImagesConfig,
|
||||
|
||||
/// Настройки аудио (голосовые сообщения).
|
||||
#[serde(default)]
|
||||
pub audio: AudioConfig,
|
||||
}
|
||||
|
||||
/// Общие настройки приложения.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GeneralConfig {
|
||||
/// Часовой пояс в формате "+03:00" или "-05:00"
|
||||
#[serde(default = "default_timezone")]
|
||||
pub timezone: String,
|
||||
}
|
||||
|
||||
/// Цветовая схема интерфейса.
|
||||
///
|
||||
/// Поддерживаемые цвета: red, green, blue, yellow, cyan, magenta,
|
||||
/// white, black, gray/grey, а также light-варианты (lightred, lightgreen и т.д.).
|
||||
#[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,
|
||||
}
|
||||
|
||||
/// Настройки desktop notifications.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NotificationsConfig {
|
||||
/// Включить/выключить уведомления
|
||||
#[serde(default = "default_notifications_enabled")]
|
||||
pub enabled: bool,
|
||||
|
||||
/// Уведомлять только при @упоминаниях
|
||||
#[serde(default)]
|
||||
pub only_mentions: bool,
|
||||
|
||||
/// Показывать превью текста сообщения
|
||||
#[serde(default = "default_show_preview")]
|
||||
pub show_preview: bool,
|
||||
|
||||
/// Продолжительность показа уведомления (миллисекунды)
|
||||
/// 0 = системное значение по умолчанию
|
||||
#[serde(default = "default_notification_timeout")]
|
||||
pub timeout_ms: i32,
|
||||
|
||||
/// Уровень важности: "low", "normal", "critical"
|
||||
#[serde(default = "default_notification_urgency")]
|
||||
pub urgency: String,
|
||||
}
|
||||
|
||||
/// Настройки отображения изображений.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ImagesConfig {
|
||||
/// Показывать превью изображений в чате
|
||||
#[serde(default = "default_show_images")]
|
||||
pub show_images: bool,
|
||||
|
||||
/// Размер кэша изображений (в МБ)
|
||||
#[serde(default = "default_image_cache_size_mb")]
|
||||
pub cache_size_mb: u64,
|
||||
|
||||
/// Максимальная ширина inline превью (в символах)
|
||||
#[serde(default = "default_inline_image_max_width")]
|
||||
pub inline_image_max_width: usize,
|
||||
|
||||
/// Автоматически загружать изображения при открытии чата
|
||||
#[serde(default = "default_auto_download_images")]
|
||||
pub auto_download_images: bool,
|
||||
}
|
||||
|
||||
impl Default for ImagesConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
show_images: default_show_images(),
|
||||
cache_size_mb: default_image_cache_size_mb(),
|
||||
inline_image_max_width: default_inline_image_max_width(),
|
||||
auto_download_images: default_auto_download_images(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Настройки аудио (голосовые сообщения).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AudioConfig {
|
||||
/// Размер кэша голосовых файлов (в МБ)
|
||||
#[serde(default = "default_audio_cache_size_mb")]
|
||||
pub cache_size_mb: u64,
|
||||
|
||||
/// Автоматически загружать голосовые при открытии чата
|
||||
#[serde(default = "default_auto_download_voice")]
|
||||
pub auto_download_voice: bool,
|
||||
}
|
||||
|
||||
impl Default for AudioConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
cache_size_mb: default_audio_cache_size_mb(),
|
||||
auto_download_voice: default_auto_download_voice(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Дефолтные значения (используются serde атрибутами)
|
||||
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()
|
||||
}
|
||||
|
||||
fn default_notifications_enabled() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn default_show_preview() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_notification_timeout() -> i32 {
|
||||
5000 // 5 seconds
|
||||
}
|
||||
|
||||
fn default_notification_urgency() -> String {
|
||||
"normal".to_string()
|
||||
}
|
||||
|
||||
fn default_show_images() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_image_cache_size_mb() -> u64 {
|
||||
crate::constants::DEFAULT_IMAGE_CACHE_SIZE_MB
|
||||
}
|
||||
|
||||
fn default_inline_image_max_width() -> usize {
|
||||
crate::constants::INLINE_IMAGE_MAX_WIDTH
|
||||
}
|
||||
|
||||
fn default_auto_download_images() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_audio_cache_size_mb() -> u64 {
|
||||
crate::constants::DEFAULT_AUDIO_CACHE_SIZE_MB
|
||||
}
|
||||
|
||||
fn default_auto_download_voice() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
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 NotificationsConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: default_notifications_enabled(),
|
||||
only_mentions: false,
|
||||
show_preview: default_show_preview(),
|
||||
timeout_ms: default_notification_timeout(),
|
||||
urgency: default_notification_urgency(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
|
||||
#[test]
|
||||
fn test_config_default_includes_keybindings() {
|
||||
let config = Config::default();
|
||||
let keybindings = &config.keybindings;
|
||||
|
||||
// Test that keybindings exist for common commands
|
||||
assert!(
|
||||
keybindings.get_command(&KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE))
|
||||
== Some(Command::ReplyMessage)
|
||||
);
|
||||
assert!(
|
||||
keybindings.get_command(&KeyEvent::new(KeyCode::Char('к'), KeyModifiers::NONE))
|
||||
== Some(Command::ReplyMessage)
|
||||
);
|
||||
assert!(
|
||||
keybindings.get_command(&KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE))
|
||||
== Some(Command::ForwardMessage)
|
||||
);
|
||||
assert!(
|
||||
keybindings.get_command(&KeyEvent::new(KeyCode::Char('а'), KeyModifiers::NONE))
|
||||
== Some(Command::ForwardMessage)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_validate_valid() {
|
||||
let config = Config::default();
|
||||
assert!(config.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_validate_invalid_timezone_no_sign() {
|
||||
let mut config = Config::default();
|
||||
config.general.timezone = "03:00".to_string();
|
||||
|
||||
let result = config.validate();
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("timezone"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_validate_valid_negative_timezone() {
|
||||
let mut config = Config::default();
|
||||
config.general.timezone = "-05:00".to_string();
|
||||
|
||||
assert!(config.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_validate_valid_positive_timezone() {
|
||||
let mut config = Config::default();
|
||||
config.general.timezone = "+09:00".to_string();
|
||||
|
||||
assert!(config.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_validate_invalid_color_incoming() {
|
||||
let mut config = Config::default();
|
||||
config.colors.incoming_message = "rainbow".to_string();
|
||||
|
||||
let result = config.validate();
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Invalid color"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_validate_invalid_color_outgoing() {
|
||||
let mut config = Config::default();
|
||||
config.colors.outgoing_message = "purple".to_string();
|
||||
|
||||
let result = config.validate();
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Invalid color"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_validate_invalid_color_selected() {
|
||||
let mut config = Config::default();
|
||||
config.colors.selected_message = "pink".to_string();
|
||||
|
||||
let result = config.validate();
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Invalid color"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_validate_valid_all_standard_colors() {
|
||||
let colors = [
|
||||
"black",
|
||||
"red",
|
||||
"green",
|
||||
"yellow",
|
||||
"blue",
|
||||
"magenta",
|
||||
"cyan",
|
||||
"gray",
|
||||
"grey",
|
||||
"white",
|
||||
"darkgray",
|
||||
"darkgrey",
|
||||
"lightred",
|
||||
"lightgreen",
|
||||
"lightyellow",
|
||||
"lightblue",
|
||||
"lightmagenta",
|
||||
"lightcyan",
|
||||
];
|
||||
|
||||
for color in colors {
|
||||
let mut config = Config::default();
|
||||
config.colors.incoming_message = color.to_string();
|
||||
config.colors.outgoing_message = color.to_string();
|
||||
config.colors.selected_message = color.to_string();
|
||||
config.colors.reaction_chosen = color.to_string();
|
||||
config.colors.reaction_other = color.to_string();
|
||||
|
||||
assert!(config.validate().is_ok(), "Color '{}' should be valid", color);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_validate_case_insensitive_colors() {
|
||||
let mut config = Config::default();
|
||||
config.colors.incoming_message = "RED".to_string();
|
||||
config.colors.outgoing_message = "Green".to_string();
|
||||
config.colors.selected_message = "YELLOW".to_string();
|
||||
|
||||
assert!(config.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_color_standard() {
|
||||
let config = Config::default();
|
||||
|
||||
use ratatui::style::Color;
|
||||
assert_eq!(config.parse_color("red"), Color::Red);
|
||||
assert_eq!(config.parse_color("green"), Color::Green);
|
||||
assert_eq!(config.parse_color("blue"), Color::Blue);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_color_light_variants() {
|
||||
let config = Config::default();
|
||||
|
||||
use ratatui::style::Color;
|
||||
assert_eq!(config.parse_color("lightred"), Color::LightRed);
|
||||
assert_eq!(config.parse_color("lightgreen"), Color::LightGreen);
|
||||
assert_eq!(config.parse_color("lightblue"), Color::LightBlue);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_color_gray_variants() {
|
||||
let config = Config::default();
|
||||
|
||||
use ratatui::style::Color;
|
||||
assert_eq!(config.parse_color("gray"), Color::Gray);
|
||||
assert_eq!(config.parse_color("grey"), Color::Gray);
|
||||
assert_eq!(config.parse_color("darkgray"), Color::DarkGray);
|
||||
assert_eq!(config.parse_color("darkgrey"), Color::DarkGray);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_color_case_insensitive() {
|
||||
let config = Config::default();
|
||||
|
||||
use ratatui::style::Color;
|
||||
assert_eq!(config.parse_color("RED"), Color::Red);
|
||||
assert_eq!(config.parse_color("Green"), Color::Green);
|
||||
assert_eq!(config.parse_color("LIGHTBLUE"), Color::LightBlue);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_color_invalid_fallback() {
|
||||
let config = Config::default();
|
||||
|
||||
use ratatui::style::Color;
|
||||
// Invalid colors should fallback to White
|
||||
assert_eq!(config.parse_color("rainbow"), Color::White);
|
||||
assert_eq!(config.parse_color("purple"), Color::White);
|
||||
assert_eq!(config.parse_color("unknown"), Color::White);
|
||||
}
|
||||
}
|
||||
88
src/config/validation.rs
Normal file
88
src/config/validation.rs
Normal file
@@ -0,0 +1,88 @@
|
||||
//! Config validation: timezone format, color names, notification settings.
|
||||
|
||||
use super::Config;
|
||||
|
||||
impl Config {
|
||||
/// Валидация конфигурации
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
// Проверка timezone
|
||||
if !self.general.timezone.starts_with('+') && !self.general.timezone.starts_with('-') {
|
||||
return Err(format!(
|
||||
"Invalid timezone (must start with + or -): {}",
|
||||
self.general.timezone
|
||||
));
|
||||
}
|
||||
|
||||
// Проверка цветов
|
||||
let valid_colors = [
|
||||
"black",
|
||||
"red",
|
||||
"green",
|
||||
"yellow",
|
||||
"blue",
|
||||
"magenta",
|
||||
"cyan",
|
||||
"gray",
|
||||
"grey",
|
||||
"white",
|
||||
"darkgray",
|
||||
"darkgrey",
|
||||
"lightred",
|
||||
"lightgreen",
|
||||
"lightyellow",
|
||||
"lightblue",
|
||||
"lightmagenta",
|
||||
"lightcyan",
|
||||
];
|
||||
|
||||
for color_name in [
|
||||
&self.colors.incoming_message,
|
||||
&self.colors.outgoing_message,
|
||||
&self.colors.selected_message,
|
||||
&self.colors.reaction_chosen,
|
||||
&self.colors.reaction_other,
|
||||
] {
|
||||
if !valid_colors.contains(&color_name.to_lowercase().as_str()) {
|
||||
return Err(format!("Invalid color: {}", color_name));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Парсит строку цвета в `ratatui::style::Color`.
|
||||
///
|
||||
/// Поддерживает стандартные цвета (red, green, blue и т.д.),
|
||||
/// light-варианты (lightred, lightgreen и т.д.) и grey/gray.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `color_str` - Название цвета (case-insensitive)
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// `Color` - Соответствующий цвет или `White` если цвет не распознан
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// Application constants
|
||||
//! Application-wide constants (memory limits, timeouts, UI sizes).
|
||||
|
||||
// ============================================================================
|
||||
// Memory Limits
|
||||
@@ -16,25 +16,6 @@ pub const MAX_CHATS: usize = 200;
|
||||
/// Максимальное количество user_ids для хранения в чате
|
||||
pub const MAX_CHAT_USER_IDS: usize = 500;
|
||||
|
||||
// ============================================================================
|
||||
// UI Constants
|
||||
// ============================================================================
|
||||
|
||||
/// Количество колонок в emoji picker сетке
|
||||
pub const EMOJI_PICKER_COLUMNS: usize = 8;
|
||||
|
||||
/// Количество рядов в emoji picker сетке
|
||||
pub const EMOJI_PICKER_ROWS: usize = 6;
|
||||
|
||||
/// Максимальная высота поля ввода (в строках)
|
||||
pub const MAX_INPUT_HEIGHT: usize = 10;
|
||||
|
||||
/// Минимальная ширина терминала для корректного отображения
|
||||
pub const MIN_TERMINAL_WIDTH: u16 = 80;
|
||||
|
||||
/// Минимальная высота терминала для корректного отображения
|
||||
pub const MIN_TERMINAL_HEIGHT: u16 = 20;
|
||||
|
||||
// ============================================================================
|
||||
// Performance
|
||||
// ============================================================================
|
||||
@@ -52,18 +33,52 @@ pub const LAZY_LOAD_USERS_PER_TICK: usize = 5;
|
||||
// TDLib
|
||||
// ============================================================================
|
||||
|
||||
/// Лимит количества чатов для загрузки через TDLib за раз
|
||||
pub const TDLIB_CHAT_LIMIT: i32 = 50;
|
||||
|
||||
/// Лимит количества сообщений для загрузки через TDLib за раз
|
||||
pub const TDLIB_MESSAGE_LIMIT: i32 = 50;
|
||||
|
||||
// ============================================================================
|
||||
// Formatting
|
||||
// Images
|
||||
// ============================================================================
|
||||
|
||||
/// Максимальная длина имени пользователя для отображения
|
||||
pub const MAX_USERNAME_DISPLAY_LENGTH: usize = 20;
|
||||
/// Максимальная ширина превью изображения (в символах)
|
||||
pub const MAX_IMAGE_WIDTH: u16 = 30;
|
||||
|
||||
/// Отступ для wrap текста сообщений
|
||||
pub const MESSAGE_TEXT_INDENT: usize = 2;
|
||||
/// Максимальная высота превью изображения (в строках)
|
||||
pub const MAX_IMAGE_HEIGHT: u16 = 15;
|
||||
|
||||
/// Минимальная высота превью изображения (в строках)
|
||||
pub const MIN_IMAGE_HEIGHT: u16 = 3;
|
||||
|
||||
/// Таймаут скачивания файла (в секундах)
|
||||
#[allow(dead_code)]
|
||||
pub const FILE_DOWNLOAD_TIMEOUT_SECS: u64 = 30;
|
||||
|
||||
/// Размер кэша изображений по умолчанию (в МБ)
|
||||
pub const DEFAULT_IMAGE_CACHE_SIZE_MB: u64 = 500;
|
||||
|
||||
/// Максимальная ширина inline превью изображений (в символах)
|
||||
#[cfg(feature = "images")]
|
||||
pub const INLINE_IMAGE_MAX_WIDTH: usize = 50;
|
||||
|
||||
/// Ширина одного фото в альбоме (в символах)
|
||||
#[cfg(feature = "images")]
|
||||
pub const ALBUM_PHOTO_WIDTH: u16 = 16;
|
||||
|
||||
/// Высота одного фото в альбоме (в строках)
|
||||
#[cfg(feature = "images")]
|
||||
pub const ALBUM_PHOTO_HEIGHT: u16 = 8;
|
||||
|
||||
/// Отступ между фото в альбоме (в символах)
|
||||
#[cfg(feature = "images")]
|
||||
pub const ALBUM_PHOTO_GAP: u16 = 1;
|
||||
|
||||
/// Максимальное количество фото в одном ряду альбома
|
||||
#[cfg(feature = "images")]
|
||||
pub const ALBUM_GRID_MAX_COLS: usize = 3;
|
||||
|
||||
// ============================================================================
|
||||
// Audio
|
||||
// ============================================================================
|
||||
|
||||
/// Размер кэша голосовых сообщений по умолчанию (в МБ)
|
||||
pub const DEFAULT_AUDIO_CACHE_SIZE_MB: u64 = 100;
|
||||
|
||||
101
src/error.rs
101
src/error.rs
@@ -1,101 +0,0 @@
|
||||
/// Error types for tele-tui application
|
||||
///
|
||||
/// Provides type-safe error handling across the application,
|
||||
/// replacing generic String errors with structured variants.
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum TeletuiError {
|
||||
/// TDLib-related errors
|
||||
#[error("TDLib error: {0}")]
|
||||
TdLib(String),
|
||||
|
||||
/// Configuration errors
|
||||
#[error("Configuration error: {0}")]
|
||||
Config(String),
|
||||
|
||||
/// Network connectivity errors
|
||||
#[error("Network error: {0}")]
|
||||
Network(String),
|
||||
|
||||
/// Authentication errors
|
||||
#[error("Authentication error: {0}")]
|
||||
Auth(String),
|
||||
|
||||
/// Invalid timezone format
|
||||
#[error("Invalid timezone format: {0}")]
|
||||
InvalidTimezone(String),
|
||||
|
||||
/// Invalid color value
|
||||
#[error("Invalid color: {0}")]
|
||||
InvalidColor(String),
|
||||
|
||||
/// Message operation errors
|
||||
#[error("Message error: {0}")]
|
||||
Message(String),
|
||||
|
||||
/// Chat operation errors
|
||||
#[error("Chat error: {0}")]
|
||||
Chat(String),
|
||||
|
||||
/// User operation errors
|
||||
#[error("User error: {0}")]
|
||||
User(String),
|
||||
|
||||
/// File system errors
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
/// TOML parsing errors
|
||||
#[error("TOML error: {0}")]
|
||||
Toml(#[from] toml::de::Error),
|
||||
|
||||
/// JSON parsing errors
|
||||
#[error("JSON error: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
|
||||
/// Clipboard errors
|
||||
#[error("Clipboard error: {0}")]
|
||||
Clipboard(String),
|
||||
|
||||
/// Generic error for cases not covered by specific variants
|
||||
#[error("{0}")]
|
||||
Other(String),
|
||||
}
|
||||
|
||||
/// Result type alias using TeletuiError
|
||||
pub type Result<T> = std::result::Result<T, TeletuiError>;
|
||||
|
||||
/// Helper trait for converting String errors to TeletuiError
|
||||
pub trait IntoTeletuiError {
|
||||
fn into_teletui_error(self, variant: ErrorVariant) -> TeletuiError;
|
||||
}
|
||||
|
||||
impl IntoTeletuiError for String {
|
||||
fn into_teletui_error(self, variant: ErrorVariant) -> TeletuiError {
|
||||
match variant {
|
||||
ErrorVariant::TdLib => TeletuiError::TdLib(self),
|
||||
ErrorVariant::Config => TeletuiError::Config(self),
|
||||
ErrorVariant::Network => TeletuiError::Network(self),
|
||||
ErrorVariant::Auth => TeletuiError::Auth(self),
|
||||
ErrorVariant::Message => TeletuiError::Message(self),
|
||||
ErrorVariant::Chat => TeletuiError::Chat(self),
|
||||
ErrorVariant::User => TeletuiError::User(self),
|
||||
ErrorVariant::Clipboard => TeletuiError::Clipboard(self),
|
||||
ErrorVariant::Other => TeletuiError::Other(self),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Error variant selector for conversion
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum ErrorVariant {
|
||||
TdLib,
|
||||
Config,
|
||||
Network,
|
||||
Auth,
|
||||
Message,
|
||||
Chat,
|
||||
User,
|
||||
Clipboard,
|
||||
Other,
|
||||
}
|
||||
@@ -126,23 +126,25 @@ pub fn format_text_with_entities(
|
||||
let start = entity.offset as usize;
|
||||
let end = (entity.offset + entity.length) as usize;
|
||||
|
||||
for i in start..end.min(chars.len()) {
|
||||
for item in char_styles
|
||||
.iter_mut()
|
||||
.take(end.min(chars.len()))
|
||||
.skip(start)
|
||||
{
|
||||
match &entity.r#type {
|
||||
TextEntityType::Bold => char_styles[i].bold = true,
|
||||
TextEntityType::Italic => char_styles[i].italic = true,
|
||||
TextEntityType::Underline => char_styles[i].underline = true,
|
||||
TextEntityType::Strikethrough => char_styles[i].strikethrough = true,
|
||||
TextEntityType::Bold => item.bold = true,
|
||||
TextEntityType::Italic => item.italic = true,
|
||||
TextEntityType::Underline => item.underline = true,
|
||||
TextEntityType::Strikethrough => item.strikethrough = true,
|
||||
TextEntityType::Code | TextEntityType::Pre | TextEntityType::PreCode(_) => {
|
||||
char_styles[i].code = true
|
||||
item.code = true
|
||||
}
|
||||
TextEntityType::Spoiler => char_styles[i].spoiler = true,
|
||||
TextEntityType::Spoiler => item.spoiler = true,
|
||||
TextEntityType::Url
|
||||
| TextEntityType::TextUrl(_)
|
||||
| TextEntityType::EmailAddress
|
||||
| TextEntityType::PhoneNumber => char_styles[i].url = true,
|
||||
TextEntityType::Mention | TextEntityType::MentionName(_) => {
|
||||
char_styles[i].mention = true
|
||||
}
|
||||
| TextEntityType::PhoneNumber => item.url = true,
|
||||
TextEntityType::Mention | TextEntityType::MentionName(_) => item.mention = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -277,11 +279,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_format_text_with_bold() {
|
||||
let text = "Hello";
|
||||
let entities = vec![TextEntity {
|
||||
offset: 0,
|
||||
length: 5,
|
||||
r#type: TextEntityType::Bold,
|
||||
}];
|
||||
let entities = vec![TextEntity { offset: 0, length: 5, r#type: TextEntityType::Bold }];
|
||||
let spans = format_text_with_entities(text, &entities, Color::White);
|
||||
|
||||
assert_eq!(spans.len(), 1);
|
||||
|
||||
@@ -1,41 +1,39 @@
|
||||
use crate::app::App;
|
||||
use crate::tdlib::AuthState;
|
||||
use crate::tdlib::{AuthState, TdClientTrait};
|
||||
use crate::utils::{is_non_empty, with_timeout_msg};
|
||||
use crossterm::event::KeyCode;
|
||||
use std::time::Duration;
|
||||
use tokio::time::timeout;
|
||||
|
||||
pub async fn handle(app: &mut App, key_code: KeyCode) {
|
||||
pub async fn handle<T: TdClientTrait>(app: &mut App<T>, key_code: KeyCode) {
|
||||
match &app.td_client.auth_state() {
|
||||
AuthState::WaitPhoneNumber => match key_code {
|
||||
KeyCode::Char(c) => {
|
||||
app.phone_input.push(c);
|
||||
app.phone_input_mut().push(c);
|
||||
app.error_message = None;
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
app.phone_input.pop();
|
||||
app.phone_input_mut().pop();
|
||||
app.error_message = None;
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if !app.phone_input.is_empty() {
|
||||
if is_non_empty(app.phone_input()) {
|
||||
app.status_message = Some("Отправка номера...".to_string());
|
||||
match timeout(
|
||||
match with_timeout_msg(
|
||||
Duration::from_secs(10),
|
||||
app.td_client.send_phone_number(app.phone_input.clone()),
|
||||
app.td_client
|
||||
.send_phone_number(app.phone_input().to_string()),
|
||||
"Таймаут отправки номера",
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(_)) => {
|
||||
Ok(_) => {
|
||||
app.error_message = None;
|
||||
app.status_message = None;
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
Err(e) => {
|
||||
app.error_message = Some(e);
|
||||
app.status_message = None;
|
||||
}
|
||||
Err(_) => {
|
||||
app.error_message = Some("Таймаут".to_string());
|
||||
app.status_message = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -43,34 +41,31 @@ pub async fn handle(app: &mut App, key_code: KeyCode) {
|
||||
},
|
||||
AuthState::WaitCode => match key_code {
|
||||
KeyCode::Char(c) if c.is_numeric() => {
|
||||
app.code_input.push(c);
|
||||
app.code_input_mut().push(c);
|
||||
app.error_message = None;
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
app.code_input.pop();
|
||||
app.code_input_mut().pop();
|
||||
app.error_message = None;
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if !app.code_input.is_empty() {
|
||||
if is_non_empty(app.code_input()) {
|
||||
app.status_message = Some("Проверка кода...".to_string());
|
||||
match timeout(
|
||||
match with_timeout_msg(
|
||||
Duration::from_secs(10),
|
||||
app.td_client.send_code(app.code_input.clone()),
|
||||
app.td_client.send_code(app.code_input().to_string()),
|
||||
"Таймаут проверки кода",
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(_)) => {
|
||||
Ok(_) => {
|
||||
app.error_message = None;
|
||||
app.status_message = None;
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
Err(e) => {
|
||||
app.error_message = Some(e);
|
||||
app.status_message = None;
|
||||
}
|
||||
Err(_) => {
|
||||
app.error_message = Some("Таймаут".to_string());
|
||||
app.status_message = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -78,34 +73,32 @@ pub async fn handle(app: &mut App, key_code: KeyCode) {
|
||||
},
|
||||
AuthState::WaitPassword => match key_code {
|
||||
KeyCode::Char(c) => {
|
||||
app.password_input.push(c);
|
||||
app.password_input_mut().push(c);
|
||||
app.error_message = None;
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
app.password_input.pop();
|
||||
app.password_input_mut().pop();
|
||||
app.error_message = None;
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if !app.password_input.is_empty() {
|
||||
if is_non_empty(app.password_input()) {
|
||||
app.status_message = Some("Проверка пароля...".to_string());
|
||||
match timeout(
|
||||
match with_timeout_msg(
|
||||
Duration::from_secs(10),
|
||||
app.td_client.send_password(app.password_input.clone()),
|
||||
app.td_client
|
||||
.send_password(app.password_input().to_string()),
|
||||
"Таймаут проверки пароля",
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(_)) => {
|
||||
Ok(_) => {
|
||||
app.error_message = None;
|
||||
app.status_message = None;
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
Err(e) => {
|
||||
app.error_message = Some(e);
|
||||
app.status_message = None;
|
||||
}
|
||||
Err(_) => {
|
||||
app.error_message = Some("Таймаут".to_string());
|
||||
app.status_message = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
812
src/input/handlers/chat.rs
Normal file
812
src/input/handlers/chat.rs
Normal file
@@ -0,0 +1,812 @@
|
||||
//! Chat input handlers
|
||||
//!
|
||||
//! Handles keyboard input when a chat is open, including:
|
||||
//! - Message scrolling and navigation
|
||||
//! - Message selection and actions
|
||||
//! - Editing and sending messages
|
||||
//! - Loading older messages
|
||||
|
||||
use super::chat_loader::{load_older_messages_if_needed, open_chat_and_load_data};
|
||||
use crate::app::methods::{
|
||||
compose::ComposeMethods, messages::MessageMethods, modal::ModalMethods,
|
||||
navigation::NavigationMethods,
|
||||
};
|
||||
use crate::app::App;
|
||||
use crate::app::InputMode;
|
||||
use crate::input::handlers::{copy_to_clipboard, format_message_for_clipboard};
|
||||
use crate::tdlib::{ChatAction, TdClientTrait};
|
||||
use crate::types::{ChatId, MessageId};
|
||||
use crate::utils::{is_non_empty, with_timeout_msg};
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Обработка режима выбора сообщения для действий
|
||||
///
|
||||
/// Обрабатывает:
|
||||
/// - Навигацию по сообщениям (Up/Down)
|
||||
/// - Удаление сообщения (d/в/Delete)
|
||||
/// - Ответ на сообщение (r/к)
|
||||
/// - Пересылку сообщения (f/а)
|
||||
/// - Копирование сообщения (y/н)
|
||||
/// - Добавление реакции (e/у)
|
||||
pub async fn handle_message_selection<T: TdClientTrait>(
|
||||
app: &mut App<T>,
|
||||
_key: KeyEvent,
|
||||
command: Option<crate::config::Command>,
|
||||
) {
|
||||
match command {
|
||||
Some(crate::config::Command::MoveUp) => {
|
||||
app.select_previous_message();
|
||||
}
|
||||
Some(crate::config::Command::MoveDown) => {
|
||||
app.select_next_message();
|
||||
}
|
||||
Some(crate::config::Command::DeleteMessage) => {
|
||||
let Some(msg) = app.get_selected_message() else {
|
||||
return;
|
||||
};
|
||||
let can_delete =
|
||||
msg.can_be_deleted_only_for_self() || msg.can_be_deleted_for_all_users();
|
||||
if can_delete {
|
||||
app.chat_state = crate::app::ChatState::DeleteConfirmation { message_id: msg.id() };
|
||||
}
|
||||
}
|
||||
Some(crate::config::Command::EnterInsertMode) => {
|
||||
app.input_mode = InputMode::Insert;
|
||||
app.chat_state = crate::app::ChatState::Normal;
|
||||
}
|
||||
Some(crate::config::Command::ReplyMessage) => {
|
||||
app.start_reply_to_selected();
|
||||
app.input_mode = InputMode::Insert;
|
||||
}
|
||||
Some(crate::config::Command::ForwardMessage) => {
|
||||
app.start_forward_selected();
|
||||
}
|
||||
Some(crate::config::Command::CopyMessage) => {
|
||||
let Some(msg) = app.get_selected_message() else {
|
||||
return;
|
||||
};
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(crate::config::Command::ViewImage) => {
|
||||
handle_view_or_play_media(app).await;
|
||||
}
|
||||
Some(crate::config::Command::TogglePlayback) => {
|
||||
handle_toggle_voice_playback(app).await;
|
||||
}
|
||||
Some(crate::config::Command::SeekForward | crate::config::Command::MoveRight) => {
|
||||
handle_voice_seek(app, 5.0);
|
||||
}
|
||||
Some(crate::config::Command::SeekBackward | crate::config::Command::MoveLeft) => {
|
||||
handle_voice_seek(app, -5.0);
|
||||
}
|
||||
Some(crate::config::Command::ReactMessage) => {
|
||||
let Some(msg) = app.get_selected_message() else {
|
||||
return;
|
||||
};
|
||||
let chat_id = app.selected_chat_id.unwrap();
|
||||
let message_id = msg.id();
|
||||
|
||||
app.status_message = Some("Загрузка реакций...".to_string());
|
||||
app.needs_redraw = true;
|
||||
|
||||
match with_timeout_msg(
|
||||
Duration::from_secs(5),
|
||||
app.td_client
|
||||
.get_message_available_reactions(chat_id, message_id),
|
||||
"Таймаут загрузки реакций",
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(reactions) => {
|
||||
let reactions: Vec<String> = 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.as_i64(), reactions);
|
||||
app.status_message = None;
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
app.error_message = Some(e);
|
||||
app.status_message = None;
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Редактирование существующего сообщения
|
||||
pub async fn edit_message<T: TdClientTrait>(
|
||||
app: &mut App<T>,
|
||||
chat_id: i64,
|
||||
msg_id: MessageId,
|
||||
text: String,
|
||||
) {
|
||||
// Проверяем, что сообщение есть в локальном кэше
|
||||
let msg_exists = app
|
||||
.td_client
|
||||
.current_chat_messages()
|
||||
.iter()
|
||||
.any(|m| m.id() == msg_id);
|
||||
|
||||
if !msg_exists {
|
||||
app.error_message =
|
||||
Some(format!("Сообщение {} не найдено в кэше чата {}", msg_id.as_i64(), chat_id));
|
||||
app.chat_state = crate::app::ChatState::Normal;
|
||||
app.message_input.clear();
|
||||
app.cursor_position = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
match with_timeout_msg(
|
||||
Duration::from_secs(5),
|
||||
app.td_client
|
||||
.edit_message(ChatId::new(chat_id), msg_id, text),
|
||||
"Таймаут редактирования",
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(mut edited_msg) => {
|
||||
// Сохраняем reply_to из старого сообщения (если есть)
|
||||
let messages = app.td_client.current_chat_messages_mut();
|
||||
if let Some(pos) = messages.iter().position(|m| m.id() == msg_id) {
|
||||
let old_reply_to = messages[pos].interactions.reply_to.clone();
|
||||
// Если в старом сообщении был reply и в новом он "Unknown" - сохраняем старый
|
||||
if let Some(old_reply) = old_reply_to {
|
||||
if edited_msg
|
||||
.interactions
|
||||
.reply_to
|
||||
.as_ref()
|
||||
.is_none_or(|r| r.sender_name == "Unknown")
|
||||
{
|
||||
edited_msg.interactions.reply_to = Some(old_reply);
|
||||
}
|
||||
}
|
||||
// Заменяем сообщение
|
||||
messages[pos] = edited_msg;
|
||||
}
|
||||
// Очищаем инпут и сбрасываем состояние ПОСЛЕ успешного редактирования
|
||||
app.message_input.clear();
|
||||
app.cursor_position = 0;
|
||||
app.chat_state = crate::app::ChatState::Normal;
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
Err(e) => {
|
||||
app.error_message = Some(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Отправка нового сообщения (с опциональным reply)
|
||||
pub async fn send_new_message<T: TdClientTrait>(app: &mut App<T>, chat_id: i64, text: String) {
|
||||
let reply_to_id = if app.is_replying() {
|
||||
app.chat_state.selected_message_id()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Создаём ReplyInfo ДО отправки, пока сообщение точно доступно
|
||||
let reply_info = app
|
||||
.get_replying_to_message()
|
||||
.map(|m| crate::tdlib::ReplyInfo {
|
||||
message_id: m.id(),
|
||||
sender_name: m.sender_name().to_string(),
|
||||
text: m.text().to_string(),
|
||||
});
|
||||
|
||||
app.message_input.clear();
|
||||
app.cursor_position = 0;
|
||||
// Сбрасываем режим reply если он был активен
|
||||
if app.is_replying() {
|
||||
app.chat_state = crate::app::ChatState::Normal;
|
||||
}
|
||||
app.last_typing_sent = None;
|
||||
|
||||
// Отменяем typing status
|
||||
app.td_client
|
||||
.send_chat_action(ChatId::new(chat_id), ChatAction::Cancel)
|
||||
.await;
|
||||
|
||||
match with_timeout_msg(
|
||||
Duration::from_secs(5),
|
||||
app.td_client
|
||||
.send_message(ChatId::new(chat_id), text, reply_to_id, reply_info),
|
||||
"Таймаут отправки",
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(sent_msg) => {
|
||||
// Добавляем отправленное сообщение в список (с лимитом)
|
||||
app.td_client.push_message(sent_msg);
|
||||
// Сбрасываем скролл чтобы видеть новое сообщение
|
||||
app.message_scroll_offset = 0;
|
||||
}
|
||||
Err(e) => {
|
||||
app.error_message = Some(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Обработка клавиши Enter
|
||||
///
|
||||
/// Обрабатывает три сценария:
|
||||
/// 1. В режиме выбора сообщения: начать редактирование
|
||||
/// 2. В открытом чате: отправить новое или редактировать существующее сообщение
|
||||
/// 3. В списке чатов: открыть выбранный чат
|
||||
pub async fn handle_enter_key<T: TdClientTrait>(app: &mut App<T>) {
|
||||
// Сценарий 1: Открытие чата из списка
|
||||
if app.selected_chat_id.is_none() {
|
||||
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() {
|
||||
open_chat_and_load_data(app, chat_id).await;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Сценарий 2: Режим выбора сообщения - начать редактирование
|
||||
if app.is_selecting_message() {
|
||||
if app.start_editing_selected() {
|
||||
app.input_mode = InputMode::Insert;
|
||||
} else {
|
||||
// Нельзя редактировать это сообщение
|
||||
app.chat_state = crate::app::ChatState::Normal;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Сценарий 3: Отправка или редактирование сообщения
|
||||
if !is_non_empty(&app.message_input) {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(chat_id) = app.get_selected_chat_id() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let text = app.message_input.clone();
|
||||
|
||||
if app.is_editing() {
|
||||
// Редактирование существующего сообщения
|
||||
if let Some(msg_id) = app.chat_state.selected_message_id() {
|
||||
edit_message(app, chat_id, msg_id, text).await;
|
||||
}
|
||||
} else {
|
||||
// Отправка нового сообщения
|
||||
send_new_message(app, chat_id, text).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Отправляет реакцию на выбранное сообщение
|
||||
pub async fn send_reaction<T: TdClientTrait>(app: &mut App<T>) {
|
||||
// Get selected reaction emoji
|
||||
let Some(emoji) = app.get_selected_reaction().cloned() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Get selected message ID
|
||||
let Some(message_id) = app.get_selected_message_for_reaction() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Get chat ID
|
||||
let Some(chat_id) = app.selected_chat_id else {
|
||||
return;
|
||||
};
|
||||
|
||||
let message_id = MessageId::new(message_id);
|
||||
app.status_message = Some("Отправка реакции...".to_string());
|
||||
app.needs_redraw = true;
|
||||
|
||||
// Send reaction with timeout
|
||||
let result = with_timeout_msg(
|
||||
Duration::from_secs(5),
|
||||
app.td_client
|
||||
.toggle_reaction(chat_id, message_id, emoji.clone()),
|
||||
"Таймаут отправки реакции",
|
||||
)
|
||||
.await;
|
||||
|
||||
// Handle result
|
||||
match result {
|
||||
Ok(_) => {
|
||||
app.status_message = Some(format!("Реакция {} добавлена", emoji));
|
||||
app.exit_reaction_picker_mode();
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
Err(e) => {
|
||||
app.error_message = Some(e);
|
||||
app.status_message = None;
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Обработка ввода клавиатуры в открытом чате
|
||||
///
|
||||
/// Обрабатывает:
|
||||
/// - Backspace/Delete: удаление символов относительно курсора
|
||||
/// - Char: вставка символов в позицию курсора + typing status
|
||||
/// - Left/Right/Home/End: навигация курсора
|
||||
/// - Up/Down: скролл сообщений или начало режима выбора
|
||||
pub async fn handle_open_chat_keyboard_input<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent) {
|
||||
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) => {
|
||||
// Игнорируем символы с Ctrl/Alt модификаторами (кроме Shift)
|
||||
// Это позволяет обрабатывать хоткеи типа Ctrl+U для профиля
|
||||
if key.modifiers.contains(KeyModifiers::CONTROL)
|
||||
|| key.modifiers.contains(KeyModifiers::ALT)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Вставляем символ в позицию курсора
|
||||
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(ChatId::new(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();
|
||||
}
|
||||
// Стрелки вверх/вниз - скролл сообщений (в Insert mode)
|
||||
KeyCode::Down => {
|
||||
if app.message_scroll_offset > 0 {
|
||||
app.message_scroll_offset = app.message_scroll_offset.saturating_sub(3);
|
||||
}
|
||||
}
|
||||
KeyCode::Up => {
|
||||
// В Insert mode — только скролл
|
||||
app.message_scroll_offset += 3;
|
||||
load_older_messages_if_needed(app).await;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Обработка команды ViewImage — только фото
|
||||
async fn handle_view_or_play_media<T: TdClientTrait>(app: &mut App<T>) {
|
||||
let Some(msg) = app.get_selected_message() else {
|
||||
return;
|
||||
};
|
||||
|
||||
if msg.has_photo() {
|
||||
#[cfg(feature = "images")]
|
||||
handle_view_image(app).await;
|
||||
#[cfg(not(feature = "images"))]
|
||||
{
|
||||
app.status_message = Some("Просмотр изображений отключён".to_string());
|
||||
}
|
||||
} else {
|
||||
app.status_message = Some("Сообщение не содержит фото".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// Space: play/pause toggle для голосовых сообщений
|
||||
async fn handle_toggle_voice_playback<T: TdClientTrait>(app: &mut App<T>) {
|
||||
use crate::tdlib::PlaybackStatus;
|
||||
|
||||
// Если уже есть активное воспроизведение — toggle pause/resume
|
||||
if let Some(ref mut playback) = app.playback_state {
|
||||
if let Some(ref player) = app.audio_player {
|
||||
match playback.status {
|
||||
PlaybackStatus::Playing => {
|
||||
player.pause();
|
||||
playback.status = PlaybackStatus::Paused;
|
||||
app.last_playback_tick = None;
|
||||
app.status_message = Some("⏸ Пауза".to_string());
|
||||
}
|
||||
PlaybackStatus::Paused => {
|
||||
// Откатываем на 1 секунду для контекста
|
||||
let resume_pos = (playback.position - 1.0).max(0.0);
|
||||
// Перезапускаем ffplay с нужной позиции (-ss)
|
||||
if player.resume_from(resume_pos).is_ok() {
|
||||
playback.position = resume_pos;
|
||||
} else {
|
||||
// Fallback: простой SIGCONT без перемотки
|
||||
player.resume();
|
||||
}
|
||||
playback.status = PlaybackStatus::Playing;
|
||||
app.last_playback_tick = Some(Instant::now());
|
||||
app.status_message = Some("▶ Воспроизведение".to_string());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Нет активного воспроизведения — пробуем запустить текущее голосовое
|
||||
let Some(msg) = app.get_selected_message() else {
|
||||
return;
|
||||
};
|
||||
if msg.has_voice() {
|
||||
handle_play_voice(app).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Seek голосового сообщения на delta секунд
|
||||
fn handle_voice_seek<T: TdClientTrait>(app: &mut App<T>, delta: f32) {
|
||||
use crate::tdlib::PlaybackStatus;
|
||||
|
||||
let Some(ref mut playback) = app.playback_state else {
|
||||
return;
|
||||
};
|
||||
let Some(ref player) = app.audio_player else {
|
||||
return;
|
||||
};
|
||||
|
||||
let was_playing = matches!(playback.status, PlaybackStatus::Playing);
|
||||
let was_paused = matches!(playback.status, PlaybackStatus::Paused);
|
||||
|
||||
if was_playing || was_paused {
|
||||
let new_position = (playback.position + delta).clamp(0.0, playback.duration);
|
||||
|
||||
if was_playing {
|
||||
// Перезапускаем ffplay с новой позиции
|
||||
if player.resume_from(new_position).is_ok() {
|
||||
playback.position = new_position;
|
||||
app.last_playback_tick = Some(std::time::Instant::now());
|
||||
}
|
||||
} else {
|
||||
// На паузе — только двигаем позицию, воспроизведение начнётся при resume
|
||||
player.stop();
|
||||
playback.position = new_position;
|
||||
}
|
||||
|
||||
let arrow = if delta > 0.0 { "→" } else { "←" };
|
||||
app.status_message = Some(format!("{} {:.0}s", arrow, new_position));
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Обработка команды ViewImage — открыть модальное окно с фото
|
||||
#[cfg(feature = "images")]
|
||||
async fn handle_view_image<T: TdClientTrait>(app: &mut App<T>) {
|
||||
use crate::tdlib::{ImageModalState, PhotoDownloadState};
|
||||
|
||||
if !app.config().images.show_images {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(msg) = app.get_selected_message() else {
|
||||
return;
|
||||
};
|
||||
|
||||
if !msg.has_photo() {
|
||||
app.status_message = Some("Сообщение не содержит фото".to_string());
|
||||
return;
|
||||
}
|
||||
|
||||
let photo = msg.photo_info().unwrap();
|
||||
let msg_id = msg.id();
|
||||
let file_id = photo.file_id;
|
||||
let photo_width = photo.width;
|
||||
let photo_height = photo.height;
|
||||
let download_state = photo.download_state.clone();
|
||||
|
||||
match download_state {
|
||||
PhotoDownloadState::Downloaded(path) => {
|
||||
// Открываем модальное окно
|
||||
app.image_modal = Some(ImageModalState {
|
||||
message_id: msg_id,
|
||||
photo_path: path,
|
||||
photo_width,
|
||||
photo_height,
|
||||
});
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
PhotoDownloadState::NotDownloaded | PhotoDownloadState::Downloading => {
|
||||
// Запоминаем намерение открыть модалку — откроется когда загрузится
|
||||
app.pending_image_open = Some(crate::app::PendingImageOpen {
|
||||
file_id,
|
||||
message_id: msg_id,
|
||||
photo_width,
|
||||
photo_height,
|
||||
});
|
||||
app.status_message = Some("Загрузка фото...".to_string());
|
||||
app.needs_redraw = true;
|
||||
|
||||
// Если нет активной фоновой загрузки — запускаем свою
|
||||
if app.photo_download_rx.is_none() {
|
||||
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
app.photo_download_rx = Some(rx);
|
||||
let client_id = app.td_client.client_id();
|
||||
tokio::spawn(async move {
|
||||
let result = tokio::time::timeout(Duration::from_secs(30), async {
|
||||
match tdlib_rs::functions::download_file(
|
||||
file_id, 1, 0, 0, true, client_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(tdlib_rs::enums::File::File(f))
|
||||
if f.local.is_downloading_completed
|
||||
&& !f.local.path.is_empty() =>
|
||||
{
|
||||
Ok(f.local.path)
|
||||
}
|
||||
Ok(_) => Err("Файл не скачан".to_string()),
|
||||
Err(e) => Err(format!("{:?}", e)),
|
||||
}
|
||||
})
|
||||
.await;
|
||||
let result =
|
||||
result.unwrap_or_else(|_| Err("Таймаут загрузки".to_string()));
|
||||
let _ = tx.send((file_id, result));
|
||||
});
|
||||
}
|
||||
}
|
||||
PhotoDownloadState::Error(_) => {
|
||||
// Повторная попытка загрузки
|
||||
app.status_message = Some("Повторная загрузка фото...".to_string());
|
||||
app.needs_redraw = true;
|
||||
match app.td_client.download_file(file_id).await {
|
||||
Ok(path) => {
|
||||
for msg in app.td_client.current_chat_messages_mut() {
|
||||
if let Some(photo) = msg.photo_info_mut() {
|
||||
if photo.file_id == file_id {
|
||||
photo.download_state = PhotoDownloadState::Downloaded(path.clone());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
app.image_modal = Some(ImageModalState {
|
||||
message_id: msg_id,
|
||||
photo_path: path,
|
||||
photo_width,
|
||||
photo_height,
|
||||
});
|
||||
app.status_message = None;
|
||||
}
|
||||
Err(e) => {
|
||||
app.error_message = Some(format!("Ошибка загрузки фото: {}", e));
|
||||
app.status_message = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Вспомогательная функция для воспроизведения из конкретного пути
|
||||
async fn handle_play_voice_from_path<T: TdClientTrait>(
|
||||
app: &mut App<T>,
|
||||
path: &str,
|
||||
voice: &crate::tdlib::VoiceInfo,
|
||||
msg: &crate::tdlib::MessageInfo,
|
||||
) {
|
||||
use crate::tdlib::{PlaybackState, PlaybackStatus};
|
||||
|
||||
if let Some(ref player) = app.audio_player {
|
||||
match player.play(path) {
|
||||
Ok(_) => {
|
||||
app.playback_state = Some(PlaybackState {
|
||||
message_id: msg.id(),
|
||||
status: PlaybackStatus::Playing,
|
||||
position: 0.0,
|
||||
duration: voice.duration as f32,
|
||||
volume: player.volume(),
|
||||
});
|
||||
app.last_playback_tick = Some(Instant::now());
|
||||
app.status_message = Some(format!("▶ Воспроизведение ({:.0}s)", voice.duration));
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
Err(e) => {
|
||||
app.error_message = Some(format!("Ошибка воспроизведения: {}", e));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
app.error_message = Some("Аудиоплеер не инициализирован".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// Воспроизведение голосового сообщения
|
||||
async fn handle_play_voice<T: TdClientTrait>(app: &mut App<T>) {
|
||||
use crate::tdlib::VoiceDownloadState;
|
||||
|
||||
let Some(msg) = app.get_selected_message() else {
|
||||
return;
|
||||
};
|
||||
|
||||
if !msg.has_voice() {
|
||||
return;
|
||||
}
|
||||
|
||||
let voice = msg.voice_info().unwrap();
|
||||
let file_id = voice.file_id;
|
||||
|
||||
match &voice.download_state {
|
||||
VoiceDownloadState::Downloaded(path) => {
|
||||
// TDLib может вернуть путь без расширения — ищем файл с .oga
|
||||
use std::path::Path;
|
||||
let audio_path = if Path::new(path).exists() {
|
||||
path.clone()
|
||||
} else {
|
||||
// Пробуем добавить .oga
|
||||
let with_oga = format!("{}.oga", path);
|
||||
if Path::new(&with_oga).exists() {
|
||||
with_oga
|
||||
} else {
|
||||
// Пробуем найти файл с похожим именем в той же папке
|
||||
if let Some(parent) = Path::new(path).parent() {
|
||||
if let Some(stem) = Path::new(path).file_name() {
|
||||
if let Ok(entries) = std::fs::read_dir(parent) {
|
||||
for entry in entries.flatten() {
|
||||
let entry_name = entry.file_name();
|
||||
if entry_name
|
||||
.to_string_lossy()
|
||||
.starts_with(&stem.to_string_lossy().to_string())
|
||||
{
|
||||
let found_path = entry.path().to_string_lossy().to_string();
|
||||
// Кэшируем найденный файл
|
||||
if let Some(ref mut cache) = app.voice_cache {
|
||||
let _ = cache.store(
|
||||
&file_id.to_string(),
|
||||
Path::new(&found_path),
|
||||
);
|
||||
}
|
||||
return handle_play_voice_from_path(
|
||||
app,
|
||||
&found_path,
|
||||
voice,
|
||||
&msg,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
app.error_message = Some(format!("Файл не найден: {}", path));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Кэшируем файл если ещё не в кэше
|
||||
if let Some(ref mut cache) = app.voice_cache {
|
||||
let _ = cache.store(&file_id.to_string(), Path::new(&audio_path));
|
||||
}
|
||||
|
||||
handle_play_voice_from_path(app, &audio_path, voice, &msg).await;
|
||||
}
|
||||
VoiceDownloadState::Downloading => {
|
||||
app.status_message = Some("Загрузка голосового...".to_string());
|
||||
}
|
||||
VoiceDownloadState::NotDownloaded => {
|
||||
// Проверяем кэш перед загрузкой
|
||||
let cache_key = file_id.to_string();
|
||||
if let Some(cached_path) = app.voice_cache.as_mut().and_then(|c| c.get(&cache_key)) {
|
||||
let path_str = cached_path.to_string_lossy().to_string();
|
||||
handle_play_voice_from_path(app, &path_str, voice, &msg).await;
|
||||
return;
|
||||
}
|
||||
|
||||
// Начинаем загрузку
|
||||
app.status_message = Some("Загрузка голосового...".to_string());
|
||||
match app.td_client.download_voice_note(file_id).await {
|
||||
Ok(path) => {
|
||||
// Кэшируем загруженный файл
|
||||
if let Some(ref mut cache) = app.voice_cache {
|
||||
let _ = cache.store(&cache_key, std::path::Path::new(&path));
|
||||
}
|
||||
|
||||
handle_play_voice_from_path(app, &path, voice, &msg).await;
|
||||
}
|
||||
Err(e) => {
|
||||
app.error_message = Some(format!("Ошибка загрузки: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
VoiceDownloadState::Error(e) => {
|
||||
app.error_message = Some(format!("Ошибка загрузки: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO (Этап 4): Эти функции будут переписаны для модального просмотрщика
|
||||
/*
|
||||
#[cfg(feature = "images")]
|
||||
fn collapse_photo<T: TdClientTrait>(app: &mut App<T>, msg_id: crate::types::MessageId) {
|
||||
// Закомментировано - будет реализовано в Этапе 4
|
||||
}
|
||||
|
||||
#[cfg(feature = "images")]
|
||||
fn expand_photo<T: TdClientTrait>(app: &mut App<T>, msg_id: crate::types::MessageId, path: &str) {
|
||||
// Закомментировано - будет реализовано в Этапе 4
|
||||
}
|
||||
*/
|
||||
|
||||
// TODO (Этап 4): Функция _download_and_expand будет переписана
|
||||
/*
|
||||
#[cfg(feature = "images")]
|
||||
async fn _download_and_expand<T: TdClientTrait>(app: &mut App<T>, msg_id: crate::types::MessageId, file_id: i32) {
|
||||
// Закомментировано - будет реализовано в Этапе 4
|
||||
}
|
||||
*/
|
||||
77
src/input/handlers/chat_list.rs
Normal file
77
src/input/handlers/chat_list.rs
Normal file
@@ -0,0 +1,77 @@
|
||||
//! Chat list input handlers
|
||||
//!
|
||||
//! Handles keyboard input for the chat list view, including:
|
||||
//! - Navigation between chats
|
||||
//! - Folder selection
|
||||
//! - Opening chats
|
||||
|
||||
use crate::app::methods::navigation::NavigationMethods;
|
||||
use crate::app::App;
|
||||
use crate::tdlib::TdClientTrait;
|
||||
use crate::utils::with_timeout;
|
||||
use crossterm::event::KeyEvent;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Обработка навигации в списке чатов
|
||||
///
|
||||
/// Обрабатывает:
|
||||
/// - Up/Down/j/k: навигация между чатами
|
||||
/// - Цифры 1-9: переключение папок (1=All, 2-9=папки из TDLib)
|
||||
pub async fn handle_chat_list_navigation<T: TdClientTrait>(
|
||||
app: &mut App<T>,
|
||||
_key: KeyEvent,
|
||||
command: Option<crate::config::Command>,
|
||||
) {
|
||||
match command {
|
||||
Some(crate::config::Command::MoveDown) => {
|
||||
app.next_chat();
|
||||
}
|
||||
Some(crate::config::Command::MoveUp) => {
|
||||
app.previous_chat();
|
||||
}
|
||||
Some(crate::config::Command::SelectFolder1) => {
|
||||
app.selected_folder_id = None;
|
||||
app.chat_list_state.select(Some(0));
|
||||
}
|
||||
Some(crate::config::Command::SelectFolder2) => {
|
||||
select_folder(app, 0).await;
|
||||
}
|
||||
Some(crate::config::Command::SelectFolder3) => {
|
||||
select_folder(app, 1).await;
|
||||
}
|
||||
Some(crate::config::Command::SelectFolder4) => {
|
||||
select_folder(app, 2).await;
|
||||
}
|
||||
Some(crate::config::Command::SelectFolder5) => {
|
||||
select_folder(app, 3).await;
|
||||
}
|
||||
Some(crate::config::Command::SelectFolder6) => {
|
||||
select_folder(app, 4).await;
|
||||
}
|
||||
Some(crate::config::Command::SelectFolder7) => {
|
||||
select_folder(app, 5).await;
|
||||
}
|
||||
Some(crate::config::Command::SelectFolder8) => {
|
||||
select_folder(app, 6).await;
|
||||
}
|
||||
Some(crate::config::Command::SelectFolder9) => {
|
||||
select_folder(app, 7).await;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Выбирает папку по индексу и загружает её чаты
|
||||
pub async fn select_folder<T: TdClientTrait>(app: &mut App<T>, folder_idx: usize) {
|
||||
if let Some(folder) = app.td_client.folders().get(folder_idx) {
|
||||
let folder_id = folder.id;
|
||||
app.selected_folder_id = Some(folder_id);
|
||||
app.status_message = Some("Загрузка чатов папки...".to_string());
|
||||
let _ =
|
||||
with_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));
|
||||
}
|
||||
}
|
||||
|
||||
194
src/input/handlers/chat_loader.rs
Normal file
194
src/input/handlers/chat_loader.rs
Normal file
@@ -0,0 +1,194 @@
|
||||
//! Chat loading logic — all three phases of message loading
|
||||
//!
|
||||
//! - Phase 1: `open_chat_and_load_data` — fast initial load (50 messages)
|
||||
//! - Phase 2: `process_pending_chat_init` — background tasks (reply info, pinned, photos)
|
||||
//! - Phase 3: `load_older_messages_if_needed` — lazy load on scroll up
|
||||
|
||||
use crate::app::methods::{compose::ComposeMethods, messages::MessageMethods};
|
||||
use crate::app::App;
|
||||
use crate::app::InputMode;
|
||||
use crate::tdlib::TdClientTrait;
|
||||
use crate::types::{ChatId, MessageId};
|
||||
use crate::utils::{with_timeout, with_timeout_ignore, with_timeout_msg};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Открывает чат и загружает последние сообщения (быстро).
|
||||
///
|
||||
/// Загружает только 50 последних сообщений для мгновенного отображения.
|
||||
/// Фоновые задачи (reply info, pinned, photos) откладываются в `pending_chat_init`
|
||||
/// и выполняются на следующем тике main loop.
|
||||
///
|
||||
/// При ошибке устанавливает error_message и очищает status_message.
|
||||
pub async fn open_chat_and_load_data<T: TdClientTrait>(app: &mut App<T>, chat_id: i64) {
|
||||
app.status_message = Some("Загрузка сообщений...".to_string());
|
||||
app.message_scroll_offset = 0;
|
||||
|
||||
// Загружаем только 50 последних сообщений (один запрос к TDLib)
|
||||
match with_timeout_msg(
|
||||
Duration::from_secs(10),
|
||||
app.td_client.get_chat_history(ChatId::new(chat_id), 50),
|
||||
"Таймаут загрузки сообщений",
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(messages) => {
|
||||
// Собираем ID всех входящих сообщений для отметки как прочитанные
|
||||
let incoming_message_ids: Vec<MessageId> = messages
|
||||
.iter()
|
||||
.filter(|msg| !msg.is_outgoing())
|
||||
.map(|msg| msg.id())
|
||||
.collect();
|
||||
|
||||
// Сохраняем загруженные сообщения
|
||||
app.td_client.set_current_chat_messages(messages);
|
||||
|
||||
// Добавляем входящие сообщения в очередь для отметки как прочитанные
|
||||
if !incoming_message_ids.is_empty() {
|
||||
app.td_client
|
||||
.pending_view_messages_mut()
|
||||
.push((ChatId::new(chat_id), incoming_message_ids));
|
||||
}
|
||||
|
||||
// ВАЖНО: Устанавливаем current_chat_id ТОЛЬКО ПОСЛЕ сохранения истории
|
||||
// Это предотвращает race condition с Update::NewMessage
|
||||
app.td_client
|
||||
.set_current_chat_id(Some(ChatId::new(chat_id)));
|
||||
|
||||
// Загружаем черновик (локальная операция, мгновенно)
|
||||
app.load_draft();
|
||||
|
||||
// Показываем чат СРАЗУ
|
||||
app.status_message = None;
|
||||
app.input_mode = InputMode::Normal;
|
||||
app.start_message_selection();
|
||||
|
||||
// Фоновые задачи (reply info, pinned, photos) — на следующем тике main loop
|
||||
app.pending_chat_init = Some(ChatId::new(chat_id));
|
||||
}
|
||||
Err(e) => {
|
||||
app.error_message = Some(e);
|
||||
app.status_message = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Выполняет фоновую инициализацию после открытия чата.
|
||||
///
|
||||
/// Вызывается на следующем тике main loop после `open_chat_and_load_data`.
|
||||
/// Загружает reply info, закреплённое сообщение и начинает авто-загрузку фото.
|
||||
pub async fn process_pending_chat_init<T: TdClientTrait>(app: &mut App<T>, chat_id: ChatId) {
|
||||
// Загружаем недостающие reply info (игнорируем ошибки)
|
||||
with_timeout_ignore(Duration::from_secs(5), app.td_client.fetch_missing_reply_info())
|
||||
.await;
|
||||
|
||||
// Загружаем последнее закреплённое сообщение (игнорируем ошибки)
|
||||
with_timeout_ignore(
|
||||
Duration::from_secs(2),
|
||||
app.td_client.load_current_pinned_message(chat_id),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Авто-загрузка фото — неблокирующая фоновая задача (до 5 фото параллельно)
|
||||
#[cfg(feature = "images")]
|
||||
{
|
||||
use crate::tdlib::PhotoDownloadState;
|
||||
|
||||
if app.config().images.auto_download_images && app.config().images.show_images {
|
||||
let photo_file_ids: Vec<i32> = app
|
||||
.td_client
|
||||
.current_chat_messages()
|
||||
.iter()
|
||||
.rev()
|
||||
.take(5)
|
||||
.filter_map(|msg| {
|
||||
msg.photo_info().and_then(|p| {
|
||||
matches!(p.download_state, PhotoDownloadState::NotDownloaded)
|
||||
.then_some(p.file_id)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
if !photo_file_ids.is_empty() {
|
||||
let client_id = app.td_client.client_id();
|
||||
let (tx, rx) =
|
||||
tokio::sync::mpsc::unbounded_channel::<(i32, Result<String, String>)>();
|
||||
app.photo_download_rx = Some(rx);
|
||||
|
||||
for file_id in photo_file_ids {
|
||||
let tx = tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let result = tokio::time::timeout(Duration::from_secs(5), async {
|
||||
match tdlib_rs::functions::download_file(
|
||||
file_id, 1, 0, 0, true, client_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(tdlib_rs::enums::File::File(file))
|
||||
if file.local.is_downloading_completed
|
||||
&& !file.local.path.is_empty() =>
|
||||
{
|
||||
Ok(file.local.path)
|
||||
}
|
||||
Ok(_) => Err("Файл не скачан".to_string()),
|
||||
Err(e) => Err(format!("{:?}", e)),
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
let result = match result {
|
||||
Ok(r) => r,
|
||||
Err(_) => Err("Таймаут загрузки".to_string()),
|
||||
};
|
||||
let _ = tx.send((file_id, result));
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
|
||||
/// Подгружает старые сообщения если скролл близко к верху
|
||||
pub async fn load_older_messages_if_needed<T: TdClientTrait>(app: &mut App<T>) {
|
||||
// Check if there are messages to load from
|
||||
if app.td_client.current_chat_messages().is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the oldest message ID
|
||||
let oldest_msg_id = app
|
||||
.td_client
|
||||
.current_chat_messages()
|
||||
.first()
|
||||
.map(|m| m.id())
|
||||
.unwrap_or(MessageId::new(0));
|
||||
|
||||
// Get current chat ID
|
||||
let Some(chat_id) = app.get_selected_chat_id() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Check if scroll is near the top
|
||||
let message_count = app.td_client.current_chat_messages().len();
|
||||
if app.message_scroll_offset <= message_count.saturating_sub(10) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Load older messages with timeout
|
||||
let Ok(older) = with_timeout(
|
||||
Duration::from_secs(3),
|
||||
app.td_client
|
||||
.load_older_messages(ChatId::new(chat_id), oldest_msg_id),
|
||||
)
|
||||
.await
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Add older messages to the beginning if any were loaded
|
||||
if !older.is_empty() {
|
||||
let msgs = app.td_client.current_chat_messages_mut();
|
||||
msgs.splice(0..0, older);
|
||||
}
|
||||
}
|
||||
101
src/input/handlers/clipboard.rs
Normal file
101
src/input/handlers/clipboard.rs
Normal file
@@ -0,0 +1,101 @@
|
||||
//! Clipboard operations for copying messages
|
||||
|
||||
use crate::tdlib::MessageInfo;
|
||||
|
||||
/// Копирует текст в системный буфер обмена
|
||||
#[cfg(feature = "clipboard")]
|
||||
pub 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(())
|
||||
}
|
||||
|
||||
/// Заглушка для copy_to_clipboard когда feature "clipboard" выключена
|
||||
#[cfg(not(feature = "clipboard"))]
|
||||
pub fn copy_to_clipboard(_text: &str) -> Result<(), String> {
|
||||
Err("Копирование в буфер обмена недоступно (требуется feature 'clipboard')".to_string())
|
||||
}
|
||||
|
||||
/// Форматирует сообщение для копирования с контекстом
|
||||
pub fn format_message_for_clipboard(msg: &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.text(), 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
|
||||
}
|
||||
85
src/input/handlers/compose.rs
Normal file
85
src/input/handlers/compose.rs
Normal file
@@ -0,0 +1,85 @@
|
||||
//! Compose input handlers
|
||||
//!
|
||||
//! Handles text input and message composition, including:
|
||||
//! - Forward mode
|
||||
//! - Reply mode
|
||||
//! - Edit mode
|
||||
//! - Cursor movement and text editing
|
||||
|
||||
use crate::app::methods::{
|
||||
compose::ComposeMethods, navigation::NavigationMethods, search::SearchMethods,
|
||||
};
|
||||
use crate::app::App;
|
||||
use crate::tdlib::TdClientTrait;
|
||||
use crate::types::ChatId;
|
||||
use crate::utils::with_timeout_msg;
|
||||
use crossterm::event::KeyEvent;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Обработка режима выбора чата для пересылки сообщения
|
||||
///
|
||||
/// Обрабатывает:
|
||||
/// - Навигацию по списку чатов (Up/Down)
|
||||
/// - Пересылку сообщения в выбранный чат (Enter)
|
||||
/// - Отмену пересылки (Esc)
|
||||
pub async fn handle_forward_mode<T: TdClientTrait>(
|
||||
app: &mut App<T>,
|
||||
_key: KeyEvent,
|
||||
command: Option<crate::config::Command>,
|
||||
) {
|
||||
match command {
|
||||
Some(crate::config::Command::Cancel) => {
|
||||
app.cancel_forward();
|
||||
}
|
||||
Some(crate::config::Command::SubmitMessage) => {
|
||||
forward_selected_message(app).await;
|
||||
app.cancel_forward();
|
||||
}
|
||||
Some(crate::config::Command::MoveDown) => {
|
||||
app.next_chat();
|
||||
}
|
||||
Some(crate::config::Command::MoveUp) => {
|
||||
app.previous_chat();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Пересылает выбранное сообщение в выбранный чат
|
||||
pub async fn forward_selected_message<T: TdClientTrait>(app: &mut App<T>) {
|
||||
// Get all required IDs with early returns
|
||||
let filtered = app.get_filtered_chats();
|
||||
let Some(i) = app.chat_list_state.selected() else {
|
||||
return;
|
||||
};
|
||||
let Some(chat) = filtered.get(i) else {
|
||||
return;
|
||||
};
|
||||
let to_chat_id = chat.id;
|
||||
|
||||
let Some(msg_id) = app.chat_state.selected_message_id() else {
|
||||
return;
|
||||
};
|
||||
let Some(from_chat_id) = app.get_selected_chat_id() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Forward the message with timeout
|
||||
let result = with_timeout_msg(
|
||||
Duration::from_secs(5),
|
||||
app.td_client
|
||||
.forward_messages(to_chat_id, ChatId::new(from_chat_id), vec![msg_id]),
|
||||
"Таймаут пересылки",
|
||||
)
|
||||
.await;
|
||||
|
||||
// Handle result
|
||||
match result {
|
||||
Ok(_) => {
|
||||
app.status_message = Some("Сообщение переслано".to_string());
|
||||
}
|
||||
Err(e) => {
|
||||
app.error_message = Some(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
101
src/input/handlers/global.rs
Normal file
101
src/input/handlers/global.rs
Normal file
@@ -0,0 +1,101 @@
|
||||
//! Global commands that work from any screen
|
||||
//!
|
||||
//! Handles Ctrl+ combinations:
|
||||
//! - Ctrl+R: Refresh chats
|
||||
//! - Ctrl+S: Start search
|
||||
//! - Ctrl+P: View pinned messages
|
||||
//! - Ctrl+F: Search messages in chat
|
||||
|
||||
use crate::app::methods::{modal::ModalMethods, search::SearchMethods};
|
||||
use crate::app::App;
|
||||
use crate::tdlib::TdClientTrait;
|
||||
use crate::types::ChatId;
|
||||
use crate::utils::{with_timeout, with_timeout_msg};
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Обрабатывает глобальные команды (Ctrl+ combinations).
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// `true` если команда была обработана, `false` если нет
|
||||
pub async fn handle_global_commands<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent) -> bool {
|
||||
let command = app.get_command(key);
|
||||
|
||||
match command {
|
||||
Some(crate::config::Command::OpenSearch) => {
|
||||
// Ctrl+S - начать поиск (только если чат не открыт)
|
||||
if app.selected_chat_id.is_none() {
|
||||
app.start_search();
|
||||
}
|
||||
true
|
||||
}
|
||||
Some(crate::config::Command::OpenSearchInChat) => {
|
||||
// Ctrl+F - поиск по сообщениям в открытом чате
|
||||
if app.selected_chat_id.is_some()
|
||||
&& !app.is_pinned_mode()
|
||||
&& !app.is_message_search_mode()
|
||||
{
|
||||
app.enter_message_search_mode();
|
||||
}
|
||||
true
|
||||
}
|
||||
_ => {
|
||||
// Проверяем специальные комбинации, которых нет в Command enum
|
||||
let has_ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
||||
match key.code {
|
||||
KeyCode::Char('r') if has_ctrl => {
|
||||
// Ctrl+R - обновить список чатов
|
||||
app.status_message = Some("Обновление чатов...".to_string());
|
||||
let _ =
|
||||
with_timeout(Duration::from_secs(5), app.td_client.load_chats(50)).await;
|
||||
// Синхронизируем muted чаты после обновления
|
||||
app.td_client.sync_notification_muted_chats();
|
||||
app.status_message = None;
|
||||
true
|
||||
}
|
||||
KeyCode::Char('p') if has_ctrl => {
|
||||
// Ctrl+P - режим просмотра закреплённых сообщений
|
||||
handle_pinned_messages(app).await;
|
||||
true
|
||||
}
|
||||
KeyCode::Char('a') if has_ctrl => {
|
||||
// Ctrl+A - переключение аккаунтов
|
||||
app.open_account_switcher();
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Обрабатывает загрузку и отображение закреплённых сообщений
|
||||
async fn handle_pinned_messages<T: TdClientTrait>(app: &mut App<T>) {
|
||||
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 with_timeout_msg(
|
||||
Duration::from_secs(5),
|
||||
app.td_client.get_pinned_messages(ChatId::new(chat_id)),
|
||||
"Таймаут загрузки",
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(messages) => {
|
||||
let messages: Vec<crate::tdlib::MessageInfo> = messages;
|
||||
if messages.is_empty() {
|
||||
app.status_message = Some("Нет закреплённых сообщений".to_string());
|
||||
} else {
|
||||
app.enter_pinned_mode(messages);
|
||||
app.status_message = None;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
app.error_message = Some(e);
|
||||
app.status_message = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
45
src/input/handlers/mod.rs
Normal file
45
src/input/handlers/mod.rs
Normal file
@@ -0,0 +1,45 @@
|
||||
//! Input handlers organized by functionality
|
||||
//!
|
||||
//! This module contains handlers for different input contexts:
|
||||
//! - global: Global commands (Ctrl+R, Ctrl+S, etc.)
|
||||
//! - clipboard: Clipboard operations
|
||||
//! - profile: Profile helper functions
|
||||
//! - chat: Keyboard input handling for open chat view
|
||||
//! - chat_list: Navigation and interaction in the chat list
|
||||
//! - chat_loader: All phases of chat message loading
|
||||
//! - compose: Text input, editing, and message composition
|
||||
//! - modal: Modal dialogs (delete confirmation, emoji picker, etc.)
|
||||
//! - search: Search functionality (chat search, message search)
|
||||
|
||||
pub mod chat;
|
||||
pub mod chat_list;
|
||||
pub mod chat_loader;
|
||||
pub mod clipboard;
|
||||
pub mod compose;
|
||||
pub mod global;
|
||||
pub mod modal;
|
||||
pub mod profile;
|
||||
pub mod search;
|
||||
|
||||
pub use chat_loader::{load_older_messages_if_needed, open_chat_and_load_data, process_pending_chat_init};
|
||||
pub use clipboard::*;
|
||||
pub use global::*;
|
||||
pub use profile::get_available_actions_count;
|
||||
|
||||
use crate::app::App;
|
||||
use crate::tdlib::TdClientTrait;
|
||||
use crate::types::MessageId;
|
||||
|
||||
/// Скроллит к сообщению по его ID в текущем чате
|
||||
pub fn scroll_to_message<T: TdClientTrait>(app: &mut App<T>, message_id: MessageId) {
|
||||
let msg_index = app
|
||||
.td_client
|
||||
.current_chat_messages()
|
||||
.iter()
|
||||
.position(|m| m.id() == message_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);
|
||||
}
|
||||
}
|
||||
404
src/input/handlers/modal.rs
Normal file
404
src/input/handlers/modal.rs
Normal file
@@ -0,0 +1,404 @@
|
||||
//! Modal dialog handlers
|
||||
//!
|
||||
//! Handles keyboard input for modal dialogs, including:
|
||||
//! - Account switcher (global overlay)
|
||||
//! - Delete confirmation
|
||||
//! - Reaction picker (emoji selector)
|
||||
//! - Pinned messages view
|
||||
//! - Profile information modal
|
||||
|
||||
use super::scroll_to_message;
|
||||
use crate::app::methods::{modal::ModalMethods, navigation::NavigationMethods};
|
||||
use crate::app::{AccountSwitcherState, App};
|
||||
use crate::input::handlers::get_available_actions_count;
|
||||
use crate::tdlib::TdClientTrait;
|
||||
use crate::types::{ChatId, MessageId};
|
||||
use crate::utils::{modal_handler::handle_yes_no, with_timeout_msg};
|
||||
use crossterm::event::{KeyCode, KeyEvent};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Обработка ввода в модалке переключения аккаунтов
|
||||
///
|
||||
/// **SelectAccount mode:**
|
||||
/// - j/k (MoveUp/MoveDown) — навигация по списку
|
||||
/// - Enter — выбор аккаунта или переход к добавлению
|
||||
/// - a/ф — быстрое добавление аккаунта
|
||||
/// - Esc — закрыть модалку
|
||||
///
|
||||
/// **AddAccount mode:**
|
||||
/// - Char input → ввод имени
|
||||
/// - Backspace → удалить символ
|
||||
/// - Enter → создать аккаунт
|
||||
/// - Esc → назад к списку
|
||||
pub async fn handle_account_switcher<T: TdClientTrait>(
|
||||
app: &mut App<T>,
|
||||
key: KeyEvent,
|
||||
command: Option<crate::config::Command>,
|
||||
) {
|
||||
let Some(state) = &app.account_switcher else {
|
||||
return;
|
||||
};
|
||||
|
||||
match state {
|
||||
AccountSwitcherState::SelectAccount { .. } => {
|
||||
match command {
|
||||
Some(crate::config::Command::MoveUp) => {
|
||||
app.account_switcher_select_prev();
|
||||
}
|
||||
Some(crate::config::Command::MoveDown) => {
|
||||
app.account_switcher_select_next();
|
||||
}
|
||||
Some(crate::config::Command::SubmitMessage) => {
|
||||
app.account_switcher_confirm();
|
||||
}
|
||||
Some(crate::config::Command::Cancel) => {
|
||||
app.close_account_switcher();
|
||||
}
|
||||
_ => {
|
||||
// Raw key check for 'a'/'ф' shortcut
|
||||
match key.code {
|
||||
KeyCode::Char('a') | KeyCode::Char('ф') => {
|
||||
app.account_switcher_start_add();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
AccountSwitcherState::AddAccount { .. } => match key.code {
|
||||
KeyCode::Esc => {
|
||||
app.account_switcher_back();
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
app.account_switcher_confirm_add();
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
if let Some(AccountSwitcherState::AddAccount {
|
||||
name_input,
|
||||
cursor_position,
|
||||
error,
|
||||
}) = &mut app.account_switcher
|
||||
{
|
||||
if *cursor_position > 0 {
|
||||
let mut chars: Vec<char> = name_input.chars().collect();
|
||||
chars.remove(*cursor_position - 1);
|
||||
*name_input = chars.into_iter().collect();
|
||||
*cursor_position -= 1;
|
||||
*error = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
if let Some(AccountSwitcherState::AddAccount {
|
||||
name_input,
|
||||
cursor_position,
|
||||
error,
|
||||
}) = &mut app.account_switcher
|
||||
{
|
||||
let mut chars: Vec<char> = name_input.chars().collect();
|
||||
chars.insert(*cursor_position, c);
|
||||
*name_input = chars.into_iter().collect();
|
||||
*cursor_position += 1;
|
||||
*error = None;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Обработка режима профиля пользователя/чата
|
||||
///
|
||||
/// Обрабатывает:
|
||||
/// - Модалку подтверждения выхода из группы (двухшаговая)
|
||||
/// - Навигацию по действиям профиля (Up/Down)
|
||||
/// - Выполнение выбранного действия (Enter): открыть в браузере, скопировать ID, покинуть группу
|
||||
/// - Выход из режима профиля (Esc)
|
||||
pub async fn handle_profile_mode<T: TdClientTrait>(
|
||||
app: &mut App<T>,
|
||||
key: KeyEvent,
|
||||
command: Option<crate::config::Command>,
|
||||
) {
|
||||
// Обработка подтверждения выхода из группы
|
||||
let confirmation_step = app.get_leave_group_confirmation_step();
|
||||
if confirmation_step > 0 {
|
||||
match handle_yes_no(key.code) {
|
||||
Some(true) => {
|
||||
// Подтверждение
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(false) => {
|
||||
// Отмена
|
||||
app.cancel_leave_group();
|
||||
}
|
||||
None => {
|
||||
// Другая клавиша - игнорируем
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Обычная навигация по профилю
|
||||
match command {
|
||||
Some(crate::config::Command::Cancel) => {
|
||||
app.exit_profile_mode();
|
||||
}
|
||||
Some(crate::config::Command::MoveUp) => {
|
||||
app.select_previous_profile_action();
|
||||
}
|
||||
Some(crate::config::Command::MoveDown) => {
|
||||
if let Some(profile) = app.get_profile_info() {
|
||||
let max_actions = get_available_actions_count(profile);
|
||||
app.select_next_profile_action(max_actions);
|
||||
}
|
||||
}
|
||||
Some(crate::config::Command::SubmitMessage) => {
|
||||
// Выполнить выбранное действие
|
||||
let Some(profile) = app.get_profile_info() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let actions = get_available_actions_count(profile);
|
||||
let action_index = app.get_selected_profile_action().unwrap_or(0);
|
||||
|
||||
// Guard: проверяем, что индекс действия валидный
|
||||
if action_index >= actions {
|
||||
return;
|
||||
}
|
||||
|
||||
// Определяем какое действие выбрано
|
||||
let mut current_idx = 0;
|
||||
|
||||
// Действие: Открыть в браузере
|
||||
if let Some(username) = &profile.username {
|
||||
if action_index == current_idx {
|
||||
let url = format!("https://t.me/{}", username.trim_start_matches('@'));
|
||||
#[cfg(feature = "url-open")]
|
||||
{
|
||||
match open::that(&url) {
|
||||
Ok(_) => {
|
||||
app.status_message = Some(format!("Открыто: {}", url));
|
||||
}
|
||||
Err(e) => {
|
||||
app.error_message =
|
||||
Some(format!("Ошибка открытия браузера: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(not(feature = "url-open"))]
|
||||
{
|
||||
app.error_message = Some(
|
||||
"Открытие URL недоступно (требуется feature 'url-open')".to_string(),
|
||||
);
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Обработка Ctrl+U для открытия профиля чата/пользователя
|
||||
///
|
||||
/// Загружает информацию о профиле и переключает в режим просмотра профиля
|
||||
pub async fn handle_profile_open<T: TdClientTrait>(app: &mut App<T>) {
|
||||
let Some(chat_id) = app.selected_chat_id else {
|
||||
return;
|
||||
};
|
||||
|
||||
app.status_message = Some("Загрузка профиля...".to_string());
|
||||
match with_timeout_msg(
|
||||
Duration::from_secs(5),
|
||||
app.td_client.get_profile_info(chat_id),
|
||||
"Таймаут загрузки профиля",
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(profile) => {
|
||||
app.enter_profile_mode(profile);
|
||||
app.status_message = None;
|
||||
}
|
||||
Err(e) => {
|
||||
app.error_message = Some(e);
|
||||
app.status_message = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Обработка модалки подтверждения удаления сообщения
|
||||
///
|
||||
/// Обрабатывает:
|
||||
/// - Подтверждение удаления (Y/y/Д/д)
|
||||
/// - Отмена удаления (N/n/Т/т)
|
||||
/// - Удаление для себя или для всех (зависит от can_be_deleted_for_all_users)
|
||||
pub async fn handle_delete_confirmation<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent) {
|
||||
match handle_yes_no(key.code) {
|
||||
Some(true) => {
|
||||
// Подтверждение удаления
|
||||
if let Some(msg_id) = app.chat_state.selected_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 with_timeout_msg(
|
||||
Duration::from_secs(5),
|
||||
app.td_client.delete_messages(
|
||||
ChatId::new(chat_id),
|
||||
vec![msg_id],
|
||||
can_delete_for_all,
|
||||
),
|
||||
"Таймаут удаления",
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
// Удаляем из локального списка
|
||||
app.td_client
|
||||
.current_chat_messages_mut()
|
||||
.retain(|m| m.id() != msg_id);
|
||||
// Сбрасываем состояние
|
||||
app.chat_state = crate::app::ChatState::Normal;
|
||||
}
|
||||
Err(e) => {
|
||||
app.error_message = Some(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Закрываем модалку
|
||||
app.chat_state = crate::app::ChatState::Normal;
|
||||
}
|
||||
Some(false) => {
|
||||
// Отмена удаления
|
||||
app.chat_state = crate::app::ChatState::Normal;
|
||||
}
|
||||
None => {
|
||||
// Другая клавиша - игнорируем
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Обработка режима выбора реакции (emoji picker)
|
||||
///
|
||||
/// Обрабатывает:
|
||||
/// - Навигацию по сетке реакций: Left/Right, Up/Down (сетка 8x6)
|
||||
/// - Добавление/удаление реакции (Enter)
|
||||
/// - Выход из режима (Esc)
|
||||
pub async fn handle_reaction_picker_mode<T: TdClientTrait>(
|
||||
app: &mut App<T>,
|
||||
_key: KeyEvent,
|
||||
command: Option<crate::config::Command>,
|
||||
) {
|
||||
match command {
|
||||
Some(crate::config::Command::MoveLeft) => {
|
||||
app.select_previous_reaction();
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
Some(crate::config::Command::MoveRight) => {
|
||||
app.select_next_reaction();
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
Some(crate::config::Command::MoveUp) => {
|
||||
if let crate::app::ChatState::ReactionPicker { selected_index, .. } =
|
||||
&mut app.chat_state
|
||||
{
|
||||
if *selected_index >= 8 {
|
||||
*selected_index = selected_index.saturating_sub(8);
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(crate::config::Command::MoveDown) => {
|
||||
if let crate::app::ChatState::ReactionPicker {
|
||||
selected_index,
|
||||
available_reactions,
|
||||
..
|
||||
} = &mut app.chat_state
|
||||
{
|
||||
let new_index = *selected_index + 8;
|
||||
if new_index < available_reactions.len() {
|
||||
*selected_index = new_index;
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(crate::config::Command::SubmitMessage) => {
|
||||
super::chat::send_reaction(app).await;
|
||||
}
|
||||
Some(crate::config::Command::Cancel) => {
|
||||
app.exit_reaction_picker_mode();
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Обработка режима просмотра закреплённых сообщений
|
||||
///
|
||||
/// Обрабатывает:
|
||||
/// - Навигацию по закреплённым сообщениям (Up/Down)
|
||||
/// - Переход к сообщению в истории (Enter)
|
||||
/// - Выход из режима (Esc)
|
||||
pub async fn handle_pinned_mode<T: TdClientTrait>(
|
||||
app: &mut App<T>,
|
||||
_key: KeyEvent,
|
||||
command: Option<crate::config::Command>,
|
||||
) {
|
||||
match command {
|
||||
Some(crate::config::Command::Cancel) => {
|
||||
app.exit_pinned_mode();
|
||||
}
|
||||
Some(crate::config::Command::MoveUp) => {
|
||||
app.select_previous_pinned();
|
||||
}
|
||||
Some(crate::config::Command::MoveDown) => {
|
||||
app.select_next_pinned();
|
||||
}
|
||||
Some(crate::config::Command::SubmitMessage) => {
|
||||
if let Some(msg_id) = app.get_selected_pinned_id() {
|
||||
scroll_to_message(app, MessageId::new(msg_id));
|
||||
app.exit_pinned_mode();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
21
src/input/handlers/profile.rs
Normal file
21
src/input/handlers/profile.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
//! Profile mode helper functions
|
||||
|
||||
/// Возвращает количество доступных действий в профиле
|
||||
pub fn get_available_actions_count(profile: &crate::tdlib::ProfileInfo) -> usize {
|
||||
let mut count = 0;
|
||||
|
||||
// Всегда есть: назад, посмотреть фото
|
||||
count += 2;
|
||||
|
||||
// Уведомления (только для групп)
|
||||
if profile.is_group {
|
||||
count += 1;
|
||||
}
|
||||
|
||||
// Выход из группы (только для групп)
|
||||
if profile.is_group {
|
||||
count += 1;
|
||||
}
|
||||
|
||||
count
|
||||
}
|
||||
136
src/input/handlers/search.rs
Normal file
136
src/input/handlers/search.rs
Normal file
@@ -0,0 +1,136 @@
|
||||
//! Search input handlers
|
||||
//!
|
||||
//! Handles keyboard input for search functionality, including:
|
||||
//! - Chat list search mode
|
||||
//! - Message search mode
|
||||
//! - Search query input
|
||||
|
||||
use crate::app::methods::{navigation::NavigationMethods, search::SearchMethods};
|
||||
use crate::app::App;
|
||||
use crate::tdlib::TdClientTrait;
|
||||
use crate::types::{ChatId, MessageId};
|
||||
use crate::utils::with_timeout;
|
||||
use crossterm::event::{KeyCode, KeyEvent};
|
||||
use std::time::Duration;
|
||||
|
||||
use super::chat_loader::open_chat_and_load_data;
|
||||
use super::scroll_to_message;
|
||||
|
||||
/// Обработка режима поиска по чатам
|
||||
///
|
||||
/// Обрабатывает:
|
||||
/// - Редактирование поискового запроса (Backspace, Char)
|
||||
/// - Навигацию по отфильтрованному списку (Up/Down)
|
||||
/// - Открытие выбранного чата (Enter)
|
||||
/// - Отмену поиска (Esc)
|
||||
pub async fn handle_chat_search_mode<T: TdClientTrait>(
|
||||
app: &mut App<T>,
|
||||
key: KeyEvent,
|
||||
command: Option<crate::config::Command>,
|
||||
) {
|
||||
match command {
|
||||
Some(crate::config::Command::Cancel) => {
|
||||
app.cancel_search();
|
||||
}
|
||||
Some(crate::config::Command::SubmitMessage) => {
|
||||
app.select_filtered_chat();
|
||||
if let Some(chat_id) = app.get_selected_chat_id() {
|
||||
open_chat_and_load_data(app, chat_id).await;
|
||||
}
|
||||
}
|
||||
Some(crate::config::Command::MoveDown) => {
|
||||
app.next_filtered_chat();
|
||||
}
|
||||
Some(crate::config::Command::MoveUp) => {
|
||||
app.previous_filtered_chat();
|
||||
}
|
||||
_ => match key.code {
|
||||
KeyCode::Backspace => {
|
||||
app.search_query.pop();
|
||||
app.chat_list_state.select(Some(0));
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
app.search_query.push(c);
|
||||
app.chat_list_state.select(Some(0));
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Обработка режима поиска по сообщениям в открытом чате
|
||||
///
|
||||
/// Обрабатывает:
|
||||
/// - Навигацию по результатам поиска (Up/Down/N/n)
|
||||
/// - Переход к выбранному сообщению (Enter)
|
||||
/// - Редактирование поискового запроса (Backspace, Char)
|
||||
/// - Выход из режима поиска (Esc)
|
||||
pub async fn handle_message_search_mode<T: TdClientTrait>(
|
||||
app: &mut App<T>,
|
||||
key: KeyEvent,
|
||||
command: Option<crate::config::Command>,
|
||||
) {
|
||||
match command {
|
||||
Some(crate::config::Command::Cancel) => {
|
||||
app.exit_message_search_mode();
|
||||
}
|
||||
Some(crate::config::Command::MoveUp) => {
|
||||
app.select_previous_search_result();
|
||||
}
|
||||
Some(crate::config::Command::MoveDown) => {
|
||||
app.select_next_search_result();
|
||||
}
|
||||
Some(crate::config::Command::SubmitMessage) => {
|
||||
if let Some(msg_id) = app.get_selected_search_result_id() {
|
||||
scroll_to_message(app, MessageId::new(msg_id));
|
||||
app.exit_message_search_mode();
|
||||
}
|
||||
}
|
||||
_ => match key.code {
|
||||
KeyCode::Char('N') => {
|
||||
app.select_previous_search_result();
|
||||
}
|
||||
KeyCode::Char('n') => {
|
||||
app.select_next_search_result();
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
let Some(mut query) = app.get_search_query().map(|s| s.to_string()) else {
|
||||
return;
|
||||
};
|
||||
query.pop();
|
||||
app.update_search_query(query.clone());
|
||||
perform_message_search(app, &query).await;
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
let Some(mut query) = app.get_search_query().map(|s| s.to_string()) else {
|
||||
return;
|
||||
};
|
||||
query.push(c);
|
||||
app.update_search_query(query.clone());
|
||||
perform_message_search(app, &query).await;
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Выполняет поиск по сообщениям с обновлением результатов
|
||||
pub async fn perform_message_search<T: TdClientTrait>(app: &mut App<T>, query: &str) {
|
||||
let Some(chat_id) = app.get_selected_chat_id() else {
|
||||
return;
|
||||
};
|
||||
|
||||
if query.is_empty() {
|
||||
app.set_search_results(Vec::new());
|
||||
return;
|
||||
}
|
||||
|
||||
if let Ok(results) = with_timeout(
|
||||
Duration::from_secs(3),
|
||||
app.td_client.search_messages(ChatId::new(chat_id), query),
|
||||
)
|
||||
.await
|
||||
{
|
||||
app.set_search_results(results);
|
||||
}
|
||||
}
|
||||
450
src/input/key_handler.rs
Normal file
450
src/input/key_handler.rs
Normal file
@@ -0,0 +1,450 @@
|
||||
/// Модуль для обработки клавиш с использованием trait-based подхода
|
||||
///
|
||||
/// Позволяет каждому экрану/режиму определить свою логику обработки клавиш,
|
||||
/// избегая огромных match блоков в одном месте.
|
||||
|
||||
use crate::app::App;
|
||||
use crate::config::Command;
|
||||
use crate::tdlib::{TdClient, TdClientTrait};
|
||||
use crossterm::event::KeyEvent;
|
||||
|
||||
/// Результат обработки клавиши
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum KeyResult {
|
||||
/// Клавиша обработана, продолжить работу
|
||||
Handled,
|
||||
|
||||
/// Клавиша обработана, нужна перерисовка UI
|
||||
HandledNeedsRedraw,
|
||||
|
||||
/// Клавиша не обработана (fallback на глобальные команды)
|
||||
NotHandled,
|
||||
|
||||
/// Выход из приложения
|
||||
Quit,
|
||||
}
|
||||
|
||||
impl KeyResult {
|
||||
/// Проверяет нужна ли перерисовка
|
||||
pub fn needs_redraw(&self) -> bool {
|
||||
matches!(self, KeyResult::HandledNeedsRedraw)
|
||||
}
|
||||
|
||||
/// Проверяет был ли запрос выхода
|
||||
pub fn should_quit(&self) -> bool {
|
||||
matches!(self, KeyResult::Quit)
|
||||
}
|
||||
}
|
||||
|
||||
/// Trait для обработки клавиш на конкретном экране/в режиме
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// struct ChatListHandler;
|
||||
///
|
||||
/// impl KeyHandler for ChatListHandler {
|
||||
/// fn handle_key(
|
||||
/// &self,
|
||||
/// app: &mut App,
|
||||
/// key: KeyEvent,
|
||||
/// command: Option<Command>,
|
||||
/// ) -> KeyResult {
|
||||
/// match command {
|
||||
/// Some(Command::MoveUp) => {
|
||||
/// app.move_chat_selection_up();
|
||||
/// KeyResult::HandledNeedsRedraw
|
||||
/// }
|
||||
/// Some(Command::OpenChat) => {
|
||||
/// // Open selected chat
|
||||
/// KeyResult::HandledNeedsRedraw
|
||||
/// }
|
||||
/// _ => KeyResult::NotHandled,
|
||||
/// }
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
pub trait KeyHandler {
|
||||
/// Обрабатывает нажатие клавиши
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `app` - Mutable reference на состояние приложения
|
||||
/// * `key` - Событие клавиши от crossterm
|
||||
/// * `command` - Опциональная команда из keybindings (если привязана)
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// `KeyResult` - результат обработки (обработана/не обработана/выход)
|
||||
fn handle_key(
|
||||
&self,
|
||||
app: &mut App,
|
||||
key: KeyEvent,
|
||||
command: Option<Command>,
|
||||
) -> KeyResult;
|
||||
|
||||
/// Приоритет обработчика (для цепочки обработчиков)
|
||||
///
|
||||
/// Обработчики с более высоким приоритетом вызываются первыми.
|
||||
/// По умолчанию 0.
|
||||
fn priority(&self) -> i32 {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/// Глобальный обработчик клавиш (работает на всех экранах)
|
||||
pub struct GlobalKeyHandler;
|
||||
|
||||
impl KeyHandler for GlobalKeyHandler {
|
||||
fn handle_key(
|
||||
&self,
|
||||
app: &mut App,
|
||||
_key: KeyEvent,
|
||||
command: Option<Command>,
|
||||
) -> KeyResult {
|
||||
match command {
|
||||
Some(Command::Quit) => KeyResult::Quit,
|
||||
|
||||
Some(Command::OpenSearch) if !app.is_searching() => {
|
||||
// TODO: implement enter_search_mode or use existing method
|
||||
KeyResult::HandledNeedsRedraw
|
||||
}
|
||||
|
||||
Some(Command::Cancel) => {
|
||||
// Cancel различных режимов
|
||||
if app.is_searching() {
|
||||
// TODO: implement exit_search_mode or use existing method
|
||||
KeyResult::HandledNeedsRedraw
|
||||
} else {
|
||||
KeyResult::NotHandled
|
||||
}
|
||||
}
|
||||
|
||||
_ => KeyResult::NotHandled,
|
||||
}
|
||||
}
|
||||
|
||||
fn priority(&self) -> i32 {
|
||||
-100 // Низкий приоритет - fallback для всех экранов
|
||||
}
|
||||
}
|
||||
|
||||
/// Обработчик для списка чатов
|
||||
pub struct ChatListKeyHandler;
|
||||
|
||||
impl KeyHandler for ChatListKeyHandler {
|
||||
fn handle_key(
|
||||
&self,
|
||||
app: &mut App,
|
||||
_key: KeyEvent,
|
||||
command: Option<Command>,
|
||||
) -> KeyResult {
|
||||
match command {
|
||||
Some(Command::MoveUp) => {
|
||||
// TODO: implement chat selection navigation
|
||||
// app.chat_list_state is ListState, use .select()
|
||||
KeyResult::HandledNeedsRedraw
|
||||
}
|
||||
|
||||
Some(Command::MoveDown) => {
|
||||
// TODO: implement chat selection navigation
|
||||
KeyResult::HandledNeedsRedraw
|
||||
}
|
||||
|
||||
Some(Command::OpenChat) => {
|
||||
// Обработка открытия чата будет в async контексте
|
||||
// Здесь только возвращаем что команда распознана
|
||||
KeyResult::HandledNeedsRedraw
|
||||
}
|
||||
|
||||
// Папки 1-9
|
||||
Some(Command::SelectFolder1) => {
|
||||
app.set_selected_folder_id(Some(1));
|
||||
KeyResult::HandledNeedsRedraw
|
||||
}
|
||||
Some(Command::SelectFolder2) => {
|
||||
app.set_selected_folder_id(Some(2));
|
||||
KeyResult::HandledNeedsRedraw
|
||||
}
|
||||
Some(Command::SelectFolder3) => {
|
||||
app.set_selected_folder_id(Some(3));
|
||||
KeyResult::HandledNeedsRedraw
|
||||
}
|
||||
Some(Command::SelectFolder4) => {
|
||||
app.set_selected_folder_id(Some(4));
|
||||
KeyResult::HandledNeedsRedraw
|
||||
}
|
||||
Some(Command::SelectFolder5) => {
|
||||
app.set_selected_folder_id(Some(5));
|
||||
KeyResult::HandledNeedsRedraw
|
||||
}
|
||||
Some(Command::SelectFolder6) => {
|
||||
app.set_selected_folder_id(Some(6));
|
||||
KeyResult::HandledNeedsRedraw
|
||||
}
|
||||
Some(Command::SelectFolder7) => {
|
||||
app.set_selected_folder_id(Some(7));
|
||||
KeyResult::HandledNeedsRedraw
|
||||
}
|
||||
Some(Command::SelectFolder8) => {
|
||||
app.set_selected_folder_id(Some(8));
|
||||
KeyResult::HandledNeedsRedraw
|
||||
}
|
||||
Some(Command::SelectFolder9) => {
|
||||
app.set_selected_folder_id(Some(9));
|
||||
KeyResult::HandledNeedsRedraw
|
||||
}
|
||||
|
||||
_ => KeyResult::NotHandled,
|
||||
}
|
||||
}
|
||||
|
||||
fn priority(&self) -> i32 {
|
||||
10 // Средний приоритет
|
||||
}
|
||||
}
|
||||
|
||||
/// Обработчик для просмотра сообщений
|
||||
pub struct MessageViewKeyHandler;
|
||||
|
||||
impl KeyHandler for MessageViewKeyHandler {
|
||||
fn handle_key(
|
||||
&self,
|
||||
app: &mut App,
|
||||
_key: KeyEvent,
|
||||
command: Option<Command>,
|
||||
) -> KeyResult {
|
||||
match command {
|
||||
Some(Command::MoveUp) => {
|
||||
if app.message_view_state().message_scroll_offset > 0 {
|
||||
app.message_view_state().message_scroll_offset -= 1;
|
||||
KeyResult::HandledNeedsRedraw
|
||||
} else {
|
||||
KeyResult::Handled
|
||||
}
|
||||
}
|
||||
|
||||
Some(Command::MoveDown) => {
|
||||
app.message_view_state().message_scroll_offset += 1;
|
||||
KeyResult::HandledNeedsRedraw
|
||||
}
|
||||
|
||||
Some(Command::PageUp) => {
|
||||
app.message_view_state().message_scroll_offset = app.message_view_state().message_scroll_offset.saturating_sub(10);
|
||||
KeyResult::HandledNeedsRedraw
|
||||
}
|
||||
|
||||
Some(Command::PageDown) => {
|
||||
app.message_view_state().message_scroll_offset += 10;
|
||||
KeyResult::HandledNeedsRedraw
|
||||
}
|
||||
|
||||
Some(Command::OpenSearchInChat) => {
|
||||
// Открыть поиск в чате
|
||||
KeyResult::HandledNeedsRedraw
|
||||
}
|
||||
|
||||
Some(Command::OpenProfile) => {
|
||||
// Открыть профиль
|
||||
KeyResult::HandledNeedsRedraw
|
||||
}
|
||||
|
||||
_ => KeyResult::NotHandled,
|
||||
}
|
||||
}
|
||||
|
||||
fn priority(&self) -> i32 {
|
||||
10 // Средний приоритет
|
||||
}
|
||||
}
|
||||
|
||||
/// Обработчик для режима выбора сообщения
|
||||
pub struct MessageSelectionKeyHandler;
|
||||
|
||||
impl KeyHandler for MessageSelectionKeyHandler {
|
||||
fn handle_key(
|
||||
&self,
|
||||
_app: &mut App,
|
||||
_key: KeyEvent,
|
||||
command: Option<Command>,
|
||||
) -> KeyResult {
|
||||
match command {
|
||||
Some(Command::DeleteMessage) => {
|
||||
// Показать модалку подтверждения удаления
|
||||
KeyResult::HandledNeedsRedraw
|
||||
}
|
||||
|
||||
Some(Command::ReplyMessage) => {
|
||||
// Войти в режим ответа
|
||||
KeyResult::HandledNeedsRedraw
|
||||
}
|
||||
|
||||
Some(Command::ForwardMessage) => {
|
||||
// Войти в режим пересылки
|
||||
KeyResult::HandledNeedsRedraw
|
||||
}
|
||||
|
||||
Some(Command::CopyMessage) => {
|
||||
// Скопировать текст в буфер
|
||||
KeyResult::HandledNeedsRedraw
|
||||
}
|
||||
|
||||
Some(Command::ReactMessage) => {
|
||||
// Открыть emoji picker
|
||||
KeyResult::HandledNeedsRedraw
|
||||
}
|
||||
|
||||
Some(Command::Cancel) => {
|
||||
// Выйти из режима выбора
|
||||
KeyResult::HandledNeedsRedraw
|
||||
}
|
||||
|
||||
_ => KeyResult::NotHandled,
|
||||
}
|
||||
}
|
||||
|
||||
fn priority(&self) -> i32 {
|
||||
20 // Высокий приоритет - режимы должны обрабатываться первыми
|
||||
}
|
||||
}
|
||||
|
||||
/// Цепочка обработчиков клавиш
|
||||
///
|
||||
/// Позволяет комбинировать несколько обработчиков в порядке приоритета.
|
||||
pub struct KeyHandlerChain {
|
||||
handlers: Vec<(i32, Box<dyn KeyHandler>)>,
|
||||
}
|
||||
|
||||
impl KeyHandlerChain {
|
||||
/// Создаёт новую цепочку
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
handlers: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Добавляет обработчик в цепочку
|
||||
pub fn add<H: KeyHandler + 'static>(mut self, handler: H) -> Self {
|
||||
let priority = handler.priority();
|
||||
self.handlers.push((priority, Box::new(handler)));
|
||||
// Сортируем по убыванию приоритета
|
||||
self.handlers.sort_by(|a, b| b.0.cmp(&a.0));
|
||||
self
|
||||
}
|
||||
|
||||
/// Обрабатывает клавишу, вызывая обработчики по порядку
|
||||
///
|
||||
/// Останавливается на первом обработчике, который вернул Handled/HandledNeedsRedraw/Quit
|
||||
pub fn handle(
|
||||
&self,
|
||||
app: &mut App,
|
||||
key: KeyEvent,
|
||||
command: Option<Command>,
|
||||
) -> KeyResult {
|
||||
for (_priority, handler) in &self.handlers {
|
||||
let result = handler.handle_key(app, key, command);
|
||||
if result != KeyResult::NotHandled {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
KeyResult::NotHandled
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for KeyHandlerChain {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crossterm::event::KeyCode;
|
||||
|
||||
#[test]
|
||||
fn test_key_result_needs_redraw() {
|
||||
assert!(!KeyResult::Handled.needs_redraw());
|
||||
assert!(KeyResult::HandledNeedsRedraw.needs_redraw());
|
||||
assert!(!KeyResult::NotHandled.needs_redraw());
|
||||
assert!(!KeyResult::Quit.needs_redraw());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_key_result_should_quit() {
|
||||
assert!(!KeyResult::Handled.should_quit());
|
||||
assert!(!KeyResult::HandledNeedsRedraw.should_quit());
|
||||
assert!(!KeyResult::NotHandled.should_quit());
|
||||
assert!(KeyResult::Quit.should_quit());
|
||||
}
|
||||
|
||||
// TODO: Enable these tests after App trait integration
|
||||
// #[test]
|
||||
// fn test_global_handler_quit() {
|
||||
// let handler = GlobalKeyHandler;
|
||||
// let mut app = App::new_for_test();
|
||||
//
|
||||
// let result = handler.handle_key(
|
||||
// &mut app,
|
||||
// KeyEvent::from(KeyCode::Char('q')),
|
||||
// Some(Command::Quit),
|
||||
// );
|
||||
//
|
||||
// assert_eq!(result, KeyResult::Quit);
|
||||
// }
|
||||
|
||||
// #[test]
|
||||
// fn test_chat_list_handler_navigation() {
|
||||
// let handler = ChatListKeyHandler;
|
||||
// let mut app = App::new_for_test();
|
||||
//
|
||||
// // Test move up (should be handled even at top)
|
||||
// let result = handler.handle_key(
|
||||
// &mut app,
|
||||
// KeyEvent::from(KeyCode::Up),
|
||||
// Some(Command::MoveUp),
|
||||
// );
|
||||
//
|
||||
// assert_eq!(result, KeyResult::Handled);
|
||||
// }
|
||||
|
||||
// #[test]
|
||||
// fn test_handler_chain() {
|
||||
// let chain = KeyHandlerChain::new()
|
||||
// .add(ChatListKeyHandler)
|
||||
// .add(GlobalKeyHandler);
|
||||
//
|
||||
// let mut app = App::new_for_test();
|
||||
//
|
||||
// // ChatListHandler should handle MoveUp first
|
||||
// let result = chain.handle(
|
||||
// &mut app,
|
||||
// KeyEvent::from(KeyCode::Up),
|
||||
// Some(Command::MoveUp),
|
||||
// );
|
||||
//
|
||||
// assert_eq!(result, KeyResult::Handled);
|
||||
//
|
||||
// // GlobalHandler should handle Quit
|
||||
// let result = chain.handle(
|
||||
// &mut app,
|
||||
// KeyEvent::from(KeyCode::Char('q')),
|
||||
// Some(Command::Quit),
|
||||
// );
|
||||
//
|
||||
// assert_eq!(result, KeyResult::Quit);
|
||||
// }
|
||||
|
||||
#[test]
|
||||
fn test_handler_priority() {
|
||||
let handler1 = ChatListKeyHandler;
|
||||
let handler2 = MessageSelectionKeyHandler;
|
||||
let handler3 = GlobalKeyHandler;
|
||||
|
||||
assert_eq!(handler1.priority(), 10);
|
||||
assert_eq!(handler2.priority(), 20);
|
||||
assert_eq!(handler3.priority(), -100);
|
||||
|
||||
// В цепочке должны быть отсортированы: MessageSelection > ChatList > Global
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,9 @@
|
||||
//! Input handling module.
|
||||
//!
|
||||
//! Routes keyboard events by screen (Auth vs Main) to specialized handlers.
|
||||
|
||||
mod auth;
|
||||
pub mod handlers;
|
||||
mod main_input;
|
||||
|
||||
pub use auth::handle as handle_auth_input;
|
||||
|
||||
11
src/lib.rs
11
src/lib.rs
@@ -1,13 +1,18 @@
|
||||
// Library interface for tele-tui
|
||||
// This allows tests to import modules
|
||||
//! tele-tui — TUI client for Telegram
|
||||
//!
|
||||
//! Library interface exposing modules for integration testing.
|
||||
|
||||
pub mod accounts;
|
||||
pub mod app;
|
||||
pub mod audio;
|
||||
pub mod config;
|
||||
pub mod constants;
|
||||
pub mod error;
|
||||
pub mod formatting;
|
||||
pub mod input;
|
||||
#[cfg(feature = "images")]
|
||||
pub mod media;
|
||||
pub mod message_grouping;
|
||||
pub mod notifications;
|
||||
pub mod tdlib;
|
||||
pub mod types;
|
||||
pub mod ui;
|
||||
|
||||
333
src/main.rs
333
src/main.rs
@@ -1,9 +1,14 @@
|
||||
mod accounts;
|
||||
mod app;
|
||||
mod audio;
|
||||
mod config;
|
||||
mod constants;
|
||||
mod error;
|
||||
mod formatting;
|
||||
mod input;
|
||||
#[cfg(feature = "images")]
|
||||
mod media;
|
||||
mod message_grouping;
|
||||
mod notifications;
|
||||
mod tdlib;
|
||||
mod types;
|
||||
mod ui;
|
||||
@@ -24,8 +29,22 @@ use tdlib_rs::enums::Update;
|
||||
use app::{App, AppScreen};
|
||||
use constants::{POLL_TIMEOUT_MS, SHUTDOWN_TIMEOUT_SECS};
|
||||
use input::{handle_auth_input, handle_main_input};
|
||||
use input::handlers::process_pending_chat_init;
|
||||
use tdlib::AuthState;
|
||||
use utils::disable_tdlib_logs;
|
||||
use utils::{disable_tdlib_logs, with_timeout_ignore};
|
||||
|
||||
/// Parses `--account <name>` from CLI arguments.
|
||||
fn parse_account_arg() -> Option<String> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let mut i = 1;
|
||||
while i < args.len() {
|
||||
if args[i] == "--account" && i + 1 < args.len() {
|
||||
return Some(args[i + 1].clone());
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), io::Error> {
|
||||
@@ -37,13 +56,41 @@ async fn main() -> Result<(), io::Error> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn"))
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn")),
|
||||
)
|
||||
.init();
|
||||
|
||||
// Загружаем конфигурацию (создаёт дефолтный если отсутствует)
|
||||
let config = config::Config::load();
|
||||
|
||||
// Загружаем/создаём accounts.toml + миграция legacy ./tdlib_data/
|
||||
let accounts_config = accounts::load_or_create();
|
||||
|
||||
// Резолвим аккаунт из CLI или default
|
||||
let account_arg = parse_account_arg();
|
||||
let (account_name, db_path) =
|
||||
accounts::resolve_account(&accounts_config, account_arg.as_deref()).unwrap_or_else(|e| {
|
||||
eprintln!("Error: {}", e);
|
||||
std::process::exit(1);
|
||||
});
|
||||
|
||||
// Создаём директорию аккаунта если её нет
|
||||
let db_path = accounts::ensure_account_dir(
|
||||
account_arg
|
||||
.as_deref()
|
||||
.unwrap_or(&accounts_config.default_account),
|
||||
)
|
||||
.unwrap_or(db_path);
|
||||
|
||||
// Acquire per-account lock BEFORE raw mode (so error prints to normal terminal)
|
||||
let account_lock = accounts::acquire_lock(
|
||||
account_arg.as_deref().unwrap_or(&accounts_config.default_account),
|
||||
)
|
||||
.unwrap_or_else(|e| {
|
||||
eprintln!("Error: {}", e);
|
||||
std::process::exit(1);
|
||||
});
|
||||
|
||||
// Отключаем логи TDLib ДО создания клиента
|
||||
disable_tdlib_logs();
|
||||
|
||||
@@ -54,8 +101,49 @@ async fn main() -> Result<(), io::Error> {
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
|
||||
// Create app state
|
||||
let mut app = App::new(config);
|
||||
// Ensure terminal restoration on panic
|
||||
let panic_hook = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |info| {
|
||||
let _ = disable_raw_mode();
|
||||
let _ = execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture);
|
||||
panic_hook(info);
|
||||
}));
|
||||
|
||||
// Create app state with account-specific db_path
|
||||
let mut app = App::new(config, db_path);
|
||||
app.current_account_name = account_name;
|
||||
app.account_lock = Some(account_lock);
|
||||
|
||||
// Запускаем инициализацию 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();
|
||||
let db_path_str = app.td_client.db_path.to_string_lossy().to_string();
|
||||
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = tdlib_rs::functions::set_tdlib_parameters(
|
||||
false, // use_test_dc
|
||||
db_path_str, // 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
|
||||
{
|
||||
tracing::error!("set_tdlib_parameters failed: {:?}", e);
|
||||
}
|
||||
});
|
||||
|
||||
let res = run_app(&mut terminal, &mut app).await;
|
||||
|
||||
// Restore terminal
|
||||
@@ -70,9 +158,9 @@ async fn main() -> Result<(), io::Error> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_app<B: ratatui::backend::Backend>(
|
||||
async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
|
||||
terminal: &mut Terminal<B>,
|
||||
app: &mut App,
|
||||
app: &mut App<T>,
|
||||
) -> io::Result<()> {
|
||||
// Флаг для остановки polling задачи
|
||||
let should_stop = Arc::new(AtomicBool::new(false));
|
||||
@@ -85,7 +173,7 @@ async fn run_app<B: ratatui::backend::Backend>(
|
||||
let polling_handle = tokio::spawn(async move {
|
||||
while !should_stop_clone.load(Ordering::Relaxed) {
|
||||
// receive() с таймаутом 0.1 сек чтобы периодически проверять флаг
|
||||
let result = tokio::task::spawn_blocking(|| tdlib_rs::receive()).await;
|
||||
let result = tokio::task::spawn_blocking(tdlib_rs::receive).await;
|
||||
if let Ok(Some((update, _client_id))) = result {
|
||||
if update_tx.send(update).is_err() {
|
||||
break; // Канал закрыт, выходим
|
||||
@@ -94,32 +182,6 @@ async fn run_app<B: ratatui::backend::Backend>(
|
||||
}
|
||||
});
|
||||
|
||||
// Запускаем инициализацию 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;
|
||||
@@ -133,6 +195,75 @@ async fn run_app<B: ratatui::backend::Backend>(
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
|
||||
// Обрабатываем результаты фоновой загрузки фото
|
||||
#[cfg(feature = "images")]
|
||||
{
|
||||
use crate::tdlib::PhotoDownloadState;
|
||||
|
||||
let mut got_photos = false;
|
||||
if let Some(ref mut rx) = app.photo_download_rx {
|
||||
while let Ok((file_id, result)) = rx.try_recv() {
|
||||
let new_state = match result {
|
||||
Ok(path) => PhotoDownloadState::Downloaded(path),
|
||||
Err(_) => PhotoDownloadState::Error("Ошибка загрузки".to_string()),
|
||||
};
|
||||
for msg in app.td_client.current_chat_messages_mut() {
|
||||
if let Some(photo) = msg.photo_info_mut() {
|
||||
if photo.file_id == file_id {
|
||||
photo.download_state = new_state;
|
||||
got_photos = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Если это фото ждёт открытия в модалке — открываем
|
||||
let pending_matches = app
|
||||
.pending_image_open
|
||||
.as_ref()
|
||||
.map(|p| p.file_id == file_id)
|
||||
.unwrap_or(false);
|
||||
if pending_matches {
|
||||
// Ищем путь из обновлённого состояния
|
||||
let downloaded_path = app
|
||||
.td_client
|
||||
.current_chat_messages()
|
||||
.iter()
|
||||
.find_map(|m| {
|
||||
m.photo_info().and_then(|p| {
|
||||
if p.file_id == file_id {
|
||||
if let PhotoDownloadState::Downloaded(ref path) =
|
||||
p.download_state
|
||||
{
|
||||
Some(path.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
});
|
||||
if let (Some(path), Some(pending)) =
|
||||
(downloaded_path, app.pending_image_open.take())
|
||||
{
|
||||
use crate::tdlib::ImageModalState;
|
||||
app.image_modal = Some(ImageModalState {
|
||||
message_id: pending.message_id,
|
||||
photo_path: path,
|
||||
photo_width: pending.photo_width,
|
||||
photo_height: pending.photo_height,
|
||||
});
|
||||
app.status_message = None;
|
||||
got_photos = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if got_photos {
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Очищаем устаревший typing status
|
||||
if app.td_client.clear_stale_typing_status() {
|
||||
app.needs_redraw = true;
|
||||
@@ -154,6 +285,42 @@ async fn run_app<B: ratatui::backend::Backend>(
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
|
||||
// Обновляем позицию воспроизведения голосового сообщения
|
||||
{
|
||||
let mut stop_playback = false;
|
||||
if let Some(ref mut playback) = app.playback_state {
|
||||
use crate::tdlib::PlaybackStatus;
|
||||
match playback.status {
|
||||
PlaybackStatus::Playing => {
|
||||
let prev_second = playback.position as u32;
|
||||
if let Some(last_tick) = app.last_playback_tick {
|
||||
let delta = last_tick.elapsed().as_secs_f32();
|
||||
playback.position += delta;
|
||||
}
|
||||
app.last_playback_tick = Some(std::time::Instant::now());
|
||||
|
||||
// Проверяем завершение воспроизведения
|
||||
if playback.position >= playback.duration
|
||||
|| app.audio_player.as_ref().is_some_and(|p| p.is_stopped())
|
||||
{
|
||||
stop_playback = true;
|
||||
}
|
||||
// Перерисовка только при смене секунды (не 60 FPS)
|
||||
if playback.position as u32 != prev_second || stop_playback {
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
app.last_playback_tick = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
if stop_playback {
|
||||
app.stop_playback();
|
||||
app.last_playback_tick = None;
|
||||
}
|
||||
}
|
||||
|
||||
// Рендерим только если есть изменения
|
||||
if app.needs_redraw {
|
||||
terminal.draw(|f| ui::render(f, app))?;
|
||||
@@ -172,21 +339,39 @@ async fn run_app<B: ratatui::backend::Backend>(
|
||||
// Graceful shutdown
|
||||
should_stop.store(true, Ordering::Relaxed);
|
||||
|
||||
// Останавливаем воспроизведение голосового (убиваем ffplay)
|
||||
app.stop_playback();
|
||||
|
||||
// Закрываем TDLib клиент
|
||||
let _ = tdlib_rs::functions::close(app.td_client.client_id()).await;
|
||||
|
||||
// Ждём завершения polling задачи (с таймаутом)
|
||||
let _ = tokio::time::timeout(Duration::from_secs(SHUTDOWN_TIMEOUT_SECS), polling_handle).await;
|
||||
with_timeout_ignore(
|
||||
Duration::from_secs(SHUTDOWN_TIMEOUT_SECS),
|
||||
polling_handle,
|
||||
)
|
||||
.await;
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
match app.screen {
|
||||
AppScreen::Loading => {
|
||||
// В состоянии загрузки игнорируем ввод
|
||||
// Ctrl+A opens account switcher from any screen
|
||||
if key.code == KeyCode::Char('a')
|
||||
&& key.modifiers.contains(KeyModifiers::CONTROL)
|
||||
&& app.account_switcher.is_none()
|
||||
{
|
||||
app.open_account_switcher();
|
||||
} else if app.account_switcher.is_some() {
|
||||
// Route to main input handler when account switcher is open
|
||||
handle_main_input(app, key).await;
|
||||
} else {
|
||||
match app.screen {
|
||||
AppScreen::Loading => {
|
||||
// В состоянии загрузки игнорируем ввод
|
||||
}
|
||||
AppScreen::Auth => handle_auth_input(app, key.code).await,
|
||||
AppScreen::Main => handle_main_input(app, key).await,
|
||||
}
|
||||
AppScreen::Auth => handle_auth_input(app, key.code).await,
|
||||
AppScreen::Main => handle_main_input(app, key).await,
|
||||
}
|
||||
|
||||
// Любой ввод требует перерисовки
|
||||
@@ -199,12 +384,70 @@ async fn run_app<B: ratatui::backend::Backend>(
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Process pending chat initialization (reply info, pinned, photos)
|
||||
if let Some(chat_id) = app.pending_chat_init.take() {
|
||||
process_pending_chat_init(app, chat_id).await;
|
||||
}
|
||||
|
||||
// Check pending account switch
|
||||
if let Some((account_name, new_db_path)) = app.pending_account_switch.take() {
|
||||
// 0. Acquire lock for new account before switching
|
||||
match accounts::acquire_lock(&account_name) {
|
||||
Ok(new_lock) => {
|
||||
// Release old lock
|
||||
if let Some(old_lock) = app.account_lock.take() {
|
||||
accounts::release_lock(old_lock);
|
||||
}
|
||||
app.account_lock = Some(new_lock);
|
||||
}
|
||||
Err(e) => {
|
||||
app.error_message = Some(e);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Stop playback
|
||||
app.stop_playback();
|
||||
|
||||
// 2. Recreate client (closes old, creates new, inits TDLib params)
|
||||
if let Err(e) = app.td_client.recreate_client(new_db_path).await {
|
||||
app.error_message = Some(format!("Ошибка переключения: {}", e));
|
||||
continue;
|
||||
}
|
||||
|
||||
// 3. Reset app state
|
||||
app.current_account_name = account_name.clone();
|
||||
app.screen = AppScreen::Loading;
|
||||
|
||||
// 4. Persist selected account as default for next launch
|
||||
let mut accounts_config = accounts::load_or_create();
|
||||
accounts_config.default_account = account_name;
|
||||
if let Err(e) = accounts::save(&accounts_config) {
|
||||
tracing::warn!("Could not save default account: {}", e);
|
||||
}
|
||||
app.chats.clear();
|
||||
app.selected_chat_id = None;
|
||||
app.chat_state = Default::default();
|
||||
app.input_mode = Default::default();
|
||||
app.status_message = Some("Переключение аккаунта...".to_string());
|
||||
app.error_message = None;
|
||||
app.is_searching = false;
|
||||
app.search_query.clear();
|
||||
app.message_input.clear();
|
||||
app.cursor_position = 0;
|
||||
app.message_scroll_offset = 0;
|
||||
app.pending_chat_init = None;
|
||||
app.account_switcher = None;
|
||||
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Возвращает true если состояние изменилось и требуется перерисовка
|
||||
async fn update_screen_state(app: &mut App) -> bool {
|
||||
use tokio::time::timeout;
|
||||
async fn update_screen_state<T: tdlib::TdClientTrait>(app: &mut App<T>) -> bool {
|
||||
use utils::with_timeout_ignore;
|
||||
|
||||
let prev_screen = app.screen.clone();
|
||||
let prev_status = app.status_message.clone();
|
||||
@@ -226,8 +469,8 @@ async fn update_screen_state(app: &mut App) -> bool {
|
||||
app.is_loading = true;
|
||||
app.status_message = Some("Загрузка чатов...".to_string());
|
||||
|
||||
// Запрашиваем загрузку чатов с таймаутом
|
||||
let _ = timeout(Duration::from_secs(5), app.td_client.load_chats(50)).await;
|
||||
// Запрашиваем загрузку чатов с таймаутом (игнорируем ошибки)
|
||||
with_timeout_ignore(Duration::from_secs(5), app.td_client.load_chats(50)).await;
|
||||
}
|
||||
|
||||
// Синхронизируем чаты из td_client в app
|
||||
@@ -236,6 +479,8 @@ async fn update_screen_state(app: &mut App) -> bool {
|
||||
if app.chat_list_state.selected().is_none() && !app.chats.is_empty() {
|
||||
app.chat_list_state.select(Some(0));
|
||||
}
|
||||
// Синхронизируем muted чаты для notifications
|
||||
app.td_client.sync_notification_muted_chats();
|
||||
// Убираем статус загрузки когда чаты появились
|
||||
if app.is_loading {
|
||||
app.is_loading = false;
|
||||
|
||||
112
src/media/cache.rs
Normal file
112
src/media/cache.rs
Normal file
@@ -0,0 +1,112 @@
|
||||
//! Image cache with LRU eviction.
|
||||
//!
|
||||
//! Stores downloaded images in `~/.cache/tele-tui/images/` with size-based eviction.
|
||||
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Кэш изображений с LRU eviction по mtime
|
||||
#[allow(dead_code)]
|
||||
pub struct ImageCache {
|
||||
cache_dir: PathBuf,
|
||||
max_size_bytes: u64,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl ImageCache {
|
||||
/// Создаёт новый кэш с указанным лимитом в МБ
|
||||
pub fn new(cache_size_mb: u64) -> Self {
|
||||
let cache_dir = dirs::cache_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("/tmp"))
|
||||
.join("tele-tui")
|
||||
.join("images");
|
||||
|
||||
// Создаём директорию кэша если не существует
|
||||
let _ = fs::create_dir_all(&cache_dir);
|
||||
|
||||
Self {
|
||||
cache_dir,
|
||||
max_size_bytes: cache_size_mb * 1024 * 1024,
|
||||
}
|
||||
}
|
||||
|
||||
/// Проверяет, есть ли файл в кэше
|
||||
pub fn get_cached(&self, file_id: i32) -> Option<PathBuf> {
|
||||
let path = self.cache_dir.join(format!("{}.jpg", file_id));
|
||||
if path.exists() {
|
||||
// Обновляем mtime для LRU
|
||||
let _ = filetime::set_file_mtime(&path, filetime::FileTime::now());
|
||||
Some(path)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Кэширует файл, копируя из source_path
|
||||
pub fn cache_file(&self, file_id: i32, source_path: &str) -> Result<PathBuf, String> {
|
||||
let dest = self.cache_dir.join(format!("{}.jpg", file_id));
|
||||
|
||||
fs::copy(source_path, &dest).map_err(|e| format!("Ошибка кэширования: {}", e))?;
|
||||
|
||||
// Evict если превышен лимит
|
||||
self.evict_if_needed();
|
||||
|
||||
Ok(dest)
|
||||
}
|
||||
|
||||
/// Удаляет старые файлы если кэш превышает лимит
|
||||
fn evict_if_needed(&self) {
|
||||
let entries = match fs::read_dir(&self.cache_dir) {
|
||||
Ok(entries) => entries,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
let mut files: Vec<(PathBuf, u64, std::time::SystemTime)> = entries
|
||||
.filter_map(|e| e.ok())
|
||||
.filter_map(|e| {
|
||||
let meta = e.metadata().ok()?;
|
||||
let mtime = meta.modified().ok()?;
|
||||
Some((e.path(), meta.len(), mtime))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let total_size: u64 = files.iter().map(|(_, size, _)| size).sum();
|
||||
|
||||
if total_size <= self.max_size_bytes {
|
||||
return;
|
||||
}
|
||||
|
||||
// Сортируем по mtime (старые первые)
|
||||
files.sort_by_key(|(_, _, mtime)| *mtime);
|
||||
|
||||
let mut current_size = total_size;
|
||||
for (path, size, _) in &files {
|
||||
if current_size <= self.max_size_bytes {
|
||||
break;
|
||||
}
|
||||
let _ = fs::remove_file(path);
|
||||
current_size -= size;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Обёртка для установки mtime без внешней зависимости
|
||||
#[allow(dead_code)]
|
||||
mod filetime {
|
||||
use std::path::Path;
|
||||
|
||||
pub struct FileTime;
|
||||
|
||||
impl FileTime {
|
||||
pub fn now() -> Self {
|
||||
FileTime
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_file_mtime(_path: &Path, _time: FileTime) -> Result<(), std::io::Error> {
|
||||
// На macOS/Linux можно использовать utime, но для простоты
|
||||
// достаточно прочитать файл (обновит atime) — LRU по mtime не критичен
|
||||
// для нашего use case. Файл будет перезаписан при повторном скачивании.
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
125
src/media/image_renderer.rs
Normal file
125
src/media/image_renderer.rs
Normal file
@@ -0,0 +1,125 @@
|
||||
//! Terminal image renderer using ratatui-image.
|
||||
//!
|
||||
//! Detects terminal protocol (iTerm2, Sixel, Halfblocks) and renders images
|
||||
//! as StatefulProtocol widgets.
|
||||
//!
|
||||
//! Implements LRU-like caching for protocols to avoid unlimited memory growth.
|
||||
|
||||
use crate::types::MessageId;
|
||||
use ratatui_image::picker::{Picker, ProtocolType};
|
||||
use ratatui_image::protocol::StatefulProtocol;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Максимальное количество кэшированных протоколов (LRU)
|
||||
const MAX_CACHED_PROTOCOLS: usize = 100;
|
||||
|
||||
/// Рендерер изображений для терминала с LRU кэшем
|
||||
pub struct ImageRenderer {
|
||||
picker: Picker,
|
||||
/// Протоколы рендеринга для каждого сообщения (message_id -> protocol)
|
||||
protocols: HashMap<i64, StatefulProtocol>,
|
||||
/// Порядок доступа для LRU (message_id -> порядковый номер)
|
||||
access_order: HashMap<i64, usize>,
|
||||
/// Счётчик для отслеживания порядка доступа
|
||||
access_counter: usize,
|
||||
}
|
||||
|
||||
impl ImageRenderer {
|
||||
/// Создаёт ImageRenderer с автодетектом протокола (высокое качество для modal)
|
||||
pub fn new() -> Option<Self> {
|
||||
let picker = Picker::from_query_stdio().ok()?;
|
||||
|
||||
Some(Self {
|
||||
picker,
|
||||
protocols: HashMap::new(),
|
||||
access_order: HashMap::new(),
|
||||
access_counter: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Создаёт ImageRenderer с принудительным Halfblocks (быстро, для inline preview)
|
||||
pub fn new_fast() -> Option<Self> {
|
||||
let mut picker = Picker::from_fontsize((8, 12));
|
||||
picker.set_protocol_type(ProtocolType::Halfblocks);
|
||||
|
||||
Some(Self {
|
||||
picker,
|
||||
protocols: HashMap::new(),
|
||||
access_order: HashMap::new(),
|
||||
access_counter: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Загружает изображение из файла и создаёт протокол рендеринга.
|
||||
///
|
||||
/// Если протокол уже существует, не загружает повторно (кэширование).
|
||||
/// Использует LRU eviction при превышении лимита.
|
||||
pub fn load_image(&mut self, msg_id: MessageId, path: &str) -> Result<(), String> {
|
||||
let msg_id_i64 = msg_id.as_i64();
|
||||
|
||||
// Оптимизация: если протокол уже есть, обновляем access time и возвращаем
|
||||
if self.protocols.contains_key(&msg_id_i64) {
|
||||
self.access_counter += 1;
|
||||
self.access_order.insert(msg_id_i64, self.access_counter);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Evict старые протоколы если превышен лимит
|
||||
if self.protocols.len() >= MAX_CACHED_PROTOCOLS {
|
||||
self.evict_oldest_protocol();
|
||||
}
|
||||
|
||||
let img = image::ImageReader::open(path)
|
||||
.map_err(|e| format!("Ошибка открытия: {}", e))?
|
||||
.decode()
|
||||
.map_err(|e| format!("Ошибка декодирования: {}", e))?;
|
||||
|
||||
let protocol = self.picker.new_resize_protocol(img);
|
||||
self.protocols.insert(msg_id_i64, protocol);
|
||||
|
||||
// Обновляем access order
|
||||
self.access_counter += 1;
|
||||
self.access_order.insert(msg_id_i64, self.access_counter);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Удаляет самый старый протокол (LRU eviction)
|
||||
fn evict_oldest_protocol(&mut self) {
|
||||
if let Some((&oldest_id, _)) = self.access_order.iter().min_by_key(|(_, &order)| order) {
|
||||
self.protocols.remove(&oldest_id);
|
||||
self.access_order.remove(&oldest_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Получает мутабельную ссылку на протокол для рендеринга.
|
||||
///
|
||||
/// Обновляет access time для LRU.
|
||||
pub fn get_protocol(&mut self, msg_id: &MessageId) -> Option<&mut StatefulProtocol> {
|
||||
let msg_id_i64 = msg_id.as_i64();
|
||||
|
||||
if self.protocols.contains_key(&msg_id_i64) {
|
||||
// Обновляем access time
|
||||
self.access_counter += 1;
|
||||
self.access_order.insert(msg_id_i64, self.access_counter);
|
||||
}
|
||||
|
||||
self.protocols.get_mut(&msg_id_i64)
|
||||
}
|
||||
|
||||
/// Удаляет протокол для сообщения
|
||||
#[allow(dead_code)]
|
||||
pub fn remove(&mut self, msg_id: &MessageId) {
|
||||
let msg_id_i64 = msg_id.as_i64();
|
||||
self.protocols.remove(&msg_id_i64);
|
||||
self.access_order.remove(&msg_id_i64);
|
||||
}
|
||||
|
||||
/// Очищает все протоколы
|
||||
#[allow(dead_code)]
|
||||
pub fn clear(&mut self) {
|
||||
self.protocols.clear();
|
||||
self.access_order.clear();
|
||||
self.access_counter = 0;
|
||||
}
|
||||
}
|
||||
9
src/media/mod.rs
Normal file
9
src/media/mod.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
//! Media handling module (feature-gated under "images").
|
||||
//!
|
||||
//! Provides image caching and terminal image rendering via ratatui-image.
|
||||
|
||||
#[cfg(feature = "images")]
|
||||
pub mod cache;
|
||||
|
||||
#[cfg(feature = "images")]
|
||||
pub mod image_renderer;
|
||||
@@ -12,9 +12,14 @@ pub enum MessageGroup {
|
||||
/// Разделитель даты (день в формате timestamp)
|
||||
DateSeparator(i32),
|
||||
/// Заголовок отправителя (is_outgoing, sender_name)
|
||||
SenderHeader { is_outgoing: bool, sender_name: String },
|
||||
SenderHeader {
|
||||
is_outgoing: bool,
|
||||
sender_name: String,
|
||||
},
|
||||
/// Сообщение
|
||||
Message(MessageInfo),
|
||||
Message(Box<MessageInfo>),
|
||||
/// Альбом (группа фото с одинаковым media_album_id)
|
||||
Album(Vec<MessageInfo>),
|
||||
}
|
||||
|
||||
/// Группирует сообщения по дате и отправителю
|
||||
@@ -51,6 +56,10 @@ pub enum MessageGroup {
|
||||
/// // Рендерим сообщение
|
||||
/// println!("{}", msg.text());
|
||||
/// }
|
||||
/// MessageGroup::Album(messages) => {
|
||||
/// // Рендерим альбом (группу фото)
|
||||
/// println!("Album with {} photos", messages.len());
|
||||
/// }
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
@@ -58,12 +67,28 @@ pub fn group_messages(messages: &[MessageInfo]) -> Vec<MessageGroup> {
|
||||
let mut result = Vec::new();
|
||||
let mut last_day: Option<i64> = None;
|
||||
let mut last_sender: Option<(bool, String)> = None; // (is_outgoing, sender_name)
|
||||
let mut album_acc: Vec<MessageInfo> = Vec::new();
|
||||
|
||||
/// Сбрасывает аккумулятор альбома в результат
|
||||
fn flush_album(acc: &mut Vec<MessageInfo>, result: &mut Vec<MessageGroup>) {
|
||||
if acc.is_empty() {
|
||||
return;
|
||||
}
|
||||
if acc.len() >= 2 {
|
||||
result.push(MessageGroup::Album(std::mem::take(acc)));
|
||||
} else {
|
||||
// Одно сообщение — не альбом
|
||||
result.push(MessageGroup::Message(Box::new(acc.remove(0))));
|
||||
}
|
||||
}
|
||||
|
||||
for msg in messages {
|
||||
// Проверяем, нужно ли добавить разделитель даты
|
||||
let msg_day = get_day(msg.date());
|
||||
|
||||
if last_day != Some(msg_day) {
|
||||
// Flush аккумулятор перед разделителем даты
|
||||
flush_album(&mut album_acc, &mut result);
|
||||
// Добавляем разделитель даты
|
||||
result.push(MessageGroup::DateSeparator(msg.date()));
|
||||
last_day = Some(msg_day);
|
||||
@@ -82,17 +107,42 @@ pub fn group_messages(messages: &[MessageInfo]) -> Vec<MessageGroup> {
|
||||
let show_sender_header = last_sender.as_ref() != Some(¤t_sender);
|
||||
|
||||
if show_sender_header {
|
||||
result.push(MessageGroup::SenderHeader {
|
||||
is_outgoing: msg.is_outgoing(),
|
||||
sender_name,
|
||||
});
|
||||
// Flush аккумулятор перед сменой отправителя
|
||||
flush_album(&mut album_acc, &mut result);
|
||||
result.push(MessageGroup::SenderHeader { is_outgoing: msg.is_outgoing(), sender_name });
|
||||
last_sender = Some(current_sender);
|
||||
}
|
||||
|
||||
// Добавляем само сообщение
|
||||
result.push(MessageGroup::Message(msg.clone()));
|
||||
// Проверяем, является ли сообщение частью альбома
|
||||
let album_id = msg.media_album_id();
|
||||
if album_id != 0 {
|
||||
// Проверяем, совпадает ли album_id с текущим аккумулятором
|
||||
if let Some(first) = album_acc.first() {
|
||||
if first.media_album_id() == album_id {
|
||||
// Тот же альбом — добавляем
|
||||
album_acc.push(msg.clone());
|
||||
continue;
|
||||
} else {
|
||||
// Другой альбом — flush старый, начинаем новый
|
||||
flush_album(&mut album_acc, &mut result);
|
||||
album_acc.push(msg.clone());
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
// Аккумулятор пуст — начинаем новый альбом
|
||||
album_acc.push(msg.clone());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Обычное сообщение (не альбом) — flush аккумулятор
|
||||
flush_album(&mut album_acc, &mut result);
|
||||
result.push(MessageGroup::Message(Box::new(msg.clone())));
|
||||
}
|
||||
|
||||
// Flush оставшийся аккумулятор
|
||||
flush_album(&mut album_acc, &mut result);
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
@@ -246,4 +296,152 @@ mod tests {
|
||||
assert!(matches!(grouped[1], MessageGroup::SenderHeader { .. }));
|
||||
assert!(matches!(grouped[2], MessageGroup::Message(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_album_grouping_two_photos() {
|
||||
let msg1 = MessageBuilder::new(MessageId::new(1))
|
||||
.sender_name("Alice")
|
||||
.text("Photo 1")
|
||||
.date(1609459200)
|
||||
.incoming()
|
||||
.media_album_id(12345)
|
||||
.build();
|
||||
|
||||
let msg2 = MessageBuilder::new(MessageId::new(2))
|
||||
.sender_name("Alice")
|
||||
.text("Photo 2")
|
||||
.date(1609459201)
|
||||
.incoming()
|
||||
.media_album_id(12345)
|
||||
.build();
|
||||
|
||||
let messages = vec![msg1, msg2];
|
||||
let grouped = group_messages(&messages);
|
||||
|
||||
// DateSep, SenderHeader, Album
|
||||
assert_eq!(grouped.len(), 3);
|
||||
assert!(matches!(grouped[0], MessageGroup::DateSeparator(_)));
|
||||
assert!(matches!(grouped[1], MessageGroup::SenderHeader { .. }));
|
||||
if let MessageGroup::Album(album) = &grouped[2] {
|
||||
assert_eq!(album.len(), 2);
|
||||
assert_eq!(album[0].id(), MessageId::new(1));
|
||||
assert_eq!(album[1].id(), MessageId::new(2));
|
||||
} else {
|
||||
panic!("Expected Album, got {:?}", grouped[2]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_album_single_photo_not_album() {
|
||||
// Одно сообщение с album_id → не альбом, обычное сообщение
|
||||
let msg = MessageBuilder::new(MessageId::new(1))
|
||||
.sender_name("Alice")
|
||||
.text("Single photo")
|
||||
.date(1609459200)
|
||||
.incoming()
|
||||
.media_album_id(12345)
|
||||
.build();
|
||||
|
||||
let messages = vec![msg];
|
||||
let grouped = group_messages(&messages);
|
||||
|
||||
// DateSep, SenderHeader, Message (не Album)
|
||||
assert_eq!(grouped.len(), 3);
|
||||
assert!(matches!(grouped[2], MessageGroup::Message(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_album_with_regular_messages() {
|
||||
let msg1 = MessageBuilder::new(MessageId::new(1))
|
||||
.sender_name("Alice")
|
||||
.text("Text message")
|
||||
.date(1609459200)
|
||||
.incoming()
|
||||
.build();
|
||||
|
||||
let msg2 = MessageBuilder::new(MessageId::new(2))
|
||||
.sender_name("Alice")
|
||||
.text("Photo 1")
|
||||
.date(1609459201)
|
||||
.incoming()
|
||||
.media_album_id(100)
|
||||
.build();
|
||||
|
||||
let msg3 = MessageBuilder::new(MessageId::new(3))
|
||||
.sender_name("Alice")
|
||||
.text("Photo 2")
|
||||
.date(1609459202)
|
||||
.incoming()
|
||||
.media_album_id(100)
|
||||
.build();
|
||||
|
||||
let msg4 = MessageBuilder::new(MessageId::new(4))
|
||||
.sender_name("Alice")
|
||||
.text("After album")
|
||||
.date(1609459203)
|
||||
.incoming()
|
||||
.build();
|
||||
|
||||
let messages = vec![msg1, msg2, msg3, msg4];
|
||||
let grouped = group_messages(&messages);
|
||||
|
||||
// DateSep, SenderHeader, Message, Album, Message
|
||||
assert_eq!(grouped.len(), 5);
|
||||
assert!(matches!(grouped[2], MessageGroup::Message(_)));
|
||||
assert!(matches!(grouped[3], MessageGroup::Album(_)));
|
||||
assert!(matches!(grouped[4], MessageGroup::Message(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_two_different_albums() {
|
||||
let msg1 = MessageBuilder::new(MessageId::new(1))
|
||||
.sender_name("Alice")
|
||||
.text("Album 1 - Photo 1")
|
||||
.date(1609459200)
|
||||
.incoming()
|
||||
.media_album_id(100)
|
||||
.build();
|
||||
|
||||
let msg2 = MessageBuilder::new(MessageId::new(2))
|
||||
.sender_name("Alice")
|
||||
.text("Album 1 - Photo 2")
|
||||
.date(1609459201)
|
||||
.incoming()
|
||||
.media_album_id(100)
|
||||
.build();
|
||||
|
||||
let msg3 = MessageBuilder::new(MessageId::new(3))
|
||||
.sender_name("Alice")
|
||||
.text("Album 2 - Photo 1")
|
||||
.date(1609459202)
|
||||
.incoming()
|
||||
.media_album_id(200)
|
||||
.build();
|
||||
|
||||
let msg4 = MessageBuilder::new(MessageId::new(4))
|
||||
.sender_name("Alice")
|
||||
.text("Album 2 - Photo 2")
|
||||
.date(1609459203)
|
||||
.incoming()
|
||||
.media_album_id(200)
|
||||
.build();
|
||||
|
||||
let messages = vec![msg1, msg2, msg3, msg4];
|
||||
let grouped = group_messages(&messages);
|
||||
|
||||
// DateSep, SenderHeader, Album(2), Album(2)
|
||||
assert_eq!(grouped.len(), 4);
|
||||
if let MessageGroup::Album(a1) = &grouped[2] {
|
||||
assert_eq!(a1.len(), 2);
|
||||
assert_eq!(a1[0].media_album_id(), 100);
|
||||
} else {
|
||||
panic!("Expected first Album");
|
||||
}
|
||||
if let MessageGroup::Album(a2) = &grouped[3] {
|
||||
assert_eq!(a2.len(), 2);
|
||||
assert_eq!(a2[0].media_album_id(), 200);
|
||||
} else {
|
||||
panic!("Expected second Album");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
341
src/notifications.rs
Normal file
341
src/notifications.rs
Normal file
@@ -0,0 +1,341 @@
|
||||
//! Desktop notifications module
|
||||
//!
|
||||
//! Provides cross-platform desktop notifications for new messages.
|
||||
|
||||
use crate::tdlib::{ChatInfo, MessageInfo};
|
||||
use crate::types::ChatId;
|
||||
use std::collections::HashSet;
|
||||
|
||||
#[cfg(feature = "notifications")]
|
||||
use notify_rust::{Notification, Timeout};
|
||||
|
||||
/// Manages desktop notifications
|
||||
#[allow(dead_code)]
|
||||
pub struct NotificationManager {
|
||||
/// Whether notifications are enabled
|
||||
enabled: bool,
|
||||
/// Set of muted chat IDs (don't notify for these chats)
|
||||
muted_chats: HashSet<ChatId>,
|
||||
/// Only notify for mentions (@username)
|
||||
only_mentions: bool,
|
||||
/// Show message preview text
|
||||
show_preview: bool,
|
||||
/// Notification timeout in milliseconds (0 = system default)
|
||||
timeout_ms: i32,
|
||||
/// Notification urgency level
|
||||
urgency: String,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl NotificationManager {
|
||||
/// Creates a new notification manager with default settings
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
muted_chats: HashSet::new(),
|
||||
only_mentions: false,
|
||||
show_preview: true,
|
||||
timeout_ms: 5000,
|
||||
urgency: "normal".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a notification manager with custom settings
|
||||
pub fn with_config(enabled: bool, only_mentions: bool, show_preview: bool) -> Self {
|
||||
Self {
|
||||
enabled,
|
||||
muted_chats: HashSet::new(),
|
||||
only_mentions,
|
||||
show_preview,
|
||||
timeout_ms: 5000,
|
||||
urgency: "normal".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets whether notifications are enabled
|
||||
pub fn set_enabled(&mut self, enabled: bool) {
|
||||
self.enabled = enabled;
|
||||
}
|
||||
|
||||
/// Sets whether to only notify for mentions
|
||||
pub fn set_only_mentions(&mut self, only_mentions: bool) {
|
||||
self.only_mentions = only_mentions;
|
||||
}
|
||||
|
||||
/// Sets notification timeout in milliseconds
|
||||
pub fn set_timeout(&mut self, timeout_ms: i32) {
|
||||
self.timeout_ms = timeout_ms;
|
||||
}
|
||||
|
||||
/// Sets notification urgency level
|
||||
pub fn set_urgency(&mut self, urgency: String) {
|
||||
self.urgency = urgency;
|
||||
}
|
||||
|
||||
/// Adds a chat to the muted list
|
||||
pub fn mute_chat(&mut self, chat_id: ChatId) {
|
||||
self.muted_chats.insert(chat_id);
|
||||
}
|
||||
|
||||
/// Removes a chat from the muted list
|
||||
pub fn unmute_chat(&mut self, chat_id: ChatId) {
|
||||
self.muted_chats.remove(&chat_id);
|
||||
}
|
||||
|
||||
/// Checks if a chat should be muted based on Telegram mute status
|
||||
pub fn sync_muted_chats(&mut self, chats: &[ChatInfo]) {
|
||||
self.muted_chats.clear();
|
||||
for chat in chats {
|
||||
if chat.is_muted {
|
||||
self.muted_chats.insert(chat.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends a notification for a new message
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `chat` - Chat information
|
||||
/// * `message` - Message information
|
||||
/// * `sender_name` - Name of the message sender
|
||||
///
|
||||
/// Returns `Ok(())` if notification was sent or skipped, `Err` if failed
|
||||
pub fn notify_new_message(
|
||||
&self,
|
||||
chat: &ChatInfo,
|
||||
message: &MessageInfo,
|
||||
sender_name: &str,
|
||||
) -> Result<(), String> {
|
||||
// Check if notifications are enabled
|
||||
if !self.enabled {
|
||||
tracing::debug!("Notifications disabled, skipping");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Don't notify for outgoing messages
|
||||
if message.is_outgoing() {
|
||||
tracing::debug!("Outgoing message, skipping notification");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Check if chat is muted
|
||||
if self.muted_chats.contains(&chat.id) {
|
||||
tracing::debug!("Chat {} is muted, skipping notification", chat.title);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Check if we only notify for mentions
|
||||
if self.only_mentions && !message.has_mention() {
|
||||
tracing::debug!("only_mentions=true but no mention found, skipping");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Format the notification
|
||||
let title = &chat.title;
|
||||
let body = self.format_message_body(sender_name, message);
|
||||
|
||||
tracing::debug!("Sending notification for chat: {}", title);
|
||||
|
||||
// Send the notification
|
||||
self.send_notification(title, &body)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Formats the message body for notification
|
||||
fn format_message_body(&self, sender_name: &str, message: &MessageInfo) -> String {
|
||||
// For groups, include sender name. For private chats, sender name is in title
|
||||
let prefix = if !sender_name.is_empty() && sender_name != message.sender_name() {
|
||||
format!("{}: ", sender_name)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let content = if self.show_preview {
|
||||
let text = message.text();
|
||||
|
||||
// Check if message is empty (media, sticker, etc.)
|
||||
if text.is_empty() {
|
||||
"Новое сообщение".to_string()
|
||||
} else {
|
||||
// Beautify media labels with emojis
|
||||
let beautified = Self::beautify_media_labels(text);
|
||||
|
||||
// Limit preview length (use char count, not byte count for UTF-8 safety)
|
||||
const MAX_PREVIEW_CHARS: usize = 147;
|
||||
let char_count = beautified.chars().count();
|
||||
if char_count > MAX_PREVIEW_CHARS {
|
||||
let truncated: String = beautified.chars().take(MAX_PREVIEW_CHARS).collect();
|
||||
format!("{}...", truncated)
|
||||
} else {
|
||||
beautified
|
||||
}
|
||||
}
|
||||
} else {
|
||||
"Новое сообщение".to_string()
|
||||
};
|
||||
|
||||
format!("{}{}", prefix, content)
|
||||
}
|
||||
|
||||
/// Replaces text media labels with emoji-enhanced versions
|
||||
fn beautify_media_labels(text: &str) -> String {
|
||||
text.replace("[Фото]", "📷 Фото")
|
||||
.replace("[Видео]", "🎥 Видео")
|
||||
.replace("[GIF]", "🎞️ GIF")
|
||||
.replace("[Голосовое]", "🎤 Голосовое")
|
||||
.replace("[Стикер:", "🎨 Стикер:")
|
||||
.replace("[Файл:", "📎 Файл:")
|
||||
.replace("[Аудио:", "🎵 Аудио:")
|
||||
.replace("[Аудио]", "🎵 Аудио")
|
||||
.replace("[Видеосообщение]", "📹 Видеосообщение")
|
||||
.replace("[Локация]", "📍 Локация")
|
||||
.replace("[Контакт:", "👤 Контакт:")
|
||||
.replace("[Опрос:", "📊 Опрос:")
|
||||
.replace("[Место встречи:", "📍 Место встречи:")
|
||||
.replace("[Неподдерживаемый тип сообщения]", "📨 Сообщение")
|
||||
}
|
||||
|
||||
/// Sends a desktop notification
|
||||
///
|
||||
/// Returns `Ok(())` if notification was sent successfully or skipped.
|
||||
/// Logs errors but doesn't fail - notifications are not critical for app functionality.
|
||||
#[cfg(feature = "notifications")]
|
||||
fn send_notification(&self, title: &str, body: &str) -> Result<(), String> {
|
||||
// Don't send if notifications are disabled
|
||||
if !self.enabled {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Determine timeout
|
||||
let timeout = if self.timeout_ms <= 0 {
|
||||
Timeout::Default
|
||||
} else {
|
||||
Timeout::Milliseconds(self.timeout_ms as u32)
|
||||
};
|
||||
|
||||
// Build notification
|
||||
let mut notification = Notification::new();
|
||||
notification
|
||||
.summary(title)
|
||||
.body(body)
|
||||
.icon("telegram")
|
||||
.appname("tele-tui")
|
||||
.timeout(timeout);
|
||||
|
||||
// Set urgency if supported
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
{
|
||||
use notify_rust::Urgency;
|
||||
let urgency_level = match self.urgency.to_lowercase().as_str() {
|
||||
"low" => Urgency::Low,
|
||||
"critical" => Urgency::Critical,
|
||||
_ => Urgency::Normal,
|
||||
};
|
||||
notification.urgency(urgency_level);
|
||||
}
|
||||
|
||||
match notification.show() {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) => {
|
||||
// Log error but don't fail - notifications are optional
|
||||
tracing::warn!("Failed to send desktop notification: {}", e);
|
||||
// Return Ok to not break the app flow
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fallback when notifications feature is disabled
|
||||
#[cfg(not(feature = "notifications"))]
|
||||
fn send_notification(&self, _title: &str, _body: &str) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for NotificationManager {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_notification_manager_creation() {
|
||||
let manager = NotificationManager::new();
|
||||
assert!(!manager.enabled); // disabled by default
|
||||
assert!(!manager.only_mentions);
|
||||
assert!(manager.show_preview);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mute_unmute() {
|
||||
let mut manager = NotificationManager::new();
|
||||
let chat_id = ChatId::new(123);
|
||||
|
||||
manager.mute_chat(chat_id);
|
||||
assert!(manager.muted_chats.contains(&chat_id));
|
||||
|
||||
manager.unmute_chat(chat_id);
|
||||
assert!(!manager.muted_chats.contains(&chat_id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_disabled_notifications() {
|
||||
let mut manager = NotificationManager::new();
|
||||
manager.set_enabled(false);
|
||||
|
||||
// Should return Ok without sending notification
|
||||
let result = manager.send_notification("Test", "Body");
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_only_mentions_setting() {
|
||||
let mut manager = NotificationManager::new();
|
||||
assert!(!manager.only_mentions);
|
||||
|
||||
manager.set_only_mentions(true);
|
||||
assert!(manager.only_mentions);
|
||||
|
||||
manager.set_only_mentions(false);
|
||||
assert!(!manager.only_mentions);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_beautify_media_labels() {
|
||||
// Test photo
|
||||
assert_eq!(NotificationManager::beautify_media_labels("[Фото]"), "📷 Фото");
|
||||
|
||||
// Test video
|
||||
assert_eq!(NotificationManager::beautify_media_labels("[Видео]"), "🎥 Видео");
|
||||
|
||||
// Test sticker with emoji
|
||||
assert_eq!(NotificationManager::beautify_media_labels("[Стикер: 😊]"), "🎨 Стикер: 😊]");
|
||||
|
||||
// Test audio with title
|
||||
assert_eq!(
|
||||
NotificationManager::beautify_media_labels("[Аудио: Artist - Song]"),
|
||||
"🎵 Аудио: Artist - Song]"
|
||||
);
|
||||
|
||||
// Test file
|
||||
assert_eq!(
|
||||
NotificationManager::beautify_media_labels("[Файл: document.pdf]"),
|
||||
"📎 Файл: document.pdf]"
|
||||
);
|
||||
|
||||
// Test regular text (no changes)
|
||||
assert_eq!(NotificationManager::beautify_media_labels("Hello, world!"), "Hello, world!");
|
||||
|
||||
// Test mixed content
|
||||
assert_eq!(
|
||||
NotificationManager::beautify_media_labels("[Фото] Check this out!"),
|
||||
"📷 Фото Check this out!"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,8 @@ use tdlib_rs::functions;
|
||||
///
|
||||
/// Отслеживает текущий этап аутентификации пользователя,
|
||||
/// от инициализации TDLib до полной авторизации.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum AuthState {
|
||||
/// Ожидание параметров TDLib (начальное состояние).
|
||||
WaitTdlibParameters,
|
||||
@@ -73,6 +73,7 @@ pub struct AuthManager {
|
||||
client_id: i32,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl AuthManager {
|
||||
/// Создает новый менеджер авторизации.
|
||||
///
|
||||
@@ -84,10 +85,7 @@ impl AuthManager {
|
||||
///
|
||||
/// Новый экземпляр `AuthManager` в состоянии `WaitTdlibParameters`.
|
||||
pub fn new(client_id: i32) -> Self {
|
||||
Self {
|
||||
state: AuthState::WaitTdlibParameters,
|
||||
client_id,
|
||||
}
|
||||
Self { state: AuthState::WaitTdlibParameters, client_id }
|
||||
}
|
||||
|
||||
/// Проверяет, завершена ли авторизация.
|
||||
|
||||
153
src/tdlib/chat_helpers.rs
Normal file
153
src/tdlib/chat_helpers.rs
Normal file
@@ -0,0 +1,153 @@
|
||||
//! Chat management helper functions.
|
||||
//!
|
||||
//! This module contains utility functions for managing chats,
|
||||
//! including finding, updating, and adding/removing chats.
|
||||
|
||||
use crate::constants::{MAX_CHATS, MAX_CHAT_USER_IDS};
|
||||
use crate::types::{ChatId, MessageId, UserId};
|
||||
use tdlib_rs::enums::{Chat as TdChat, ChatList, ChatType};
|
||||
|
||||
use super::client::TdClient;
|
||||
use super::types::ChatInfo;
|
||||
|
||||
/// Находит мутабельную ссылку на чат по ID.
|
||||
pub fn find_chat_mut(client: &mut TdClient, chat_id: ChatId) -> Option<&mut ChatInfo> {
|
||||
client.chats_mut().iter_mut().find(|c| c.id == chat_id)
|
||||
}
|
||||
|
||||
/// Обновляет поле чата, если чат найден.
|
||||
pub fn update_chat<F>(client: &mut TdClient, chat_id: ChatId, updater: F)
|
||||
where
|
||||
F: FnOnce(&mut ChatInfo),
|
||||
{
|
||||
if let Some(chat) = find_chat_mut(client, chat_id) {
|
||||
updater(chat);
|
||||
}
|
||||
}
|
||||
|
||||
/// Добавляет новый чат или обновляет существующий
|
||||
pub fn add_or_update_chat(client: &mut TdClient, td_chat_enum: &TdChat) {
|
||||
// Pattern match to get inner Chat struct
|
||||
let TdChat::Chat(td_chat) = td_chat_enum;
|
||||
|
||||
// Пропускаем удалённые аккаунты
|
||||
if td_chat.title == "Deleted Account" || td_chat.title.is_empty() {
|
||||
// Удаляем из списка если уже был добавлен
|
||||
client
|
||||
.chats_mut()
|
||||
.retain(|c| c.id != ChatId::new(td_chat.id));
|
||||
return;
|
||||
}
|
||||
|
||||
// Ищем позицию в Main списке (если есть)
|
||||
let main_position = td_chat
|
||||
.positions
|
||||
.iter()
|
||||
.find(|pos| matches!(pos.list, ChatList::Main));
|
||||
|
||||
// Получаем order и is_pinned из позиции, или используем значения по умолчанию
|
||||
let (order, is_pinned) = main_position
|
||||
.map(|p| (p.order, p.is_pinned))
|
||||
.unwrap_or((1, false)); // order=1 чтобы чат отображался
|
||||
|
||||
let (last_message, last_message_date) = td_chat
|
||||
.last_message
|
||||
.as_ref()
|
||||
.map(|m| (TdClient::extract_message_text_static(m).0, m.date))
|
||||
.unwrap_or_default();
|
||||
|
||||
// Извлекаем user_id для приватных чатов и сохраняем связь
|
||||
let username = match &td_chat.r#type {
|
||||
ChatType::Private(private) => {
|
||||
// Ограничиваем размер chat_user_ids
|
||||
let chat_id = ChatId::new(td_chat.id);
|
||||
if client.user_cache.chat_user_ids.len() >= MAX_CHAT_USER_IDS
|
||||
&& !client.user_cache.chat_user_ids.contains_key(&chat_id)
|
||||
{
|
||||
// Удаляем случайную запись (первую найденную)
|
||||
if let Some(&key) = client.user_cache.chat_user_ids.keys().next() {
|
||||
client.user_cache.chat_user_ids.remove(&key);
|
||||
}
|
||||
}
|
||||
let user_id = UserId::new(private.user_id);
|
||||
client.user_cache.chat_user_ids.insert(chat_id, user_id);
|
||||
// Проверяем, есть ли уже username в кэше (peek не обновляет LRU)
|
||||
client
|
||||
.user_cache
|
||||
.user_usernames
|
||||
.peek(&user_id)
|
||||
.map(|u| format!("@{}", u))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
// Извлекаем ID папок из позиций
|
||||
let folder_ids: Vec<i32> = td_chat
|
||||
.positions
|
||||
.iter()
|
||||
.filter_map(|pos| match &pos.list {
|
||||
ChatList::Folder(folder) => Some(folder.chat_folder_id),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Проверяем mute статус
|
||||
let is_muted = td_chat.notification_settings.mute_for > 0;
|
||||
|
||||
let chat_info = ChatInfo {
|
||||
id: ChatId::new(td_chat.id),
|
||||
title: td_chat.title.clone(),
|
||||
username,
|
||||
last_message,
|
||||
last_message_date,
|
||||
unread_count: td_chat.unread_count,
|
||||
unread_mention_count: td_chat.unread_mention_count,
|
||||
is_pinned,
|
||||
order,
|
||||
last_read_outbox_message_id: MessageId::new(td_chat.last_read_outbox_message_id),
|
||||
folder_ids,
|
||||
is_muted,
|
||||
draft_text: None,
|
||||
};
|
||||
|
||||
if let Some(existing) = find_chat_mut(client, ChatId::new(td_chat.id)) {
|
||||
existing.title = chat_info.title;
|
||||
existing.last_message = chat_info.last_message;
|
||||
existing.last_message_date = chat_info.last_message_date;
|
||||
existing.unread_count = chat_info.unread_count;
|
||||
existing.unread_mention_count = chat_info.unread_mention_count;
|
||||
existing.last_read_outbox_message_id = chat_info.last_read_outbox_message_id;
|
||||
existing.folder_ids = chat_info.folder_ids;
|
||||
existing.is_muted = chat_info.is_muted;
|
||||
|
||||
// Обновляем username если он появился
|
||||
if let Some(username) = chat_info.username {
|
||||
existing.username = Some(username);
|
||||
}
|
||||
|
||||
// Обновляем позицию только если она пришла
|
||||
if main_position.is_some() {
|
||||
existing.is_pinned = chat_info.is_pinned;
|
||||
existing.order = chat_info.order;
|
||||
}
|
||||
} else {
|
||||
client.chats_mut().push(chat_info);
|
||||
// Ограничиваем количество чатов
|
||||
if client.chats_mut().len() > MAX_CHATS {
|
||||
// Удаляем чат с наименьшим order (наименее активный)
|
||||
let Some(min_idx) = client
|
||||
.chats()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.min_by_key(|(_, c)| c.order)
|
||||
.map(|(i, _)| i)
|
||||
else {
|
||||
return; // Нет чатов для удаления (не должно произойти)
|
||||
};
|
||||
client.chats_mut().remove(min_idx);
|
||||
}
|
||||
}
|
||||
|
||||
// Сортируем чаты по order (TDLib order учитывает pinned и время)
|
||||
client.chats_mut().sort_by(|a, b| b.order.cmp(&a.order));
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
use crate::constants::TDLIB_CHAT_LIMIT;
|
||||
use crate::types::{ChatId, UserId};
|
||||
use std::time::Instant;
|
||||
use tdlib_rs::enums::{ChatAction, ChatList, ChatType};
|
||||
use tdlib_rs::functions;
|
||||
|
||||
use super::types::{ChatInfo, FolderInfo, MessageInfo, ProfileInfo};
|
||||
use super::types::{ChatInfo, FolderInfo, ProfileInfo};
|
||||
|
||||
/// Менеджер чатов TDLib.
|
||||
///
|
||||
@@ -183,10 +182,7 @@ impl ChatManager {
|
||||
Err(e) => return Err(format!("Ошибка получения чата: {:?}", e)),
|
||||
};
|
||||
|
||||
let chat = match chat_enum {
|
||||
tdlib_rs::enums::Chat::Chat(c) => c,
|
||||
_ => return Err("Неожиданный тип чата".to_string()),
|
||||
};
|
||||
let tdlib_rs::enums::Chat::Chat(chat) = chat_enum;
|
||||
|
||||
let chat_type_str = match &chat.r#type {
|
||||
ChatType::Private(_) => "Личный чат",
|
||||
@@ -201,10 +197,7 @@ impl ChatManager {
|
||||
ChatType::Secret(_) => "Секретный чат",
|
||||
};
|
||||
|
||||
let is_group = matches!(
|
||||
&chat.r#type,
|
||||
ChatType::Supergroup(_) | ChatType::BasicGroup(_)
|
||||
);
|
||||
let is_group = matches!(&chat.r#type, ChatType::Supergroup(_) | ChatType::BasicGroup(_));
|
||||
|
||||
// Для личных чатов получаем информацию о пользователе
|
||||
let (bio, phone_number, username, online_status) = if let ChatType::Private(private_chat) =
|
||||
@@ -212,13 +205,15 @@ impl ChatManager {
|
||||
{
|
||||
match functions::get_user(private_chat.user_id, self.client_id).await {
|
||||
Ok(tdlib_rs::enums::User::User(user)) => {
|
||||
let bio_opt = if let Ok(tdlib_rs::enums::UserFullInfo::UserFullInfo(full_info)) =
|
||||
functions::get_user_full_info(private_chat.user_id, self.client_id).await
|
||||
{
|
||||
full_info.bio.map(|b| b.text)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let bio_opt =
|
||||
if let Ok(tdlib_rs::enums::UserFullInfo::UserFullInfo(full_info)) =
|
||||
functions::get_user_full_info(private_chat.user_id, self.client_id)
|
||||
.await
|
||||
{
|
||||
full_info.bio.map(|b| b.text)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let online_status_str = match user.status {
|
||||
tdlib_rs::enums::UserStatus::Online(_) => Some("В сети".to_string()),
|
||||
@@ -238,10 +233,7 @@ impl ChatManager {
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let username_opt = user
|
||||
.usernames
|
||||
.as_ref()
|
||||
.map(|u| u.editable_username.clone());
|
||||
let username_opt = user.usernames.as_ref().map(|u| u.editable_username.clone());
|
||||
|
||||
(bio_opt, Some(user.phone_number.clone()), username_opt, online_status_str)
|
||||
}
|
||||
@@ -261,7 +253,10 @@ impl ChatManager {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let link = full_info.invite_link.as_ref().map(|l| l.invite_link.clone());
|
||||
let link = full_info
|
||||
.invite_link
|
||||
.as_ref()
|
||||
.map(|l| l.invite_link.clone());
|
||||
(Some(full_info.member_count), desc, link)
|
||||
}
|
||||
_ => (None, None, None),
|
||||
@@ -328,7 +323,8 @@ impl ChatManager {
|
||||
/// ).await;
|
||||
/// ```
|
||||
pub async fn send_chat_action(&self, chat_id: ChatId, action: ChatAction) {
|
||||
let _ = functions::send_chat_action(chat_id.as_i64(), 0, Some(action), self.client_id).await;
|
||||
let _ =
|
||||
functions::send_chat_action(chat_id.as_i64(), 0, Some(action), self.client_id).await;
|
||||
}
|
||||
|
||||
/// Очищает устаревший typing-статус.
|
||||
@@ -375,6 +371,7 @@ impl ChatManager {
|
||||
/// println!("Status: {}", typing_text);
|
||||
/// }
|
||||
/// ```
|
||||
#[allow(dead_code)]
|
||||
pub fn get_typing_text(&self) -> Option<String> {
|
||||
self.typing_status
|
||||
.as_ref()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
309
src/tdlib/client_impl.rs
Normal file
309
src/tdlib/client_impl.rs
Normal file
@@ -0,0 +1,309 @@
|
||||
//! Implementation of TdClientTrait for TdClient
|
||||
//!
|
||||
//! This file contains the trait implementation that delegates to existing TdClient methods.
|
||||
|
||||
use super::client::TdClient;
|
||||
use super::r#trait::TdClientTrait;
|
||||
use super::{
|
||||
AuthState, ChatInfo, FolderInfo, MessageInfo, ProfileInfo, ReplyInfo, UserCache,
|
||||
UserOnlineStatus,
|
||||
};
|
||||
use crate::types::{ChatId, MessageId, UserId};
|
||||
use async_trait::async_trait;
|
||||
use std::path::PathBuf;
|
||||
use tdlib_rs::enums::{ChatAction, Update};
|
||||
|
||||
#[async_trait]
|
||||
impl TdClientTrait for TdClient {
|
||||
// ============ Auth methods ============
|
||||
async fn send_phone_number(&self, phone: String) -> Result<(), String> {
|
||||
self.send_phone_number(phone).await
|
||||
}
|
||||
|
||||
async fn send_code(&self, code: String) -> Result<(), String> {
|
||||
self.send_code(code).await
|
||||
}
|
||||
|
||||
async fn send_password(&self, password: String) -> Result<(), String> {
|
||||
self.send_password(password).await
|
||||
}
|
||||
|
||||
// ============ Chat methods ============
|
||||
async fn load_chats(&mut self, limit: i32) -> Result<(), String> {
|
||||
self.load_chats(limit).await
|
||||
}
|
||||
|
||||
async fn load_folder_chats(&mut self, folder_id: i32, limit: i32) -> Result<(), String> {
|
||||
self.load_folder_chats(folder_id, limit).await
|
||||
}
|
||||
|
||||
async fn leave_chat(&self, chat_id: ChatId) -> Result<(), String> {
|
||||
self.leave_chat(chat_id).await
|
||||
}
|
||||
|
||||
async fn get_profile_info(&self, chat_id: ChatId) -> Result<ProfileInfo, String> {
|
||||
self.get_profile_info(chat_id).await
|
||||
}
|
||||
|
||||
// ============ Chat actions ============
|
||||
async fn send_chat_action(&self, chat_id: ChatId, action: ChatAction) {
|
||||
self.send_chat_action(chat_id, action).await
|
||||
}
|
||||
|
||||
fn clear_stale_typing_status(&mut self) -> bool {
|
||||
self.clear_stale_typing_status()
|
||||
}
|
||||
|
||||
// ============ Message methods ============
|
||||
async fn get_chat_history(
|
||||
&mut self,
|
||||
chat_id: ChatId,
|
||||
limit: i32,
|
||||
) -> Result<Vec<MessageInfo>, String> {
|
||||
self.get_chat_history(chat_id, limit).await
|
||||
}
|
||||
|
||||
async fn load_older_messages(
|
||||
&mut self,
|
||||
chat_id: ChatId,
|
||||
from_message_id: MessageId,
|
||||
) -> Result<Vec<MessageInfo>, String> {
|
||||
self.load_older_messages(chat_id, from_message_id).await
|
||||
}
|
||||
|
||||
async fn get_pinned_messages(&mut self, chat_id: ChatId) -> Result<Vec<MessageInfo>, String> {
|
||||
self.get_pinned_messages(chat_id).await
|
||||
}
|
||||
|
||||
async fn load_current_pinned_message(&mut self, chat_id: ChatId) {
|
||||
self.load_current_pinned_message(chat_id).await
|
||||
}
|
||||
|
||||
async fn search_messages(
|
||||
&self,
|
||||
chat_id: ChatId,
|
||||
query: &str,
|
||||
) -> Result<Vec<MessageInfo>, String> {
|
||||
self.search_messages(chat_id, query).await
|
||||
}
|
||||
|
||||
async fn send_message(
|
||||
&mut self,
|
||||
chat_id: ChatId,
|
||||
text: String,
|
||||
reply_to_message_id: Option<MessageId>,
|
||||
reply_info: Option<ReplyInfo>,
|
||||
) -> Result<MessageInfo, String> {
|
||||
self.message_manager
|
||||
.send_message(chat_id, text, reply_to_message_id, reply_info)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn edit_message(
|
||||
&mut self,
|
||||
chat_id: ChatId,
|
||||
message_id: MessageId,
|
||||
new_text: String,
|
||||
) -> Result<MessageInfo, String> {
|
||||
self.message_manager
|
||||
.edit_message(chat_id, message_id, new_text)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn delete_messages(
|
||||
&mut self,
|
||||
chat_id: ChatId,
|
||||
message_ids: Vec<MessageId>,
|
||||
revoke: bool,
|
||||
) -> Result<(), String> {
|
||||
self.message_manager
|
||||
.delete_messages(chat_id, message_ids, revoke)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn forward_messages(
|
||||
&mut self,
|
||||
to_chat_id: ChatId,
|
||||
from_chat_id: ChatId,
|
||||
message_ids: Vec<MessageId>,
|
||||
) -> Result<(), String> {
|
||||
self.message_manager
|
||||
.forward_messages(to_chat_id, from_chat_id, message_ids)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn set_draft_message(&self, chat_id: ChatId, text: String) -> Result<(), String> {
|
||||
self.set_draft_message(chat_id, text).await
|
||||
}
|
||||
|
||||
fn push_message(&mut self, msg: MessageInfo) {
|
||||
self.push_message(msg)
|
||||
}
|
||||
|
||||
async fn fetch_missing_reply_info(&mut self) {
|
||||
self.fetch_missing_reply_info().await
|
||||
}
|
||||
|
||||
async fn process_pending_view_messages(&mut self) {
|
||||
self.process_pending_view_messages().await
|
||||
}
|
||||
|
||||
// ============ User methods ============
|
||||
fn get_user_status_by_chat_id(&self, chat_id: ChatId) -> Option<&UserOnlineStatus> {
|
||||
self.get_user_status_by_chat_id(chat_id)
|
||||
}
|
||||
|
||||
async fn process_pending_user_ids(&mut self) {
|
||||
self.process_pending_user_ids().await
|
||||
}
|
||||
|
||||
// ============ Reaction methods ============
|
||||
async fn get_message_available_reactions(
|
||||
&self,
|
||||
chat_id: ChatId,
|
||||
message_id: MessageId,
|
||||
) -> Result<Vec<String>, String> {
|
||||
self.get_message_available_reactions(chat_id, message_id)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn toggle_reaction(
|
||||
&self,
|
||||
chat_id: ChatId,
|
||||
message_id: MessageId,
|
||||
reaction: String,
|
||||
) -> Result<(), String> {
|
||||
self.toggle_reaction(chat_id, message_id, reaction).await
|
||||
}
|
||||
|
||||
// ============ File methods ============
|
||||
async fn download_file(&self, file_id: i32) -> Result<String, String> {
|
||||
self.download_file(file_id).await
|
||||
}
|
||||
|
||||
async fn download_voice_note(&self, file_id: i32) -> Result<String, String> {
|
||||
// Voice notes use the same download mechanism as photos
|
||||
self.download_file(file_id).await
|
||||
}
|
||||
|
||||
fn client_id(&self) -> i32 {
|
||||
self.client_id()
|
||||
}
|
||||
|
||||
async fn get_me(&self) -> Result<i64, String> {
|
||||
self.get_me().await
|
||||
}
|
||||
|
||||
fn auth_state(&self) -> &AuthState {
|
||||
self.auth_state()
|
||||
}
|
||||
|
||||
fn chats(&self) -> &[ChatInfo] {
|
||||
self.chats()
|
||||
}
|
||||
|
||||
fn folders(&self) -> &[FolderInfo] {
|
||||
self.folders()
|
||||
}
|
||||
|
||||
fn current_chat_messages(&self) -> Vec<MessageInfo> {
|
||||
self.message_manager.current_chat_messages.to_vec()
|
||||
}
|
||||
|
||||
fn current_chat_id(&self) -> Option<ChatId> {
|
||||
self.current_chat_id()
|
||||
}
|
||||
|
||||
fn current_pinned_message(&self) -> Option<MessageInfo> {
|
||||
self.message_manager.current_pinned_message.clone()
|
||||
}
|
||||
|
||||
fn typing_status(&self) -> Option<&(UserId, String, std::time::Instant)> {
|
||||
self.typing_status()
|
||||
}
|
||||
|
||||
fn pending_view_messages(&self) -> &[(ChatId, Vec<MessageId>)] {
|
||||
self.pending_view_messages()
|
||||
}
|
||||
|
||||
fn pending_user_ids(&self) -> &[UserId] {
|
||||
self.pending_user_ids()
|
||||
}
|
||||
|
||||
fn main_chat_list_position(&self) -> i32 {
|
||||
self.main_chat_list_position()
|
||||
}
|
||||
|
||||
fn user_cache(&self) -> &UserCache {
|
||||
self.user_cache()
|
||||
}
|
||||
|
||||
fn network_state(&self) -> super::types::NetworkState {
|
||||
self.network_state.clone()
|
||||
}
|
||||
|
||||
fn chats_mut(&mut self) -> &mut Vec<ChatInfo> {
|
||||
self.chats_mut()
|
||||
}
|
||||
|
||||
fn folders_mut(&mut self) -> &mut Vec<FolderInfo> {
|
||||
self.folders_mut()
|
||||
}
|
||||
|
||||
fn current_chat_messages_mut(&mut self) -> &mut Vec<MessageInfo> {
|
||||
self.current_chat_messages_mut()
|
||||
}
|
||||
|
||||
fn clear_current_chat_messages(&mut self) {
|
||||
self.current_chat_messages_mut().clear()
|
||||
}
|
||||
|
||||
fn set_current_chat_messages(&mut self, messages: Vec<MessageInfo>) {
|
||||
*self.current_chat_messages_mut() = messages;
|
||||
}
|
||||
|
||||
fn set_current_chat_id(&mut self, chat_id: Option<ChatId>) {
|
||||
self.set_current_chat_id(chat_id)
|
||||
}
|
||||
|
||||
fn set_current_pinned_message(&mut self, msg: Option<MessageInfo>) {
|
||||
self.set_current_pinned_message(msg)
|
||||
}
|
||||
|
||||
fn set_typing_status(&mut self, status: Option<(UserId, String, std::time::Instant)>) {
|
||||
self.set_typing_status(status)
|
||||
}
|
||||
|
||||
fn pending_view_messages_mut(&mut self) -> &mut Vec<(ChatId, Vec<MessageId>)> {
|
||||
self.pending_view_messages_mut()
|
||||
}
|
||||
|
||||
fn pending_user_ids_mut(&mut self) -> &mut Vec<UserId> {
|
||||
self.pending_user_ids_mut()
|
||||
}
|
||||
|
||||
fn set_main_chat_list_position(&mut self, position: i32) {
|
||||
self.set_main_chat_list_position(position)
|
||||
}
|
||||
|
||||
fn user_cache_mut(&mut self) -> &mut UserCache {
|
||||
&mut self.user_cache
|
||||
}
|
||||
|
||||
// ============ Notification methods ============
|
||||
fn sync_notification_muted_chats(&mut self) {
|
||||
self.notification_manager
|
||||
.sync_muted_chats(&self.chat_manager.chats);
|
||||
}
|
||||
|
||||
// ============ Account switching ============
|
||||
async fn recreate_client(&mut self, db_path: PathBuf) -> Result<(), String> {
|
||||
TdClient::recreate_client(self, db_path).await
|
||||
}
|
||||
|
||||
// ============ Update handling ============
|
||||
fn handle_update(&mut self, update: Update) {
|
||||
// Delegate to the real implementation
|
||||
TdClient::handle_update(self, update)
|
||||
}
|
||||
}
|
||||
213
src/tdlib/message_conversion.rs
Normal file
213
src/tdlib/message_conversion.rs
Normal file
@@ -0,0 +1,213 @@
|
||||
//! Вспомогательные функции для конвертации TDLib сообщений в MessageInfo
|
||||
//!
|
||||
//! Этот модуль содержит функции для извлечения различных частей сообщения
|
||||
//! из TDLib Message и конвертации их в наш внутренний формат MessageInfo.
|
||||
|
||||
use crate::types::MessageId;
|
||||
use tdlib_rs::enums::{MessageContent, MessageSender};
|
||||
use tdlib_rs::types::Message as TdMessage;
|
||||
|
||||
use super::types::{
|
||||
ForwardInfo, MediaInfo, PhotoDownloadState, PhotoInfo, ReactionInfo, ReplyInfo,
|
||||
VoiceDownloadState, VoiceInfo,
|
||||
};
|
||||
|
||||
/// Извлекает текст контента из TDLib Message
|
||||
///
|
||||
/// Обрабатывает различные типы сообщений (текст, фото, видео, стикеры, и т.д.)
|
||||
/// и возвращает текстовое представление.
|
||||
pub fn extract_content_text(msg: &TdMessage) -> String {
|
||||
match &msg.content {
|
||||
MessageContent::MessageText(t) => t.text.text.clone(),
|
||||
MessageContent::MessagePhoto(p) => {
|
||||
let caption_text = p.caption.text.clone();
|
||||
if caption_text.is_empty() {
|
||||
"📷 [Фото]".to_string()
|
||||
} else {
|
||||
format!("📷 {}", caption_text)
|
||||
}
|
||||
}
|
||||
MessageContent::MessageVideo(v) => {
|
||||
let caption_text = v.caption.text.clone();
|
||||
if caption_text.is_empty() {
|
||||
"[Видео]".to_string()
|
||||
} else {
|
||||
caption_text
|
||||
}
|
||||
}
|
||||
MessageContent::MessageDocument(d) => {
|
||||
let caption_text = d.caption.text.clone();
|
||||
if caption_text.is_empty() {
|
||||
format!("[Файл: {}]", d.document.file_name)
|
||||
} else {
|
||||
caption_text
|
||||
}
|
||||
}
|
||||
MessageContent::MessageSticker(s) => {
|
||||
format!("[Стикер: {}]", s.sticker.emoji)
|
||||
}
|
||||
MessageContent::MessageAnimation(a) => {
|
||||
let caption_text = a.caption.text.clone();
|
||||
if caption_text.is_empty() {
|
||||
"[GIF]".to_string()
|
||||
} else {
|
||||
caption_text
|
||||
}
|
||||
}
|
||||
MessageContent::MessageVoiceNote(v) => {
|
||||
let duration = v.voice_note.duration;
|
||||
let caption_text = v.caption.text.clone();
|
||||
if caption_text.is_empty() {
|
||||
format!("🎤 [Голосовое {:.0}s]", duration)
|
||||
} else {
|
||||
format!("🎤 {} ({:.0}s)", caption_text, duration)
|
||||
}
|
||||
}
|
||||
MessageContent::MessageAudio(a) => {
|
||||
let caption_text = a.caption.text.clone();
|
||||
if caption_text.is_empty() {
|
||||
let title = a.audio.title.clone();
|
||||
let performer = a.audio.performer.clone();
|
||||
if !title.is_empty() || !performer.is_empty() {
|
||||
format!("[Аудио: {} - {}]", performer, title)
|
||||
} else {
|
||||
"[Аудио]".to_string()
|
||||
}
|
||||
} else {
|
||||
caption_text
|
||||
}
|
||||
}
|
||||
_ => "[Неподдерживаемый тип сообщения]".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Извлекает entities (форматирование) из TDLib Message
|
||||
pub fn extract_entities(msg: &TdMessage) -> Vec<tdlib_rs::types::TextEntity> {
|
||||
if let MessageContent::MessageText(t) = &msg.content {
|
||||
t.text.entities.clone()
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
/// Извлекает имя отправителя из TDLib Message
|
||||
///
|
||||
/// Для пользователей делает API вызов get_user для получения имени.
|
||||
/// Для чатов возвращает ID чата.
|
||||
pub async fn extract_sender_name(msg: &TdMessage, client_id: i32) -> String {
|
||||
match &msg.sender_id {
|
||||
MessageSender::User(user) => {
|
||||
match tdlib_rs::functions::get_user(user.user_id, client_id).await {
|
||||
Ok(tdlib_rs::enums::User::User(u)) => format!("{} {}", u.first_name, u.last_name)
|
||||
.trim()
|
||||
.to_string(),
|
||||
_ => format!("User {}", user.user_id),
|
||||
}
|
||||
}
|
||||
MessageSender::Chat(chat) => format!("Chat {}", chat.chat_id),
|
||||
}
|
||||
}
|
||||
|
||||
/// Извлекает информацию о пересылке из TDLib Message
|
||||
pub fn extract_forward_info(msg: &TdMessage) -> Option<ForwardInfo> {
|
||||
msg.forward_info.as_ref().and_then(|fi| {
|
||||
if let tdlib_rs::enums::MessageOrigin::User(origin_user) = &fi.origin {
|
||||
Some(ForwardInfo {
|
||||
sender_name: format!("User {}", origin_user.sender_user_id),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Извлекает информацию об ответе из TDLib Message
|
||||
pub fn extract_reply_info(msg: &TdMessage) -> Option<ReplyInfo> {
|
||||
msg.reply_to.as_ref().and_then(|reply_to| {
|
||||
if let tdlib_rs::enums::MessageReplyTo::Message(reply_msg) = reply_to {
|
||||
Some(ReplyInfo {
|
||||
message_id: MessageId::new(reply_msg.message_id),
|
||||
sender_name: "Unknown".to_string(),
|
||||
text: "...".to_string(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Извлекает информацию о медиа-контенте из TDLib Message
|
||||
///
|
||||
/// Для MessagePhoto: получает лучший размер фото, извлекает file_id, width, height.
|
||||
/// Возвращает None для не-медийных типов сообщений.
|
||||
pub fn extract_media_info(msg: &TdMessage) -> Option<MediaInfo> {
|
||||
match &msg.content {
|
||||
MessageContent::MessagePhoto(p) => {
|
||||
// Берём лучший (последний = самый большой) размер фото
|
||||
let best_size = p.photo.sizes.last()?;
|
||||
let file_id = best_size.photo.id;
|
||||
let width = best_size.width;
|
||||
let height = best_size.height;
|
||||
|
||||
// Проверяем, скачан ли файл
|
||||
let download_state = if !best_size.photo.local.path.is_empty()
|
||||
&& best_size.photo.local.is_downloading_completed
|
||||
{
|
||||
PhotoDownloadState::Downloaded(best_size.photo.local.path.clone())
|
||||
} else {
|
||||
PhotoDownloadState::NotDownloaded
|
||||
};
|
||||
|
||||
Some(MediaInfo::Photo(PhotoInfo { file_id, width, height, download_state }))
|
||||
}
|
||||
MessageContent::MessageVoiceNote(v) => {
|
||||
let file_id = v.voice_note.voice.id;
|
||||
let duration = v.voice_note.duration;
|
||||
let mime_type = v.voice_note.mime_type.clone();
|
||||
let waveform = v.voice_note.waveform.clone();
|
||||
|
||||
// Проверяем, скачан ли файл
|
||||
let download_state = if !v.voice_note.voice.local.path.is_empty()
|
||||
&& v.voice_note.voice.local.is_downloading_completed
|
||||
{
|
||||
VoiceDownloadState::Downloaded(v.voice_note.voice.local.path.clone())
|
||||
} else {
|
||||
VoiceDownloadState::NotDownloaded
|
||||
};
|
||||
|
||||
Some(MediaInfo::Voice(VoiceInfo {
|
||||
file_id,
|
||||
duration,
|
||||
mime_type,
|
||||
waveform,
|
||||
download_state,
|
||||
}))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Извлекает реакции из TDLib Message
|
||||
pub fn extract_reactions(msg: &TdMessage) -> Vec<ReactionInfo> {
|
||||
msg.interaction_info
|
||||
.as_ref()
|
||||
.and_then(|ii| ii.reactions.as_ref())
|
||||
.map(|reactions| {
|
||||
reactions
|
||||
.reactions
|
||||
.iter()
|
||||
.filter_map(|r| {
|
||||
if let tdlib_rs::enums::ReactionType::Emoji(emoji_type) = &r.r#type {
|
||||
Some(ReactionInfo {
|
||||
emoji: emoji_type.emoji.clone(),
|
||||
count: r.total_count,
|
||||
is_chosen: r.is_chosen,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
234
src/tdlib/message_converter.rs
Normal file
234
src/tdlib/message_converter.rs
Normal file
@@ -0,0 +1,234 @@
|
||||
//! Message conversion utilities for transforming TDLib messages.
|
||||
//!
|
||||
//! This module contains functions for converting TDLib message formats
|
||||
//! to the application's internal MessageInfo format, including extraction
|
||||
//! of replies, forwards, and reactions.
|
||||
|
||||
use crate::types::{ChatId, MessageId, UserId};
|
||||
use tdlib_rs::types::Message as TdMessage;
|
||||
|
||||
use super::client::TdClient;
|
||||
use super::types::{ForwardInfo, MessageInfo, ReactionInfo, ReplyInfo};
|
||||
|
||||
/// Конвертирует TDLib сообщение в MessageInfo
|
||||
pub fn convert_message(client: &mut TdClient, message: &TdMessage, chat_id: ChatId) -> MessageInfo {
|
||||
let sender_name = match &message.sender_id {
|
||||
tdlib_rs::enums::MessageSender::User(user) => {
|
||||
// Пробуем получить имя из кеша (get обновляет LRU порядок)
|
||||
let user_id = UserId::new(user.user_id);
|
||||
client
|
||||
.user_cache
|
||||
.user_names
|
||||
.get(&user_id)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| {
|
||||
// Добавляем в очередь для загрузки
|
||||
if !client.pending_user_ids().contains(&user_id) {
|
||||
client.pending_user_ids_mut().push(user_id);
|
||||
}
|
||||
format!("User_{}", user_id.as_i64())
|
||||
})
|
||||
}
|
||||
tdlib_rs::enums::MessageSender::Chat(chat) => {
|
||||
// Для чатов используем название чата
|
||||
let sender_chat_id = ChatId::new(chat.chat_id);
|
||||
client
|
||||
.chats()
|
||||
.iter()
|
||||
.find(|c| c.id == sender_chat_id)
|
||||
.map(|c| c.title.clone())
|
||||
.unwrap_or_else(|| format!("Chat_{}", sender_chat_id.as_i64()))
|
||||
}
|
||||
};
|
||||
|
||||
// Определяем, прочитано ли исходящее сообщение
|
||||
let message_id = MessageId::new(message.id);
|
||||
let is_read = if message.is_outgoing {
|
||||
// Сообщение прочитано, если его ID <= last_read_outbox_message_id чата
|
||||
client
|
||||
.chats()
|
||||
.iter()
|
||||
.find(|c| c.id == chat_id)
|
||||
.map(|c| message_id <= c.last_read_outbox_message_id)
|
||||
.unwrap_or(false)
|
||||
} else {
|
||||
true // Входящие сообщения не показывают галочки
|
||||
};
|
||||
|
||||
let (content, entities) = TdClient::extract_message_text_static(message);
|
||||
|
||||
// Извлекаем информацию о reply
|
||||
let reply_to = extract_reply_info(client, message);
|
||||
|
||||
// Извлекаем информацию о forward
|
||||
let forward_from = extract_forward_info(client, message);
|
||||
|
||||
// Извлекаем реакции
|
||||
let reactions = extract_reactions(client, message);
|
||||
|
||||
// Используем MessageBuilder для более читабельного создания
|
||||
let mut builder = crate::tdlib::MessageBuilder::new(message_id)
|
||||
.sender_name(sender_name)
|
||||
.text(content)
|
||||
.entities(entities)
|
||||
.date(message.date)
|
||||
.edit_date(message.edit_date)
|
||||
.media_album_id(message.media_album_id);
|
||||
|
||||
// Применяем флаги
|
||||
if message.is_outgoing {
|
||||
builder = builder.outgoing();
|
||||
}
|
||||
if is_read {
|
||||
builder = builder.read();
|
||||
}
|
||||
if message.can_be_edited {
|
||||
builder = builder.editable();
|
||||
}
|
||||
if message.can_be_deleted_only_for_self {
|
||||
builder = builder.deletable_for_self();
|
||||
}
|
||||
if message.can_be_deleted_for_all_users {
|
||||
builder = builder.deletable_for_all();
|
||||
}
|
||||
|
||||
// Добавляем опциональные данные
|
||||
if let Some(reply) = reply_to {
|
||||
builder = builder.reply_to(reply);
|
||||
}
|
||||
if let Some(forward) = forward_from {
|
||||
builder = builder.forward_from(forward);
|
||||
}
|
||||
if !reactions.is_empty() {
|
||||
builder = builder.reactions(reactions);
|
||||
}
|
||||
|
||||
builder.build()
|
||||
}
|
||||
|
||||
/// Извлекает информацию о reply из сообщения
|
||||
pub fn extract_reply_info(client: &TdClient, message: &TdMessage) -> Option<ReplyInfo> {
|
||||
use tdlib_rs::enums::MessageReplyTo;
|
||||
|
||||
match &message.reply_to {
|
||||
Some(MessageReplyTo::Message(reply)) => {
|
||||
// Получаем имя отправителя из origin или ищем сообщение в текущем списке
|
||||
let sender_name = reply
|
||||
.origin
|
||||
.as_ref()
|
||||
.map(get_origin_sender_name)
|
||||
.unwrap_or_else(|| {
|
||||
// Пробуем найти оригинальное сообщение в текущем списке
|
||||
let reply_msg_id = MessageId::new(reply.message_id);
|
||||
client
|
||||
.current_chat_messages()
|
||||
.iter()
|
||||
.find(|m| m.id() == reply_msg_id)
|
||||
.map(|m| m.sender_name().to_string())
|
||||
.unwrap_or_else(|| "...".to_string())
|
||||
});
|
||||
|
||||
// Получаем текст из content или quote
|
||||
let reply_msg_id = MessageId::new(reply.message_id);
|
||||
let text = reply
|
||||
.quote
|
||||
.as_ref()
|
||||
.map(|q| q.text.text.clone())
|
||||
.or_else(|| reply.content.as_ref().map(TdClient::extract_content_text))
|
||||
.unwrap_or_else(|| {
|
||||
// Пробуем найти в текущих сообщениях
|
||||
client
|
||||
.current_chat_messages()
|
||||
.iter()
|
||||
.find(|m| m.id() == reply_msg_id)
|
||||
.map(|m| m.text().to_string())
|
||||
.unwrap_or_default()
|
||||
});
|
||||
|
||||
Some(ReplyInfo { message_id: reply_msg_id, sender_name, text })
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Извлекает информацию о forward из сообщения
|
||||
pub fn extract_forward_info(_client: &TdClient, message: &TdMessage) -> Option<ForwardInfo> {
|
||||
message.forward_info.as_ref().map(|info| {
|
||||
let sender_name = get_origin_sender_name(&info.origin);
|
||||
ForwardInfo { sender_name }
|
||||
})
|
||||
}
|
||||
|
||||
/// Извлекает реакции из сообщения
|
||||
pub fn extract_reactions(_client: &TdClient, message: &TdMessage) -> Vec<ReactionInfo> {
|
||||
message
|
||||
.interaction_info
|
||||
.as_ref()
|
||||
.and_then(|info| info.reactions.as_ref())
|
||||
.map(|reactions| {
|
||||
reactions
|
||||
.reactions
|
||||
.iter()
|
||||
.filter_map(|reaction| {
|
||||
let emoji = match &reaction.r#type {
|
||||
tdlib_rs::enums::ReactionType::Emoji(e) => e.emoji.clone(),
|
||||
tdlib_rs::enums::ReactionType::CustomEmoji(_) => return None,
|
||||
};
|
||||
|
||||
Some(ReactionInfo {
|
||||
emoji,
|
||||
count: reaction.total_count,
|
||||
is_chosen: reaction.is_chosen,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Получает имя отправителя из MessageOrigin
|
||||
fn get_origin_sender_name(origin: &tdlib_rs::enums::MessageOrigin) -> String {
|
||||
use tdlib_rs::enums::MessageOrigin;
|
||||
|
||||
match origin {
|
||||
MessageOrigin::User(u) => format!("User_{}", u.sender_user_id),
|
||||
MessageOrigin::Chat(c) => format!("Chat_{}", c.sender_chat_id),
|
||||
MessageOrigin::Channel(c) => c.author_signature.clone(),
|
||||
MessageOrigin::HiddenUser(h) => h.sender_name.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Обновляет reply info для сообщений, где данные не были загружены
|
||||
/// Вызывается после загрузки истории, когда все сообщения уже в списке
|
||||
#[allow(dead_code)]
|
||||
pub fn update_reply_info_from_loaded_messages(client: &mut TdClient) {
|
||||
// Собираем данные для обновления (id -> (sender_name, content))
|
||||
let msg_data: std::collections::HashMap<i64, (String, String)> = client
|
||||
.current_chat_messages()
|
||||
.iter()
|
||||
.map(|m| (m.id().as_i64(), (m.sender_name().to_string(), m.text().to_string())))
|
||||
.collect();
|
||||
|
||||
// Обновляем reply_to для сообщений с неполными данными
|
||||
for msg in client.current_chat_messages_mut().iter_mut() {
|
||||
let Some(ref mut reply) = msg.interactions.reply_to else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// Если sender_name = "..." или text пустой — пробуем заполнить
|
||||
if reply.sender_name != "..." && !reply.text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some((sender, content)) = msg_data.get(&reply.message_id.as_i64()) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if reply.sender_name == "..." {
|
||||
reply.sender_name = sender.clone();
|
||||
}
|
||||
if reply.text.is_empty() {
|
||||
reply.text = content.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,859 +0,0 @@
|
||||
use crate::constants::{MAX_MESSAGES_IN_CHAT, TDLIB_MESSAGE_LIMIT};
|
||||
use crate::types::{ChatId, MessageId};
|
||||
use tdlib_rs::enums::{ChatAction, InputMessageContent, InputMessageReplyTo, MessageContent, MessageSender, SearchMessagesFilter, TextParseMode};
|
||||
use tdlib_rs::functions;
|
||||
use tdlib_rs::types::{Chat as TdChat, FormattedText, InputMessageReplyToMessage, InputMessageText, Message as TdMessage, TextEntity, TextParseModeMarkdown};
|
||||
|
||||
use super::types::{ForwardInfo, MessageBuilder, MessageInfo, ReactionInfo, ReplyInfo};
|
||||
|
||||
/// Менеджер сообщений TDLib.
|
||||
///
|
||||
/// Управляет загрузкой, отправкой, редактированием и удалением сообщений.
|
||||
/// Кеширует сообщения текущего открытого чата и закрепленные сообщения.
|
||||
///
|
||||
/// # Основные возможности
|
||||
///
|
||||
/// - Загрузка истории сообщений чата
|
||||
/// - Отправка текстовых сообщений с поддержкой Markdown
|
||||
/// - Редактирование и удаление сообщений
|
||||
/// - Пересылка сообщений между чатами
|
||||
/// - Поиск сообщений по тексту
|
||||
/// - Управление закрепленными сообщениями
|
||||
/// - Управление черновиками
|
||||
/// - Автоматическая отметка сообщений как прочитанных
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// let mut msg_manager = MessageManager::new(client_id);
|
||||
///
|
||||
/// // Загрузить историю чата
|
||||
/// let messages = msg_manager.get_chat_history(chat_id, 50).await?;
|
||||
///
|
||||
/// // Отправить сообщение
|
||||
/// let msg = msg_manager.send_message(
|
||||
/// chat_id,
|
||||
/// "Hello, **world**!".to_string(),
|
||||
/// None,
|
||||
/// None
|
||||
/// ).await?;
|
||||
/// ```
|
||||
pub struct MessageManager {
|
||||
/// Список сообщений текущего открытого чата (до MAX_MESSAGES_IN_CHAT).
|
||||
pub current_chat_messages: Vec<MessageInfo>,
|
||||
|
||||
/// ID текущего открытого чата.
|
||||
pub current_chat_id: Option<ChatId>,
|
||||
|
||||
/// Текущее закрепленное сообщение открытого чата.
|
||||
pub current_pinned_message: Option<MessageInfo>,
|
||||
|
||||
/// Очередь сообщений для отметки как прочитанных: (chat_id, message_ids).
|
||||
pub pending_view_messages: Vec<(ChatId, Vec<MessageId>)>,
|
||||
|
||||
/// ID клиента TDLib для API вызовов.
|
||||
client_id: i32,
|
||||
}
|
||||
|
||||
impl MessageManager {
|
||||
/// Создает новый менеджер сообщений.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `client_id` - ID клиента TDLib для API вызовов
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Новый экземпляр `MessageManager` с пустым списком сообщений.
|
||||
pub fn new(client_id: i32) -> Self {
|
||||
Self {
|
||||
current_chat_messages: Vec::new(),
|
||||
current_chat_id: None,
|
||||
current_pinned_message: None,
|
||||
pending_view_messages: Vec::new(),
|
||||
client_id,
|
||||
}
|
||||
}
|
||||
|
||||
/// Добавляет сообщение в список текущего чата.
|
||||
///
|
||||
/// Автоматически ограничивает размер списка до [`MAX_MESSAGES_IN_CHAT`],
|
||||
/// удаляя старые сообщения при превышении лимита.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `msg` - Сообщение для добавления
|
||||
///
|
||||
/// # Note
|
||||
///
|
||||
/// Сообщение добавляется в конец списка. При превышении лимита
|
||||
/// удаляются самые старые сообщения из начала списка.
|
||||
pub fn push_message(&mut self, msg: MessageInfo) {
|
||||
self.current_chat_messages.push(msg); // Добавляем в конец
|
||||
|
||||
// Ограничиваем размер списка (удаляем старые с начала)
|
||||
if self.current_chat_messages.len() > MAX_MESSAGES_IN_CHAT {
|
||||
self.current_chat_messages.drain(0..(self.current_chat_messages.len() - MAX_MESSAGES_IN_CHAT));
|
||||
}
|
||||
}
|
||||
|
||||
/// Загружает историю сообщений чата.
|
||||
///
|
||||
/// Запрашивает последние сообщения из указанного чата и сохраняет их
|
||||
/// в [`current_chat_messages`](Self::current_chat_messages). Делает несколько попыток
|
||||
/// загрузки при неудаче.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `chat_id` - ID чата для загрузки истории
|
||||
/// * `limit` - Максимальное количество сообщений (обычно до 50)
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok(Vec<MessageInfo>)` - Список загруженных сообщений (от старых к новым)
|
||||
/// * `Err(String)` - Ошибка загрузки после всех попыток
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// let messages = msg_manager.get_chat_history(
|
||||
/// ChatId::new(123),
|
||||
/// 50
|
||||
/// ).await?;
|
||||
/// println!("Loaded {} messages", messages.len());
|
||||
/// ```
|
||||
pub async fn get_chat_history(
|
||||
&mut self,
|
||||
chat_id: ChatId,
|
||||
limit: i32,
|
||||
) -> Result<Vec<MessageInfo>, String> {
|
||||
use tokio::time::{sleep, Duration};
|
||||
|
||||
// ВАЖНО: Сначала открываем чат в TDLib
|
||||
// Это сообщает TDLib что пользователь открыл чат и нужно загрузить историю
|
||||
let _ = functions::open_chat(chat_id.as_i64(), self.client_id).await;
|
||||
|
||||
// Даём TDLib время на синхронизацию (загрузку истории с сервера)
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
|
||||
// НЕ устанавливаем current_chat_id здесь!
|
||||
// Он будет установлен снаружи ПОСЛЕ сохранения истории
|
||||
// Это предотвращает race condition с Update::NewMessage
|
||||
|
||||
// Пробуем загрузить несколько раз, TDLib может подгружать с сервера
|
||||
let mut all_messages = Vec::new();
|
||||
let max_attempts = 3;
|
||||
|
||||
for attempt in 1..=max_attempts {
|
||||
let result = functions::get_chat_history(
|
||||
chat_id.as_i64(),
|
||||
0, // from_message_id (0 = from latest)
|
||||
0, // offset
|
||||
limit,
|
||||
false, // only_local - false means can fetch from server
|
||||
self.client_id,
|
||||
)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(tdlib_rs::enums::Messages::Messages(messages_obj)) => {
|
||||
if !messages_obj.messages.is_empty() {
|
||||
all_messages.clear(); // Очищаем предыдущие результаты
|
||||
for msg_opt in messages_obj.messages.iter().rev() {
|
||||
if let Some(msg) = msg_opt {
|
||||
if let Some(info) = self.convert_message(msg).await {
|
||||
all_messages.push(info);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Если получили достаточно сообщений, прекращаем попытки
|
||||
if all_messages.len() >= 2 || attempt == max_attempts {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Если сообщений мало, ждём перед следующей попыткой
|
||||
if attempt < max_attempts {
|
||||
sleep(Duration::from_millis(200)).await;
|
||||
}
|
||||
}
|
||||
Ok(_) => return Err("Неожиданный тип сообщений".to_string()),
|
||||
Err(e) => return Err(format!("Ошибка загрузки истории: {:?}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(all_messages)
|
||||
}
|
||||
|
||||
/// Загружает более старые сообщения для пагинации.
|
||||
///
|
||||
/// Используется для подгрузки предыдущих сообщений при прокрутке
|
||||
/// истории чата вверх.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `chat_id` - ID чата
|
||||
/// * `from_message_id` - ID сообщения, от которого загружать историю
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok(Vec<MessageInfo>)` - Список старых сообщений (от старых к новым)
|
||||
/// * `Err(String)` - Ошибка загрузки
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// // Загрузить сообщения старше указанного
|
||||
/// let older = msg_manager.load_older_messages(
|
||||
/// chat_id,
|
||||
/// MessageId::new(12345)
|
||||
/// ).await?;
|
||||
/// ```
|
||||
pub async fn load_older_messages(
|
||||
&mut self,
|
||||
chat_id: ChatId,
|
||||
from_message_id: MessageId,
|
||||
) -> Result<Vec<MessageInfo>, String> {
|
||||
let result = functions::get_chat_history(
|
||||
chat_id.as_i64(),
|
||||
from_message_id.as_i64(),
|
||||
0, // offset
|
||||
TDLIB_MESSAGE_LIMIT,
|
||||
false,
|
||||
self.client_id,
|
||||
)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(tdlib_rs::enums::Messages::Messages(messages_obj)) => {
|
||||
let mut messages = Vec::new();
|
||||
for msg_opt in messages_obj.messages.iter().rev() {
|
||||
if let Some(msg) = msg_opt {
|
||||
if let Some(info) = self.convert_message(msg).await {
|
||||
messages.push(info);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(messages)
|
||||
}
|
||||
Ok(_) => Err("Неожиданный тип сообщений".to_string()),
|
||||
Err(e) => Err(format!("Ошибка загрузки старых сообщений: {:?}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Получает все закрепленные сообщения чата.
|
||||
///
|
||||
/// Выполняет поиск всех сообщений с фильтром "pinned" и возвращает их список.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `chat_id` - ID чата
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok(Vec<MessageInfo>)` - Список закрепленных сообщений (до 100)
|
||||
/// * `Err(String)` - Ошибка загрузки
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// let pinned = msg_manager.get_pinned_messages(chat_id).await?;
|
||||
/// println!("Found {} pinned messages", pinned.len());
|
||||
/// ```
|
||||
pub async fn get_pinned_messages(&mut self, chat_id: ChatId) -> Result<Vec<MessageInfo>, String> {
|
||||
let result = functions::search_chat_messages(
|
||||
chat_id.as_i64(),
|
||||
String::new(),
|
||||
None,
|
||||
0, // from_message_id
|
||||
0, // offset
|
||||
100, // limit
|
||||
Some(SearchMessagesFilter::Pinned),
|
||||
0, // message_thread_id
|
||||
0, // saved_messages_topic_id
|
||||
self.client_id,
|
||||
)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(tdlib_rs::enums::FoundChatMessages::FoundChatMessages(messages_obj)) => {
|
||||
let mut pinned_messages = Vec::new();
|
||||
for msg in messages_obj.messages.iter().rev() {
|
||||
if let Some(info) = self.convert_message(msg).await {
|
||||
pinned_messages.push(info);
|
||||
}
|
||||
}
|
||||
Ok(pinned_messages)
|
||||
}
|
||||
Ok(_) => Err("Неожиданный тип результата поиска".to_string()),
|
||||
Err(e) => Err(format!("Ошибка загрузки закреплённых: {:?}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Загружает текущее верхнее закрепленное сообщение.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `chat_id` - ID чата
|
||||
///
|
||||
/// # Note
|
||||
///
|
||||
/// TODO: В tdlib-rs 1.8.29 поле `pinned_message_id` было удалено из `Chat`.
|
||||
/// Нужно использовать `getChatPinnedMessage` или альтернативный способ.
|
||||
/// Временно отключено, возвращает `None`.
|
||||
pub async fn load_current_pinned_message(&mut self, chat_id: ChatId) {
|
||||
// TODO: В tdlib-rs 1.8.29 поле pinned_message_id было удалено из Chat.
|
||||
// Нужно использовать getChatPinnedMessage или альтернативный способ.
|
||||
// Временно отключено.
|
||||
let _ = chat_id;
|
||||
self.current_pinned_message = None;
|
||||
|
||||
// match functions::get_chat(chat_id, self.client_id).await {
|
||||
// Ok(tdlib_rs::enums::Chat::Chat(chat)) => {
|
||||
// // chat.pinned_message_id больше не существует
|
||||
// }
|
||||
// _ => {}
|
||||
// }
|
||||
}
|
||||
|
||||
/// Выполняет поиск сообщений по тексту в указанном чате.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `chat_id` - ID чата для поиска
|
||||
/// * `query` - Текстовый запрос для поиска
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok(Vec<MessageInfo>)` - Найденные сообщения (до 100)
|
||||
/// * `Err(String)` - Ошибка поиска
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// let results = msg_manager.search_messages(chat_id, "hello").await?;
|
||||
/// ```
|
||||
pub async fn search_messages(
|
||||
&self,
|
||||
chat_id: ChatId,
|
||||
query: &str,
|
||||
) -> Result<Vec<MessageInfo>, String> {
|
||||
let result = functions::search_chat_messages(
|
||||
chat_id.as_i64(),
|
||||
query.to_string(),
|
||||
None,
|
||||
0, // from_message_id
|
||||
0, // offset
|
||||
100, // limit
|
||||
None,
|
||||
0, // message_thread_id
|
||||
0, // saved_messages_topic_id
|
||||
self.client_id,
|
||||
)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(tdlib_rs::enums::FoundChatMessages::FoundChatMessages(messages_obj)) => {
|
||||
let mut search_results = Vec::new();
|
||||
for msg in messages_obj.messages.iter().rev() {
|
||||
if let Some(info) = self.convert_message(msg).await {
|
||||
search_results.push(info);
|
||||
}
|
||||
}
|
||||
Ok(search_results)
|
||||
}
|
||||
Ok(_) => Err("Неожиданный тип результата поиска".to_string()),
|
||||
Err(e) => Err(format!("Ошибка поиска: {:?}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Отправляет текстовое сообщение в чат с поддержкой Markdown.
|
||||
///
|
||||
/// Автоматически парсит Markdown v2 форматирование (**bold**, *italic*, `code` и т.д.).
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `chat_id` - ID чата-получателя
|
||||
/// * `text` - Текст сообщения (поддерживает Markdown v2)
|
||||
/// * `reply_to_message_id` - Опциональный ID сообщения для ответа
|
||||
/// * `reply_info` - Опциональная информация об исходном сообщении
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok(MessageInfo)` - Отправленное сообщение
|
||||
/// * `Err(String)` - Ошибка отправки
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// // Простое сообщение
|
||||
/// let msg = msg_manager.send_message(
|
||||
/// chat_id,
|
||||
/// "Hello, **world**!".to_string(),
|
||||
/// None,
|
||||
/// None
|
||||
/// ).await?;
|
||||
///
|
||||
/// // Ответ на сообщение
|
||||
/// let reply = msg_manager.send_message(
|
||||
/// chat_id,
|
||||
/// "Got it!".to_string(),
|
||||
/// Some(MessageId::new(123)),
|
||||
/// Some(reply_info)
|
||||
/// ).await?;
|
||||
/// ```
|
||||
pub async fn send_message(
|
||||
&self,
|
||||
chat_id: ChatId,
|
||||
text: String,
|
||||
reply_to_message_id: Option<MessageId>,
|
||||
reply_info: Option<ReplyInfo>,
|
||||
) -> Result<MessageInfo, String> {
|
||||
// Парсим markdown в тексте
|
||||
let formatted_text = match functions::parse_text_entities(
|
||||
text.clone(),
|
||||
TextParseMode::Markdown(TextParseModeMarkdown { version: 2 }),
|
||||
self.client_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(tdlib_rs::enums::FormattedText::FormattedText(ft)) => {
|
||||
FormattedText {
|
||||
text: ft.text,
|
||||
entities: ft.entities,
|
||||
}
|
||||
}
|
||||
Err(_) => FormattedText {
|
||||
text: text.clone(),
|
||||
entities: vec![],
|
||||
},
|
||||
};
|
||||
|
||||
let content = InputMessageContent::InputMessageText(InputMessageText {
|
||||
text: formatted_text,
|
||||
link_preview_options: None,
|
||||
clear_draft: true,
|
||||
});
|
||||
|
||||
let reply_to = reply_to_message_id.map(|msg_id| {
|
||||
InputMessageReplyTo::Message(InputMessageReplyToMessage {
|
||||
chat_id: 0,
|
||||
message_id: msg_id.as_i64(),
|
||||
quote: None,
|
||||
})
|
||||
});
|
||||
|
||||
let result = functions::send_message(
|
||||
chat_id.as_i64(),
|
||||
0, // message_thread_id
|
||||
reply_to,
|
||||
None, // options
|
||||
content,
|
||||
self.client_id,
|
||||
)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(tdlib_rs::enums::Message::Message(msg)) => {
|
||||
let mut msg_info = self
|
||||
.convert_message(&msg)
|
||||
.await
|
||||
.ok_or_else(|| "Не удалось конвертировать сообщение".to_string())?;
|
||||
|
||||
// Добавляем reply_info если был передан
|
||||
if let Some(reply) = reply_info {
|
||||
msg_info.interactions.reply_to = Some(reply);
|
||||
}
|
||||
|
||||
Ok(msg_info)
|
||||
}
|
||||
Ok(_) => Err("Неожиданный тип сообщения".to_string()),
|
||||
Err(e) => Err(format!("Ошибка отправки сообщения: {:?}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Редактирует существующее сообщение.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `chat_id` - ID чата
|
||||
/// * `message_id` - ID сообщения для редактирования
|
||||
/// * `text` - Новый текст (поддерживает Markdown v2)
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok(MessageInfo)` - Отредактированное сообщение
|
||||
/// * `Err(String)` - Ошибка (нет прав, сообщение слишком старое и т.д.)
|
||||
pub async fn edit_message(
|
||||
&self,
|
||||
chat_id: ChatId,
|
||||
message_id: MessageId,
|
||||
text: String,
|
||||
) -> Result<MessageInfo, String> {
|
||||
let formatted_text = match functions::parse_text_entities(
|
||||
text.clone(),
|
||||
TextParseMode::Markdown(TextParseModeMarkdown { version: 2 }),
|
||||
self.client_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(tdlib_rs::enums::FormattedText::FormattedText(ft)) => {
|
||||
FormattedText {
|
||||
text: ft.text,
|
||||
entities: ft.entities,
|
||||
}
|
||||
}
|
||||
Err(_) => FormattedText {
|
||||
text: text.clone(),
|
||||
entities: vec![],
|
||||
},
|
||||
};
|
||||
|
||||
let content = InputMessageContent::InputMessageText(InputMessageText {
|
||||
text: formatted_text,
|
||||
link_preview_options: None,
|
||||
clear_draft: true,
|
||||
});
|
||||
|
||||
let result =
|
||||
functions::edit_message_text(chat_id.as_i64(), message_id.as_i64(), content, self.client_id).await;
|
||||
|
||||
match result {
|
||||
Ok(tdlib_rs::enums::Message::Message(msg)) => self
|
||||
.convert_message(&msg)
|
||||
.await
|
||||
.ok_or_else(|| "Не удалось конвертировать отредактированное сообщение".to_string()),
|
||||
Ok(_) => Err("Неожиданный тип сообщения".to_string()),
|
||||
Err(e) => Err(format!("Ошибка редактирования: {:?}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Удаляет одно или несколько сообщений.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `chat_id` - ID чата
|
||||
/// * `message_ids` - Список ID сообщений для удаления
|
||||
/// * `revoke` - `true` - удалить для всех, `false` - только для себя
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok(())` - Сообщения удалены
|
||||
/// * `Err(String)` - Ошибка удаления
|
||||
pub async fn delete_messages(
|
||||
&self,
|
||||
chat_id: ChatId,
|
||||
message_ids: Vec<MessageId>,
|
||||
revoke: bool,
|
||||
) -> Result<(), String> {
|
||||
let message_ids_i64: Vec<i64> = message_ids.into_iter().map(|id| id.as_i64()).collect();
|
||||
let result =
|
||||
functions::delete_messages(chat_id.as_i64(), message_ids_i64, revoke, self.client_id).await;
|
||||
match result {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) => Err(format!("Ошибка удаления: {:?}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Пересылает сообщения из одного чата в другой.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `to_chat_id` - ID чата-получателя
|
||||
/// * `from_chat_id` - ID чата-источника
|
||||
/// * `message_ids` - Список ID сообщений для пересылки
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok(())` - Сообщения переслань
|
||||
/// * `Err(String)` - Ошибка пересылки
|
||||
pub async fn forward_messages(
|
||||
&self,
|
||||
to_chat_id: ChatId,
|
||||
from_chat_id: ChatId,
|
||||
message_ids: Vec<MessageId>,
|
||||
) -> Result<(), String> {
|
||||
let message_ids_i64: Vec<i64> = message_ids.into_iter().map(|id| id.as_i64()).collect();
|
||||
let result = functions::forward_messages(
|
||||
to_chat_id.as_i64(),
|
||||
0, // message_thread_id
|
||||
from_chat_id.as_i64(),
|
||||
message_ids_i64,
|
||||
None, // options
|
||||
false, // send_copy
|
||||
false, // remove_caption
|
||||
self.client_id,
|
||||
)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) => Err(format!("Ошибка пересылки: {:?}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Сохраняет черновик сообщения для чата.
|
||||
///
|
||||
/// Черновик отображается в списке чатов и восстанавливается
|
||||
/// при следующем открытии чата.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `chat_id` - ID чата
|
||||
/// * `text` - Текст черновика (пустая строка удаляет черновик)
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok(())` - Черновик сохранен
|
||||
/// * `Err(String)` - Ошибка сохранения
|
||||
pub async fn set_draft_message(&self, chat_id: ChatId, text: String) -> Result<(), String> {
|
||||
use tdlib_rs::types::DraftMessage;
|
||||
|
||||
let draft = if text.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(DraftMessage {
|
||||
reply_to: None,
|
||||
date: 0,
|
||||
input_message_text: InputMessageContent::InputMessageText(InputMessageText {
|
||||
text: FormattedText {
|
||||
text: text.clone(),
|
||||
entities: vec![],
|
||||
},
|
||||
link_preview_options: None,
|
||||
clear_draft: false,
|
||||
}),
|
||||
})
|
||||
};
|
||||
|
||||
let result = functions::set_chat_draft_message(chat_id.as_i64(), 0, draft, self.client_id).await;
|
||||
|
||||
match result {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) => Err(format!("Ошибка сохранения черновика: {:?}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Обрабатывает очередь сообщений для отметки как прочитанных.
|
||||
///
|
||||
/// Автоматически отмечает просмотренные сообщения как прочитанные,
|
||||
/// что сбрасывает счетчик непрочитанных сообщений в чате.
|
||||
///
|
||||
/// # Note
|
||||
///
|
||||
/// Вызывайте периодически (например, в основном цикле) для обработки накопленной очереди.
|
||||
pub async fn process_pending_view_messages(&mut self) {
|
||||
if self.pending_view_messages.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let batch = std::mem::take(&mut self.pending_view_messages);
|
||||
|
||||
for (chat_id, message_ids) in batch {
|
||||
let ids: Vec<i64> = message_ids.iter().map(|id| id.as_i64()).collect();
|
||||
let _ = functions::view_messages(chat_id.as_i64(), ids, None, true, self.client_id).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Конвертировать TdMessage в MessageInfo
|
||||
async fn convert_message(&self, msg: &TdMessage) -> Option<MessageInfo> {
|
||||
let content_text = match &msg.content {
|
||||
MessageContent::MessageText(t) => t.text.text.clone(),
|
||||
MessageContent::MessagePhoto(p) => {
|
||||
let caption_text = p.caption.text.clone();
|
||||
if caption_text.is_empty() { "[Фото]".to_string() } else { caption_text }
|
||||
}
|
||||
MessageContent::MessageVideo(v) => {
|
||||
let caption_text = v.caption.text.clone();
|
||||
if caption_text.is_empty() { "[Видео]".to_string() } else { caption_text }
|
||||
}
|
||||
MessageContent::MessageDocument(d) => {
|
||||
let caption_text = d.caption.text.clone();
|
||||
if caption_text.is_empty() { format!("[Файл: {}]", d.document.file_name) } else { caption_text }
|
||||
}
|
||||
MessageContent::MessageSticker(s) => {
|
||||
format!("[Стикер: {}]", s.sticker.emoji)
|
||||
}
|
||||
MessageContent::MessageAnimation(a) => {
|
||||
let caption_text = a.caption.text.clone();
|
||||
if caption_text.is_empty() { "[GIF]".to_string() } else { caption_text }
|
||||
}
|
||||
MessageContent::MessageVoiceNote(v) => {
|
||||
let caption_text = v.caption.text.clone();
|
||||
if caption_text.is_empty() { "[Голосовое]".to_string() } else { caption_text }
|
||||
}
|
||||
MessageContent::MessageAudio(a) => {
|
||||
let caption_text = a.caption.text.clone();
|
||||
if caption_text.is_empty() {
|
||||
let title = a.audio.title.clone();
|
||||
let performer = a.audio.performer.clone();
|
||||
if !title.is_empty() || !performer.is_empty() {
|
||||
format!("[Аудио: {} - {}]", performer, title)
|
||||
} else {
|
||||
"[Аудио]".to_string()
|
||||
}
|
||||
} else {
|
||||
caption_text
|
||||
}
|
||||
}
|
||||
_ => "[Неподдерживаемый тип сообщения]".to_string(),
|
||||
};
|
||||
|
||||
let entities = if let MessageContent::MessageText(t) = &msg.content {
|
||||
t.text.entities.clone()
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
let sender_name = match &msg.sender_id {
|
||||
MessageSender::User(user) => {
|
||||
match functions::get_user(user.user_id, self.client_id).await {
|
||||
Ok(tdlib_rs::enums::User::User(u)) => format!("{} {}", u.first_name, u.last_name).trim().to_string(),
|
||||
_ => format!("User {}", user.user_id),
|
||||
}
|
||||
}
|
||||
MessageSender::Chat(chat) => format!("Chat {}", chat.chat_id),
|
||||
};
|
||||
|
||||
let forward_from = msg.forward_info.as_ref().and_then(|fi| {
|
||||
if let tdlib_rs::enums::MessageOrigin::User(origin_user) = &fi.origin {
|
||||
Some(ForwardInfo {
|
||||
sender_name: format!("User {}", origin_user.sender_user_id),
|
||||
date: fi.date,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
let reply_to = if let Some(ref reply_to) = msg.reply_to {
|
||||
if let tdlib_rs::enums::MessageReplyTo::Message(reply_msg) = reply_to {
|
||||
// Здесь можно загрузить информацию об оригинальном сообщении
|
||||
Some(ReplyInfo {
|
||||
message_id: MessageId::new(reply_msg.message_id),
|
||||
sender_name: "Unknown".to_string(),
|
||||
text: "...".to_string(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let reactions: Vec<ReactionInfo> = msg
|
||||
.interaction_info
|
||||
.as_ref()
|
||||
.and_then(|ii| ii.reactions.as_ref())
|
||||
.map(|reactions| {
|
||||
reactions
|
||||
.reactions
|
||||
.iter()
|
||||
.filter_map(|r| {
|
||||
if let tdlib_rs::enums::ReactionType::Emoji(emoji_type) = &r.r#type {
|
||||
Some(ReactionInfo {
|
||||
emoji: emoji_type.emoji.clone(),
|
||||
count: r.total_count,
|
||||
is_chosen: r.is_chosen,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut builder = MessageBuilder::new(MessageId::new(msg.id))
|
||||
.sender_name(sender_name)
|
||||
.text(content_text)
|
||||
.entities(entities)
|
||||
.date(msg.date)
|
||||
.edit_date(msg.edit_date);
|
||||
|
||||
if msg.is_outgoing {
|
||||
builder = builder.outgoing();
|
||||
} else {
|
||||
builder = builder.incoming();
|
||||
}
|
||||
|
||||
if !msg.contains_unread_mention {
|
||||
builder = builder.read();
|
||||
} else {
|
||||
builder = builder.unread();
|
||||
}
|
||||
|
||||
if msg.can_be_edited {
|
||||
builder = builder.editable();
|
||||
}
|
||||
|
||||
if msg.can_be_deleted_only_for_self {
|
||||
builder = builder.deletable_for_self();
|
||||
}
|
||||
|
||||
if msg.can_be_deleted_for_all_users {
|
||||
builder = builder.deletable_for_all();
|
||||
}
|
||||
|
||||
if let Some(reply) = reply_to {
|
||||
builder = builder.reply_to(reply);
|
||||
}
|
||||
|
||||
if let Some(forward) = forward_from {
|
||||
builder = builder.forward_from(forward);
|
||||
}
|
||||
|
||||
builder = builder.reactions(reactions);
|
||||
|
||||
Some(builder.build())
|
||||
}
|
||||
|
||||
/// Загружает недостающую информацию об исходных сообщениях для ответов.
|
||||
///
|
||||
/// Ищет все reply-сообщения с `sender_name == "Unknown"` и загружает
|
||||
/// полную информацию (имя отправителя, текст) из TDLib.
|
||||
///
|
||||
/// # Note
|
||||
///
|
||||
/// Вызывайте после загрузки истории чата для заполнения информации о цитируемых сообщениях.
|
||||
pub async fn fetch_missing_reply_info(&mut self) {
|
||||
// Collect message IDs that need to be fetched
|
||||
let mut to_fetch = Vec::new();
|
||||
for msg in &self.current_chat_messages {
|
||||
if let Some(ref reply) = msg.interactions.reply_to {
|
||||
if reply.sender_name == "Unknown" {
|
||||
to_fetch.push(reply.message_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch missing messages
|
||||
if let Some(chat_id) = self.current_chat_id {
|
||||
for message_id in to_fetch {
|
||||
if let Ok(original_msg_enum) =
|
||||
functions::get_message(chat_id.as_i64(), message_id.as_i64(), self.client_id).await
|
||||
{
|
||||
if let tdlib_rs::enums::Message::Message(original_msg) = original_msg_enum {
|
||||
if let Some(orig_info) = self.convert_message(&original_msg).await {
|
||||
// Update the reply info
|
||||
for msg in &mut self.current_chat_messages {
|
||||
if let Some(ref mut reply) = msg.interactions.reply_to {
|
||||
if reply.message_id == message_id {
|
||||
reply.sender_name = orig_info.metadata.sender_name.clone();
|
||||
reply.text = orig_info
|
||||
.content
|
||||
.text
|
||||
.chars()
|
||||
.take(50)
|
||||
.collect::<String>();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
137
src/tdlib/messages/convert.rs
Normal file
137
src/tdlib/messages/convert.rs
Normal file
@@ -0,0 +1,137 @@
|
||||
//! TDLib message conversion: JSON → MessageInfo, reply info fetching.
|
||||
|
||||
use crate::types::{ChatId, MessageId};
|
||||
use tdlib_rs::functions;
|
||||
use tdlib_rs::types::Message as TdMessage;
|
||||
|
||||
use crate::tdlib::types::{MessageBuilder, MessageInfo};
|
||||
|
||||
use super::MessageManager;
|
||||
|
||||
impl MessageManager {
|
||||
/// Конвертировать TdMessage в MessageInfo
|
||||
pub(crate) async fn convert_message(&self, msg: &TdMessage) -> Option<MessageInfo> {
|
||||
use crate::tdlib::message_conversion::{
|
||||
extract_content_text, extract_entities, extract_forward_info, extract_media_info,
|
||||
extract_reactions, extract_reply_info, extract_sender_name,
|
||||
};
|
||||
|
||||
// Извлекаем все части сообщения используя вспомогательные функции
|
||||
let content_text = extract_content_text(msg);
|
||||
let entities = extract_entities(msg);
|
||||
let sender_name = extract_sender_name(msg, self.client_id).await;
|
||||
let forward_from = extract_forward_info(msg);
|
||||
let reply_to = extract_reply_info(msg);
|
||||
let reactions = extract_reactions(msg);
|
||||
let media = extract_media_info(msg);
|
||||
|
||||
let mut builder = MessageBuilder::new(MessageId::new(msg.id))
|
||||
.sender_name(sender_name)
|
||||
.text(content_text)
|
||||
.entities(entities)
|
||||
.date(msg.date)
|
||||
.edit_date(msg.edit_date)
|
||||
.media_album_id(msg.media_album_id);
|
||||
|
||||
if msg.is_outgoing {
|
||||
builder = builder.outgoing();
|
||||
} else {
|
||||
builder = builder.incoming();
|
||||
}
|
||||
|
||||
if !msg.contains_unread_mention {
|
||||
builder = builder.read();
|
||||
} else {
|
||||
builder = builder.unread();
|
||||
}
|
||||
|
||||
if msg.can_be_edited {
|
||||
builder = builder.editable();
|
||||
}
|
||||
|
||||
if msg.can_be_deleted_only_for_self {
|
||||
builder = builder.deletable_for_self();
|
||||
}
|
||||
|
||||
if msg.can_be_deleted_for_all_users {
|
||||
builder = builder.deletable_for_all();
|
||||
}
|
||||
|
||||
if let Some(reply) = reply_to {
|
||||
builder = builder.reply_to(reply);
|
||||
}
|
||||
|
||||
if let Some(forward) = forward_from {
|
||||
builder = builder.forward_from(forward);
|
||||
}
|
||||
|
||||
builder = builder.reactions(reactions);
|
||||
|
||||
if let Some(media) = media {
|
||||
builder = builder.media(media);
|
||||
}
|
||||
|
||||
Some(builder.build())
|
||||
}
|
||||
|
||||
/// Загружает недостающую информацию об исходных сообщениях для ответов.
|
||||
///
|
||||
/// Ищет все reply-сообщения с `sender_name == "Unknown"` и загружает
|
||||
/// полную информацию (имя отправителя, текст) из TDLib.
|
||||
///
|
||||
/// # Note
|
||||
///
|
||||
/// Вызывайте после загрузки истории чата для заполнения информации о цитируемых сообщениях.
|
||||
pub async fn fetch_missing_reply_info(&mut self) {
|
||||
// Early return if no chat selected
|
||||
let Some(chat_id) = self.current_chat_id else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Collect message IDs with missing reply info using filter_map
|
||||
let to_fetch: Vec<MessageId> = self
|
||||
.current_chat_messages
|
||||
.iter()
|
||||
.filter_map(|msg| {
|
||||
msg.interactions
|
||||
.reply_to
|
||||
.as_ref()
|
||||
.filter(|reply| reply.sender_name == "Unknown")
|
||||
.map(|reply| reply.message_id)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Fetch and update each missing message
|
||||
for message_id in to_fetch {
|
||||
self.fetch_and_update_reply(chat_id, message_id).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Загружает одно сообщение и обновляет reply информацию.
|
||||
async fn fetch_and_update_reply(&mut self, chat_id: ChatId, message_id: MessageId) {
|
||||
// Try to fetch the original message
|
||||
let Ok(original_msg_enum) =
|
||||
functions::get_message(chat_id.as_i64(), message_id.as_i64(), self.client_id).await
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
let tdlib_rs::enums::Message::Message(original_msg) = original_msg_enum;
|
||||
let Some(orig_info) = self.convert_message(&original_msg).await else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Extract text preview (first 50 chars)
|
||||
let text_preview: String = orig_info.content.text.chars().take(50).collect();
|
||||
|
||||
// Update reply info in all messages that reference this message
|
||||
self.current_chat_messages
|
||||
.iter_mut()
|
||||
.filter_map(|msg| msg.interactions.reply_to.as_mut())
|
||||
.filter(|reply| reply.message_id == message_id)
|
||||
.for_each(|reply| {
|
||||
reply.sender_name = orig_info.metadata.sender_name.clone();
|
||||
reply.text = text_preview.clone();
|
||||
});
|
||||
}
|
||||
}
|
||||
102
src/tdlib/messages/mod.rs
Normal file
102
src/tdlib/messages/mod.rs
Normal file
@@ -0,0 +1,102 @@
|
||||
//! Message management: storage, conversion, and TDLib API operations.
|
||||
|
||||
mod convert;
|
||||
mod operations;
|
||||
|
||||
use crate::constants::MAX_MESSAGES_IN_CHAT;
|
||||
use crate::types::{ChatId, MessageId};
|
||||
|
||||
use super::types::MessageInfo;
|
||||
|
||||
/// Менеджер сообщений TDLib.
|
||||
///
|
||||
/// Управляет загрузкой, отправкой, редактированием и удалением сообщений.
|
||||
/// Кеширует сообщения текущего открытого чата и закрепленные сообщения.
|
||||
///
|
||||
/// # Основные возможности
|
||||
///
|
||||
/// - Загрузка истории сообщений чата
|
||||
/// - Отправка текстовых сообщений с поддержкой Markdown
|
||||
/// - Редактирование и удаление сообщений
|
||||
/// - Пересылка сообщений между чатами
|
||||
/// - Поиск сообщений по тексту
|
||||
/// - Управление закрепленными сообщениями
|
||||
/// - Управление черновиками
|
||||
/// - Автоматическая отметка сообщений как прочитанных
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// let mut msg_manager = MessageManager::new(client_id);
|
||||
///
|
||||
/// // Загрузить историю чата
|
||||
/// let messages = msg_manager.get_chat_history(chat_id, 50).await?;
|
||||
///
|
||||
/// // Отправить сообщение
|
||||
/// let msg = msg_manager.send_message(
|
||||
/// chat_id,
|
||||
/// "Hello, **world**!".to_string(),
|
||||
/// None,
|
||||
/// None
|
||||
/// ).await?;
|
||||
/// ```
|
||||
pub struct MessageManager {
|
||||
/// Список сообщений текущего открытого чата (до MAX_MESSAGES_IN_CHAT).
|
||||
pub current_chat_messages: Vec<MessageInfo>,
|
||||
|
||||
/// ID текущего открытого чата.
|
||||
pub current_chat_id: Option<ChatId>,
|
||||
|
||||
/// Текущее закрепленное сообщение открытого чата.
|
||||
pub current_pinned_message: Option<MessageInfo>,
|
||||
|
||||
/// Очередь сообщений для отметки как прочитанных: (chat_id, message_ids).
|
||||
pub pending_view_messages: Vec<(ChatId, Vec<MessageId>)>,
|
||||
|
||||
/// ID клиента TDLib для API вызовов.
|
||||
pub(crate) client_id: i32,
|
||||
}
|
||||
|
||||
impl MessageManager {
|
||||
/// Создает новый менеджер сообщений.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `client_id` - ID клиента TDLib для API вызовов
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Новый экземпляр `MessageManager` с пустым списком сообщений.
|
||||
pub fn new(client_id: i32) -> Self {
|
||||
Self {
|
||||
current_chat_messages: Vec::new(),
|
||||
current_chat_id: None,
|
||||
current_pinned_message: None,
|
||||
pending_view_messages: Vec::new(),
|
||||
client_id,
|
||||
}
|
||||
}
|
||||
|
||||
/// Добавляет сообщение в список текущего чата.
|
||||
///
|
||||
/// Автоматически ограничивает размер списка до [`MAX_MESSAGES_IN_CHAT`],
|
||||
/// удаляя старые сообщения при превышении лимита.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `msg` - Сообщение для добавления
|
||||
///
|
||||
/// # Note
|
||||
///
|
||||
/// Сообщение добавляется в конец списка. При превышении лимита
|
||||
/// удаляются самые старые сообщения из начала списка.
|
||||
pub fn push_message(&mut self, msg: MessageInfo) {
|
||||
self.current_chat_messages.push(msg); // Добавляем в конец
|
||||
|
||||
// Ограничиваем размер списка (удаляем старые с начала)
|
||||
if self.current_chat_messages.len() > MAX_MESSAGES_IN_CHAT {
|
||||
self.current_chat_messages
|
||||
.drain(0..(self.current_chat_messages.len() - MAX_MESSAGES_IN_CHAT));
|
||||
}
|
||||
}
|
||||
}
|
||||
617
src/tdlib/messages/operations.rs
Normal file
617
src/tdlib/messages/operations.rs
Normal file
@@ -0,0 +1,617 @@
|
||||
//! TDLib message API operations: history, send, edit, delete, forward, search.
|
||||
|
||||
use crate::constants::TDLIB_MESSAGE_LIMIT;
|
||||
use crate::types::{ChatId, MessageId};
|
||||
use tdlib_rs::enums::{
|
||||
InputMessageContent, InputMessageReplyTo, SearchMessagesFilter, TextParseMode,
|
||||
};
|
||||
use tdlib_rs::functions;
|
||||
use tdlib_rs::types::{
|
||||
FormattedText, InputMessageReplyToMessage, InputMessageText, TextParseModeMarkdown,
|
||||
};
|
||||
use tokio::time::{sleep, Duration};
|
||||
|
||||
use crate::tdlib::types::{MessageInfo, ReplyInfo};
|
||||
|
||||
use super::MessageManager;
|
||||
|
||||
impl MessageManager {
|
||||
/// Загружает историю сообщений чата с динамической подгрузкой.
|
||||
///
|
||||
/// Загружает сообщения чанками, ожидая пока TDLib синхронизирует их с сервера.
|
||||
/// Продолжает загрузку пока не будет достигнут `limit` или пока TDLib отдает сообщения.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `chat_id` - ID чата
|
||||
/// * `limit` - Желаемое минимальное количество сообщений (для заполнения экрана)
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok(Vec<MessageInfo>)` - Список сообщений (от старых к новым)
|
||||
/// * `Err(String)` - Ошибка загрузки
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// // Загрузить достаточно сообщений для экрана высотой 30 строк
|
||||
/// let messages = msg_manager.get_chat_history(chat_id, 30).await?;
|
||||
/// ```
|
||||
pub async fn get_chat_history(
|
||||
&mut self,
|
||||
chat_id: ChatId,
|
||||
limit: i32,
|
||||
) -> Result<Vec<MessageInfo>, String> {
|
||||
// ВАЖНО: Сначала открываем чат в TDLib
|
||||
// Это сообщает TDLib что пользователь открыл чат и нужно загрузить историю
|
||||
let _ = functions::open_chat(chat_id.as_i64(), self.client_id).await;
|
||||
|
||||
// Открываем чат - TDLib начнет синхронизацию автоматически
|
||||
|
||||
// НЕ устанавливаем current_chat_id здесь!
|
||||
// Он будет установлен снаружи ПОСЛЕ сохранения истории
|
||||
// Это предотвращает race condition с Update::NewMessage
|
||||
|
||||
let mut all_messages = Vec::new();
|
||||
let mut from_message_id = 0i64; // 0 = начинаем с последних сообщений
|
||||
let max_attempts_per_chunk = 20; // Максимум попыток на чанк
|
||||
let mut consecutive_empty_results = 0; // Счетчик пустых результатов подряд
|
||||
|
||||
// Загружаем чанками по TDLIB_MESSAGE_LIMIT пока не достигнем limit
|
||||
while (all_messages.len() as i32) < limit {
|
||||
let remaining = limit - (all_messages.len() as i32);
|
||||
let chunk_size = std::cmp::min(TDLIB_MESSAGE_LIMIT, remaining);
|
||||
|
||||
let mut chunk_loaded = false;
|
||||
|
||||
// Пробуем загрузить чанк (TDLib подгружает с сервера по мере готовности)
|
||||
for attempt in 1..=max_attempts_per_chunk {
|
||||
let result = functions::get_chat_history(
|
||||
chat_id.as_i64(),
|
||||
from_message_id,
|
||||
0, // offset
|
||||
chunk_size,
|
||||
false, // only_local - false means can fetch from server
|
||||
self.client_id,
|
||||
)
|
||||
.await;
|
||||
|
||||
let messages_obj = match result {
|
||||
Ok(tdlib_rs::enums::Messages::Messages(obj)) => obj,
|
||||
Err(e) => {
|
||||
// При первой загрузке (from_message_id == 0) возвращаем ошибку
|
||||
// При последующих чанках - прерываем цикл (возможно кончились сообщения)
|
||||
if all_messages.is_empty() {
|
||||
return Err(format!("Ошибка загрузки истории: {:?}", e));
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let received_count = messages_obj.messages.len();
|
||||
|
||||
// Если получили пустой результат
|
||||
if messages_obj.messages.is_empty() {
|
||||
consecutive_empty_results += 1;
|
||||
// Если несколько раз подряд пусто - прерываем
|
||||
if consecutive_empty_results >= 3 {
|
||||
break;
|
||||
}
|
||||
// Пробуем еще раз
|
||||
continue;
|
||||
}
|
||||
|
||||
// Получили сообщения - сбрасываем счетчик
|
||||
consecutive_empty_results = 0;
|
||||
|
||||
// Если это первая загрузка и получили мало сообщений - продолжаем попытки
|
||||
// TDLib может подгружать данные с сервера постепенно
|
||||
if all_messages.is_empty()
|
||||
&& received_count < (chunk_size as usize)
|
||||
&& attempt < max_attempts_per_chunk
|
||||
{
|
||||
// Даём TDLib время на синхронизацию с сервером
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Конвертируем сообщения (от новых к старым, потом реверсим)
|
||||
let mut chunk_messages = Vec::new();
|
||||
for msg in messages_obj.messages.iter().flatten() {
|
||||
if let Some(info) = self.convert_message(msg).await {
|
||||
chunk_messages.push(info);
|
||||
}
|
||||
}
|
||||
|
||||
// Реверсим чтобы получить порядок от старых к новым
|
||||
chunk_messages.reverse();
|
||||
|
||||
// Добавляем загруженные сообщения
|
||||
if !chunk_messages.is_empty() {
|
||||
// Для следующей итерации: ID самого старого сообщения из текущего чанка
|
||||
from_message_id = chunk_messages[0].id().as_i64();
|
||||
|
||||
// ВАЖНО: Вставляем чанк В НАЧАЛО списка!
|
||||
// Первый чанк содержит НОВЫЕ сообщения (например 51-100)
|
||||
// Второй чанк содержит СТАРЫЕ сообщения (например 1-50)
|
||||
// Поэтому более старые чанки должны быть в начале списка
|
||||
if all_messages.is_empty() {
|
||||
// Первый чанк - просто добавляем
|
||||
all_messages = chunk_messages;
|
||||
} else {
|
||||
// Последующие чанки - вставляем в начало
|
||||
all_messages.splice(0..0, chunk_messages);
|
||||
}
|
||||
|
||||
chunk_loaded = true;
|
||||
}
|
||||
|
||||
// Если получили меньше чем chunk_size, значит это последний доступный чанк
|
||||
if (messages_obj.messages.len() as i32) < chunk_size {
|
||||
return Ok(all_messages);
|
||||
}
|
||||
|
||||
break; // Чанк успешно загружен
|
||||
}
|
||||
|
||||
// Если чанк не загрузился после всех попыток - прерываем
|
||||
if !chunk_loaded {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(all_messages)
|
||||
}
|
||||
|
||||
/// Загружает более старые сообщения для пагинации.
|
||||
///
|
||||
/// Используется для подгрузки предыдущих сообщений при прокрутке
|
||||
/// истории чата вверх.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `chat_id` - ID чата
|
||||
/// * `from_message_id` - ID сообщения, от которого загружать историю
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok(Vec<MessageInfo>)` - Список старых сообщений (от старых к новым)
|
||||
/// * `Err(String)` - Ошибка загрузки
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// // Загрузить сообщения старше указанного
|
||||
/// let older = msg_manager.load_older_messages(
|
||||
/// chat_id,
|
||||
/// MessageId::new(12345)
|
||||
/// ).await?;
|
||||
/// ```
|
||||
pub async fn load_older_messages(
|
||||
&mut self,
|
||||
chat_id: ChatId,
|
||||
from_message_id: MessageId,
|
||||
) -> Result<Vec<MessageInfo>, String> {
|
||||
let result = functions::get_chat_history(
|
||||
chat_id.as_i64(),
|
||||
from_message_id.as_i64(),
|
||||
0, // offset
|
||||
TDLIB_MESSAGE_LIMIT,
|
||||
false,
|
||||
self.client_id,
|
||||
)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(tdlib_rs::enums::Messages::Messages(messages_obj)) => {
|
||||
let mut messages = Vec::new();
|
||||
for msg in messages_obj.messages.iter().rev().flatten() {
|
||||
if let Some(info) = self.convert_message(msg).await {
|
||||
messages.push(info);
|
||||
}
|
||||
}
|
||||
Ok(messages)
|
||||
}
|
||||
Err(e) => Err(format!("Ошибка загрузки старых сообщений: {:?}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Получает все закрепленные сообщения чата.
|
||||
///
|
||||
/// Выполняет поиск всех сообщений с фильтром "pinned" и возвращает их список.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `chat_id` - ID чата
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok(Vec<MessageInfo>)` - Список закрепленных сообщений (до 100)
|
||||
/// * `Err(String)` - Ошибка загрузки
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// let pinned = msg_manager.get_pinned_messages(chat_id).await?;
|
||||
/// println!("Found {} pinned messages", pinned.len());
|
||||
/// ```
|
||||
pub async fn get_pinned_messages(
|
||||
&mut self,
|
||||
chat_id: ChatId,
|
||||
) -> Result<Vec<MessageInfo>, String> {
|
||||
let result = functions::search_chat_messages(
|
||||
chat_id.as_i64(),
|
||||
String::new(),
|
||||
None,
|
||||
0, // from_message_id
|
||||
0, // offset
|
||||
100, // limit
|
||||
Some(SearchMessagesFilter::Pinned),
|
||||
0, // message_thread_id
|
||||
0, // saved_messages_topic_id
|
||||
self.client_id,
|
||||
)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(tdlib_rs::enums::FoundChatMessages::FoundChatMessages(messages_obj)) => {
|
||||
let mut pinned_messages = Vec::new();
|
||||
for msg in messages_obj.messages.iter().rev() {
|
||||
if let Some(info) = self.convert_message(msg).await {
|
||||
pinned_messages.push(info);
|
||||
}
|
||||
}
|
||||
Ok(pinned_messages)
|
||||
}
|
||||
Err(e) => Err(format!("Ошибка загрузки закреплённых: {:?}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Загружает текущее верхнее закрепленное сообщение.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `chat_id` - ID чата
|
||||
///
|
||||
/// # Note
|
||||
///
|
||||
/// TODO: В tdlib-rs 1.8.29 поле `pinned_message_id` было удалено из `Chat`.
|
||||
/// Нужно использовать `getChatPinnedMessage` или альтернативный способ.
|
||||
/// Временно отключено, возвращает `None`.
|
||||
pub async fn load_current_pinned_message(&mut self, _chat_id: ChatId) {
|
||||
// TODO: В tdlib-rs 1.8.29 поле pinned_message_id было удалено из Chat.
|
||||
// Нужно использовать getChatPinnedMessage или альтернативный способ.
|
||||
// Временно отключено.
|
||||
self.current_pinned_message = None;
|
||||
}
|
||||
|
||||
/// Выполняет поиск сообщений по тексту в указанном чате.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `chat_id` - ID чата для поиска
|
||||
/// * `query` - Текстовый запрос для поиска
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok(Vec<MessageInfo>)` - Найденные сообщения (до 100)
|
||||
/// * `Err(String)` - Ошибка поиска
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// let results = msg_manager.search_messages(chat_id, "hello").await?;
|
||||
/// ```
|
||||
pub async fn search_messages(
|
||||
&self,
|
||||
chat_id: ChatId,
|
||||
query: &str,
|
||||
) -> Result<Vec<MessageInfo>, String> {
|
||||
let result = functions::search_chat_messages(
|
||||
chat_id.as_i64(),
|
||||
query.to_string(),
|
||||
None,
|
||||
0, // from_message_id
|
||||
0, // offset
|
||||
100, // limit
|
||||
None,
|
||||
0, // message_thread_id
|
||||
0, // saved_messages_topic_id
|
||||
self.client_id,
|
||||
)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(tdlib_rs::enums::FoundChatMessages::FoundChatMessages(messages_obj)) => {
|
||||
let mut search_results = Vec::new();
|
||||
for msg in messages_obj.messages.iter().rev() {
|
||||
if let Some(info) = self.convert_message(msg).await {
|
||||
search_results.push(info);
|
||||
}
|
||||
}
|
||||
Ok(search_results)
|
||||
}
|
||||
Err(e) => Err(format!("Ошибка поиска: {:?}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Отправляет текстовое сообщение в чат с поддержкой Markdown.
|
||||
///
|
||||
/// Автоматически парсит Markdown v2 форматирование (**bold**, *italic*, `code` и т.д.).
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `chat_id` - ID чата-получателя
|
||||
/// * `text` - Текст сообщения (поддерживает Markdown v2)
|
||||
/// * `reply_to_message_id` - Опциональный ID сообщения для ответа
|
||||
/// * `reply_info` - Опциональная информация об исходном сообщении
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok(MessageInfo)` - Отправленное сообщение
|
||||
/// * `Err(String)` - Ошибка отправки
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// // Простое сообщение
|
||||
/// let msg = msg_manager.send_message(
|
||||
/// chat_id,
|
||||
/// "Hello, **world**!".to_string(),
|
||||
/// None,
|
||||
/// None
|
||||
/// ).await?;
|
||||
///
|
||||
/// // Ответ на сообщение
|
||||
/// let reply = msg_manager.send_message(
|
||||
/// chat_id,
|
||||
/// "Got it!".to_string(),
|
||||
/// Some(MessageId::new(123)),
|
||||
/// Some(reply_info)
|
||||
/// ).await?;
|
||||
/// ```
|
||||
pub async fn send_message(
|
||||
&self,
|
||||
chat_id: ChatId,
|
||||
text: String,
|
||||
reply_to_message_id: Option<MessageId>,
|
||||
reply_info: Option<ReplyInfo>,
|
||||
) -> Result<MessageInfo, String> {
|
||||
// Парсим markdown в тексте
|
||||
let formatted_text = match functions::parse_text_entities(
|
||||
text.clone(),
|
||||
TextParseMode::Markdown(TextParseModeMarkdown { version: 2 }),
|
||||
self.client_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(tdlib_rs::enums::FormattedText::FormattedText(ft)) => {
|
||||
FormattedText { text: ft.text, entities: ft.entities }
|
||||
}
|
||||
Err(_) => FormattedText { text: text.clone(), entities: vec![] },
|
||||
};
|
||||
|
||||
let content = InputMessageContent::InputMessageText(InputMessageText {
|
||||
text: formatted_text,
|
||||
link_preview_options: None,
|
||||
clear_draft: true,
|
||||
});
|
||||
|
||||
let reply_to = reply_to_message_id.map(|msg_id| {
|
||||
InputMessageReplyTo::Message(InputMessageReplyToMessage {
|
||||
chat_id: 0,
|
||||
message_id: msg_id.as_i64(),
|
||||
quote: None,
|
||||
})
|
||||
});
|
||||
|
||||
let result = functions::send_message(
|
||||
chat_id.as_i64(),
|
||||
0, // message_thread_id
|
||||
reply_to,
|
||||
None, // options
|
||||
content,
|
||||
self.client_id,
|
||||
)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(tdlib_rs::enums::Message::Message(msg)) => {
|
||||
let mut msg_info = self
|
||||
.convert_message(&msg)
|
||||
.await
|
||||
.ok_or_else(|| "Не удалось конвертировать сообщение".to_string())?;
|
||||
|
||||
// Добавляем reply_info если был передан
|
||||
if let Some(reply) = reply_info {
|
||||
msg_info.interactions.reply_to = Some(reply);
|
||||
}
|
||||
|
||||
Ok(msg_info)
|
||||
}
|
||||
Err(e) => Err(format!("Ошибка отправки сообщения: {:?}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Редактирует существующее сообщение.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `chat_id` - ID чата
|
||||
/// * `message_id` - ID сообщения для редактирования
|
||||
/// * `text` - Новый текст (поддерживает Markdown v2)
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok(MessageInfo)` - Отредактированное сообщение
|
||||
/// * `Err(String)` - Ошибка (нет прав, сообщение слишком старое и т.д.)
|
||||
pub async fn edit_message(
|
||||
&self,
|
||||
chat_id: ChatId,
|
||||
message_id: MessageId,
|
||||
text: String,
|
||||
) -> Result<MessageInfo, String> {
|
||||
let formatted_text = match functions::parse_text_entities(
|
||||
text.clone(),
|
||||
TextParseMode::Markdown(TextParseModeMarkdown { version: 2 }),
|
||||
self.client_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(tdlib_rs::enums::FormattedText::FormattedText(ft)) => {
|
||||
FormattedText { text: ft.text, entities: ft.entities }
|
||||
}
|
||||
Err(_) => FormattedText { text: text.clone(), entities: vec![] },
|
||||
};
|
||||
|
||||
let content = InputMessageContent::InputMessageText(InputMessageText {
|
||||
text: formatted_text,
|
||||
link_preview_options: None,
|
||||
clear_draft: true,
|
||||
});
|
||||
|
||||
let result = functions::edit_message_text(
|
||||
chat_id.as_i64(),
|
||||
message_id.as_i64(),
|
||||
content,
|
||||
self.client_id,
|
||||
)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(tdlib_rs::enums::Message::Message(msg)) => self
|
||||
.convert_message(&msg)
|
||||
.await
|
||||
.ok_or_else(|| "Не удалось конвертировать отредактированное сообщение".to_string()),
|
||||
Err(e) => Err(format!("Ошибка редактирования: {:?}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Удаляет одно или несколько сообщений.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `chat_id` - ID чата
|
||||
/// * `message_ids` - Список ID сообщений для удаления
|
||||
/// * `revoke` - `true` - удалить для всех, `false` - только для себя
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok(())` - Сообщения удалены
|
||||
/// * `Err(String)` - Ошибка удаления
|
||||
pub async fn delete_messages(
|
||||
&self,
|
||||
chat_id: ChatId,
|
||||
message_ids: Vec<MessageId>,
|
||||
revoke: bool,
|
||||
) -> Result<(), String> {
|
||||
let message_ids_i64: Vec<i64> = message_ids.into_iter().map(|id| id.as_i64()).collect();
|
||||
let result =
|
||||
functions::delete_messages(chat_id.as_i64(), message_ids_i64, revoke, self.client_id)
|
||||
.await;
|
||||
match result {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) => Err(format!("Ошибка удаления: {:?}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Пересылает сообщения из одного чата в другой.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `to_chat_id` - ID чата-получателя
|
||||
/// * `from_chat_id` - ID чата-источника
|
||||
/// * `message_ids` - Список ID сообщений для пересылки
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok(())` - Сообщения переслань
|
||||
/// * `Err(String)` - Ошибка пересылки
|
||||
pub async fn forward_messages(
|
||||
&self,
|
||||
to_chat_id: ChatId,
|
||||
from_chat_id: ChatId,
|
||||
message_ids: Vec<MessageId>,
|
||||
) -> Result<(), String> {
|
||||
let message_ids_i64: Vec<i64> = message_ids.into_iter().map(|id| id.as_i64()).collect();
|
||||
let result = functions::forward_messages(
|
||||
to_chat_id.as_i64(),
|
||||
0, // message_thread_id
|
||||
from_chat_id.as_i64(),
|
||||
message_ids_i64,
|
||||
None, // options
|
||||
false, // send_copy
|
||||
false, // remove_caption
|
||||
self.client_id,
|
||||
)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) => Err(format!("Ошибка пересылки: {:?}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Сохраняет черновик сообщения для чата.
|
||||
///
|
||||
/// Черновик отображается в списке чатов и восстанавливается
|
||||
/// при следующем открытии чата.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `chat_id` - ID чата
|
||||
/// * `text` - Текст черновика (пустая строка удаляет черновик)
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok(())` - Черновик сохранен
|
||||
/// * `Err(String)` - Ошибка сохранения
|
||||
pub async fn set_draft_message(&self, chat_id: ChatId, text: String) -> Result<(), String> {
|
||||
use tdlib_rs::types::DraftMessage;
|
||||
|
||||
let draft = if text.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(DraftMessage {
|
||||
reply_to: None,
|
||||
date: 0,
|
||||
input_message_text: InputMessageContent::InputMessageText(InputMessageText {
|
||||
text: FormattedText { text: text.clone(), entities: vec![] },
|
||||
link_preview_options: None,
|
||||
clear_draft: false,
|
||||
}),
|
||||
})
|
||||
};
|
||||
|
||||
let result =
|
||||
functions::set_chat_draft_message(chat_id.as_i64(), 0, draft, self.client_id).await;
|
||||
|
||||
match result {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) => Err(format!("Ошибка сохранения черновика: {:?}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Обрабатывает очередь сообщений для отметки как прочитанных.
|
||||
///
|
||||
/// Автоматически отмечает просмотренные сообщения как прочитанные,
|
||||
/// что сбрасывает счетчик непрочитанных сообщений в чате.
|
||||
///
|
||||
/// # Note
|
||||
///
|
||||
/// Вызывайте периодически (например, в основном цикле) для обработки накопленной очереди.
|
||||
pub async fn process_pending_view_messages(&mut self) {
|
||||
if self.pending_view_messages.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let batch = std::mem::take(&mut self.pending_view_messages);
|
||||
|
||||
for (chat_id, message_ids) in batch {
|
||||
let ids: Vec<i64> = message_ids.iter().map(|id| id.as_i64()).collect();
|
||||
let _ =
|
||||
functions::view_messages(chat_id.as_i64(), ids, None, true, self.client_id).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,32 @@
|
||||
// Модули
|
||||
pub mod auth;
|
||||
mod chat_helpers; // Chat management helpers
|
||||
pub mod chats;
|
||||
pub mod client;
|
||||
mod client_impl; // Private module for trait implementation
|
||||
mod message_conversion; // Message conversion utilities (for messages.rs)
|
||||
mod message_converter; // Message conversion utilities (for client.rs)
|
||||
pub mod messages;
|
||||
pub mod reactions;
|
||||
pub mod r#trait;
|
||||
pub mod types;
|
||||
mod update_handlers; // Update handlers extracted from client
|
||||
pub mod users;
|
||||
|
||||
// Экспорт основных типов
|
||||
pub use auth::AuthState;
|
||||
pub use client::TdClient;
|
||||
pub use r#trait::TdClientTrait;
|
||||
#[allow(unused_imports)]
|
||||
pub use types::{
|
||||
ChatInfo, FolderInfo, ForwardInfo, MessageBuilder, MessageInfo, NetworkState, ProfileInfo,
|
||||
ReactionInfo, ReplyInfo, UserOnlineStatus,
|
||||
ChatInfo, FolderInfo, MediaInfo, MessageBuilder, MessageInfo, NetworkState, PhotoDownloadState,
|
||||
PhotoInfo, PlaybackState, PlaybackStatus, ProfileInfo, ReplyInfo, UserOnlineStatus,
|
||||
VoiceDownloadState, VoiceInfo,
|
||||
};
|
||||
|
||||
#[cfg(feature = "images")]
|
||||
pub use types::ImageModalState;
|
||||
pub use users::UserCache;
|
||||
|
||||
// Re-export ChatAction для удобства
|
||||
pub use tdlib_rs::enums::ChatAction;
|
||||
|
||||
@@ -69,8 +69,9 @@ impl ReactionManager {
|
||||
message_id: MessageId,
|
||||
) -> Result<Vec<String>, String> {
|
||||
// Получаем сообщение
|
||||
let msg_result = functions::get_message(chat_id.as_i64(), message_id.as_i64(), self.client_id).await;
|
||||
let msg = match msg_result {
|
||||
let msg_result =
|
||||
functions::get_message(chat_id.as_i64(), message_id.as_i64(), self.client_id).await;
|
||||
let _msg = match msg_result {
|
||||
Ok(m) => m,
|
||||
Err(e) => return Err(format!("Ошибка получения сообщения: {:?}", e)),
|
||||
};
|
||||
|
||||
153
src/tdlib/trait.rs
Normal file
153
src/tdlib/trait.rs
Normal file
@@ -0,0 +1,153 @@
|
||||
//! Trait definition for TdClient to enable dependency injection
|
||||
//!
|
||||
//! This trait allows tests to use FakeTdClient instead of real TDLib client.
|
||||
|
||||
use crate::tdlib::{AuthState, FolderInfo, MessageInfo, ProfileInfo, UserCache, UserOnlineStatus};
|
||||
use crate::types::{ChatId, MessageId, UserId};
|
||||
use async_trait::async_trait;
|
||||
use std::path::PathBuf;
|
||||
use tdlib_rs::enums::{ChatAction, Update};
|
||||
|
||||
use super::ChatInfo;
|
||||
|
||||
/// Trait for TDLib client operations
|
||||
///
|
||||
/// This trait defines the interface for both real and fake TDLib clients,
|
||||
/// enabling dependency injection and easier testing.
|
||||
#[allow(dead_code)]
|
||||
#[async_trait]
|
||||
pub trait TdClientTrait: Send {
|
||||
// ============ Auth methods ============
|
||||
async fn send_phone_number(&self, phone: String) -> Result<(), String>;
|
||||
async fn send_code(&self, code: String) -> Result<(), String>;
|
||||
async fn send_password(&self, password: String) -> Result<(), String>;
|
||||
|
||||
// ============ Chat methods ============
|
||||
async fn load_chats(&mut self, limit: i32) -> Result<(), String>;
|
||||
async fn load_folder_chats(&mut self, folder_id: i32, limit: i32) -> Result<(), String>;
|
||||
async fn leave_chat(&self, chat_id: ChatId) -> Result<(), String>;
|
||||
async fn get_profile_info(&self, chat_id: ChatId) -> Result<ProfileInfo, String>;
|
||||
|
||||
// ============ Chat actions ============
|
||||
async fn send_chat_action(&self, chat_id: ChatId, action: ChatAction);
|
||||
fn clear_stale_typing_status(&mut self) -> bool;
|
||||
|
||||
// ============ Message methods ============
|
||||
async fn get_chat_history(
|
||||
&mut self,
|
||||
chat_id: ChatId,
|
||||
limit: i32,
|
||||
) -> Result<Vec<MessageInfo>, String>;
|
||||
async fn load_older_messages(
|
||||
&mut self,
|
||||
chat_id: ChatId,
|
||||
from_message_id: MessageId,
|
||||
) -> Result<Vec<MessageInfo>, String>;
|
||||
async fn get_pinned_messages(&mut self, chat_id: ChatId) -> Result<Vec<MessageInfo>, String>;
|
||||
async fn load_current_pinned_message(&mut self, chat_id: ChatId);
|
||||
async fn search_messages(
|
||||
&self,
|
||||
chat_id: ChatId,
|
||||
query: &str,
|
||||
) -> Result<Vec<MessageInfo>, String>;
|
||||
|
||||
async fn send_message(
|
||||
&mut self,
|
||||
chat_id: ChatId,
|
||||
text: String,
|
||||
reply_to_message_id: Option<MessageId>,
|
||||
reply_info: Option<super::ReplyInfo>,
|
||||
) -> Result<MessageInfo, String>;
|
||||
|
||||
async fn edit_message(
|
||||
&mut self,
|
||||
chat_id: ChatId,
|
||||
message_id: MessageId,
|
||||
new_text: String,
|
||||
) -> Result<MessageInfo, String>;
|
||||
|
||||
async fn delete_messages(
|
||||
&mut self,
|
||||
chat_id: ChatId,
|
||||
message_ids: Vec<MessageId>,
|
||||
revoke: bool,
|
||||
) -> Result<(), String>;
|
||||
|
||||
async fn forward_messages(
|
||||
&mut self,
|
||||
to_chat_id: ChatId,
|
||||
from_chat_id: ChatId,
|
||||
message_ids: Vec<MessageId>,
|
||||
) -> Result<(), String>;
|
||||
|
||||
async fn set_draft_message(&self, chat_id: ChatId, text: String) -> Result<(), String>;
|
||||
|
||||
fn push_message(&mut self, msg: MessageInfo);
|
||||
async fn fetch_missing_reply_info(&mut self);
|
||||
async fn process_pending_view_messages(&mut self);
|
||||
|
||||
// ============ User methods ============
|
||||
fn get_user_status_by_chat_id(&self, chat_id: ChatId) -> Option<&UserOnlineStatus>;
|
||||
async fn process_pending_user_ids(&mut self);
|
||||
|
||||
// ============ Reaction methods ============
|
||||
async fn get_message_available_reactions(
|
||||
&self,
|
||||
chat_id: ChatId,
|
||||
message_id: MessageId,
|
||||
) -> Result<Vec<String>, String>;
|
||||
|
||||
async fn toggle_reaction(
|
||||
&self,
|
||||
chat_id: ChatId,
|
||||
message_id: MessageId,
|
||||
reaction: String,
|
||||
) -> Result<(), String>;
|
||||
|
||||
// ============ File methods ============
|
||||
async fn download_file(&self, file_id: i32) -> Result<String, String>;
|
||||
async fn download_voice_note(&self, file_id: i32) -> Result<String, String>;
|
||||
|
||||
// ============ Getters (immutable) ============
|
||||
fn client_id(&self) -> i32;
|
||||
async fn get_me(&self) -> Result<i64, String>;
|
||||
fn auth_state(&self) -> &AuthState;
|
||||
fn chats(&self) -> &[ChatInfo];
|
||||
fn folders(&self) -> &[FolderInfo];
|
||||
fn current_chat_messages(&self) -> Vec<MessageInfo>;
|
||||
fn current_chat_id(&self) -> Option<ChatId>;
|
||||
fn current_pinned_message(&self) -> Option<MessageInfo>;
|
||||
fn typing_status(&self) -> Option<&(UserId, String, std::time::Instant)>;
|
||||
fn pending_view_messages(&self) -> &[(ChatId, Vec<MessageId>)];
|
||||
fn pending_user_ids(&self) -> &[UserId];
|
||||
fn main_chat_list_position(&self) -> i32;
|
||||
fn user_cache(&self) -> &UserCache;
|
||||
fn network_state(&self) -> super::types::NetworkState;
|
||||
|
||||
// ============ Setters (mutable) ============
|
||||
fn chats_mut(&mut self) -> &mut Vec<ChatInfo>;
|
||||
fn folders_mut(&mut self) -> &mut Vec<FolderInfo>;
|
||||
fn current_chat_messages_mut(&mut self) -> &mut Vec<MessageInfo>;
|
||||
fn clear_current_chat_messages(&mut self);
|
||||
fn set_current_chat_messages(&mut self, messages: Vec<MessageInfo>);
|
||||
fn set_current_chat_id(&mut self, chat_id: Option<ChatId>);
|
||||
fn set_current_pinned_message(&mut self, msg: Option<MessageInfo>);
|
||||
fn set_typing_status(&mut self, status: Option<(UserId, String, std::time::Instant)>);
|
||||
fn pending_view_messages_mut(&mut self) -> &mut Vec<(ChatId, Vec<MessageId>)>;
|
||||
fn pending_user_ids_mut(&mut self) -> &mut Vec<UserId>;
|
||||
fn set_main_chat_list_position(&mut self, position: i32);
|
||||
fn user_cache_mut(&mut self) -> &mut UserCache;
|
||||
|
||||
// ============ Notification methods ============
|
||||
fn sync_notification_muted_chats(&mut self);
|
||||
|
||||
// ============ Account switching ============
|
||||
/// Recreates the client with a new database path (for account switching).
|
||||
///
|
||||
/// For real TdClient: closes old client, creates new one, inits TDLib parameters.
|
||||
/// For FakeTdClient: no-op.
|
||||
async fn recreate_client(&mut self, db_path: PathBuf) -> Result<(), String>;
|
||||
|
||||
// ============ Update handling ============
|
||||
fn handle_update(&mut self, update: Update);
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
use tdlib_rs::enums::TextEntityType;
|
||||
use tdlib_rs::types::TextEntity;
|
||||
|
||||
use crate::types::{ChatId, MessageId};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)]
|
||||
pub struct ChatInfo {
|
||||
pub id: ChatId,
|
||||
pub title: String,
|
||||
@@ -41,9 +41,6 @@ pub struct ReplyInfo {
|
||||
pub struct ForwardInfo {
|
||||
/// Имя оригинального отправителя
|
||||
pub sender_name: String,
|
||||
/// Дата оригинального сообщения (для будущего использования)
|
||||
#[allow(dead_code)]
|
||||
pub date: i32,
|
||||
}
|
||||
|
||||
/// Информация о реакции на сообщение
|
||||
@@ -57,6 +54,54 @@ pub struct ReactionInfo {
|
||||
pub is_chosen: bool,
|
||||
}
|
||||
|
||||
/// Информация о медиа-контенте сообщения
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum MediaInfo {
|
||||
Photo(PhotoInfo),
|
||||
Voice(VoiceInfo),
|
||||
}
|
||||
|
||||
/// Информация о фотографии в сообщении
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PhotoInfo {
|
||||
pub file_id: i32,
|
||||
pub width: i32,
|
||||
pub height: i32,
|
||||
pub download_state: PhotoDownloadState,
|
||||
}
|
||||
|
||||
/// Состояние загрузки фотографии
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum PhotoDownloadState {
|
||||
NotDownloaded,
|
||||
Downloading,
|
||||
Downloaded(String),
|
||||
Error(String),
|
||||
}
|
||||
|
||||
/// Информация о голосовом сообщении
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VoiceInfo {
|
||||
pub file_id: i32,
|
||||
pub duration: i32, // seconds
|
||||
pub mime_type: String,
|
||||
/// Waveform данные для визуализации (base64-encoded строка амплитуд)
|
||||
pub waveform: String,
|
||||
pub download_state: VoiceDownloadState,
|
||||
}
|
||||
|
||||
/// Состояние загрузки голосового сообщения
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum VoiceDownloadState {
|
||||
NotDownloaded,
|
||||
Downloading,
|
||||
Downloaded(String), // path to cached OGG file
|
||||
Error(String),
|
||||
}
|
||||
|
||||
/// Метаданные сообщения (ID, отправитель, время)
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MessageMetadata {
|
||||
@@ -65,14 +110,18 @@ pub struct MessageMetadata {
|
||||
pub date: i32,
|
||||
/// Дата редактирования (0 если не редактировалось)
|
||||
pub edit_date: i32,
|
||||
/// ID медиа-альбома (0 если не часть альбома)
|
||||
pub media_album_id: i64,
|
||||
}
|
||||
|
||||
/// Контент сообщения (текст и форматирование)
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MessageContent {
|
||||
pub text: String,
|
||||
/// Сущности форматирования (bold, italic, code и т.д.)
|
||||
pub entities: Vec<TextEntity>,
|
||||
/// Медиа-контент (фото, видео и т.д.)
|
||||
pub media: Option<MediaInfo>,
|
||||
}
|
||||
|
||||
/// Состояние и права доступа к сообщению
|
||||
@@ -109,6 +158,7 @@ pub struct MessageInfo {
|
||||
|
||||
impl MessageInfo {
|
||||
/// Создать новое сообщение
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
id: MessageId,
|
||||
sender_name: String,
|
||||
@@ -131,11 +181,9 @@ impl MessageInfo {
|
||||
sender_name,
|
||||
date,
|
||||
edit_date,
|
||||
media_album_id: 0,
|
||||
},
|
||||
content: MessageContent {
|
||||
text: content,
|
||||
entities,
|
||||
},
|
||||
content: MessageContent { text: content, entities, media: None },
|
||||
state: MessageState {
|
||||
is_outgoing,
|
||||
is_read,
|
||||
@@ -143,11 +191,7 @@ impl MessageInfo {
|
||||
can_be_deleted_only_for_self,
|
||||
can_be_deleted_for_all_users,
|
||||
},
|
||||
interactions: MessageInteractions {
|
||||
reply_to,
|
||||
forward_from,
|
||||
reactions,
|
||||
},
|
||||
interactions: MessageInteractions { reply_to, forward_from, reactions },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,14 +208,14 @@ impl MessageInfo {
|
||||
self.metadata.date
|
||||
}
|
||||
|
||||
pub fn edit_date(&self) -> i32 {
|
||||
self.metadata.edit_date
|
||||
}
|
||||
|
||||
pub fn is_edited(&self) -> bool {
|
||||
self.metadata.edit_date > 0
|
||||
}
|
||||
|
||||
pub fn media_album_id(&self) -> i64 {
|
||||
self.metadata.media_album_id
|
||||
}
|
||||
|
||||
pub fn text(&self) -> &str {
|
||||
&self.content.text
|
||||
}
|
||||
@@ -200,6 +244,56 @@ impl MessageInfo {
|
||||
self.state.can_be_deleted_for_all_users
|
||||
}
|
||||
|
||||
/// Checks if the message contains a mention (@username or user mention)
|
||||
pub fn has_mention(&self) -> bool {
|
||||
self.content.entities.iter().any(|entity| {
|
||||
matches!(entity.r#type, TextEntityType::Mention | TextEntityType::MentionName(_))
|
||||
})
|
||||
}
|
||||
|
||||
/// Проверяет, содержит ли сообщение фото
|
||||
pub fn has_photo(&self) -> bool {
|
||||
matches!(self.content.media, Some(MediaInfo::Photo(_)))
|
||||
}
|
||||
|
||||
/// Возвращает ссылку на PhotoInfo (если есть)
|
||||
pub fn photo_info(&self) -> Option<&PhotoInfo> {
|
||||
match &self.content.media {
|
||||
Some(MediaInfo::Photo(info)) => Some(info),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Возвращает мутабельную ссылку на PhotoInfo (если есть)
|
||||
pub fn photo_info_mut(&mut self) -> Option<&mut PhotoInfo> {
|
||||
match &mut self.content.media {
|
||||
Some(MediaInfo::Photo(info)) => Some(info),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Проверяет, содержит ли сообщение голосовое
|
||||
pub fn has_voice(&self) -> bool {
|
||||
matches!(self.content.media, Some(MediaInfo::Voice(_)))
|
||||
}
|
||||
|
||||
/// Возвращает ссылку на VoiceInfo (если есть)
|
||||
pub fn voice_info(&self) -> Option<&VoiceInfo> {
|
||||
match &self.content.media {
|
||||
Some(MediaInfo::Voice(info)) => Some(info),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Возвращает мутабельную ссылку на VoiceInfo (если есть)
|
||||
#[allow(dead_code)]
|
||||
pub fn voice_info_mut(&mut self) -> Option<&mut VoiceInfo> {
|
||||
match &mut self.content.media {
|
||||
Some(MediaInfo::Voice(info)) => Some(info),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reply_to(&self) -> Option<&ReplyInfo> {
|
||||
self.interactions.reply_to.as_ref()
|
||||
}
|
||||
@@ -214,13 +308,13 @@ impl MessageInfo {
|
||||
}
|
||||
|
||||
/// Builder для удобного создания MessageInfo с fluent API
|
||||
///
|
||||
///
|
||||
/// # Примеры
|
||||
///
|
||||
///
|
||||
/// ```
|
||||
/// use tele_tui::tdlib::MessageBuilder;
|
||||
/// use tele_tui::types::MessageId;
|
||||
///
|
||||
///
|
||||
/// let message = MessageBuilder::new(MessageId::new(123))
|
||||
/// .sender_name("Alice")
|
||||
/// .text("Hello, world!")
|
||||
@@ -243,6 +337,8 @@ pub struct MessageBuilder {
|
||||
reply_to: Option<ReplyInfo>,
|
||||
forward_from: Option<ForwardInfo>,
|
||||
reactions: Vec<ReactionInfo>,
|
||||
media: Option<MediaInfo>,
|
||||
media_album_id: i64,
|
||||
}
|
||||
|
||||
impl MessageBuilder {
|
||||
@@ -263,6 +359,8 @@ impl MessageBuilder {
|
||||
reply_to: None,
|
||||
forward_from: None,
|
||||
reactions: Vec::new(),
|
||||
media: None,
|
||||
media_album_id: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -312,12 +410,6 @@ impl MessageBuilder {
|
||||
self
|
||||
}
|
||||
|
||||
/// Пометить сообщение как отредактированное (edit_date = date + 60)
|
||||
pub fn edited(mut self) -> Self {
|
||||
self.edit_date = self.date + 60;
|
||||
self
|
||||
}
|
||||
|
||||
/// Пометить сообщение как прочитанное
|
||||
pub fn read(mut self) -> Self {
|
||||
self.is_read = true;
|
||||
@@ -366,15 +458,21 @@ impl MessageBuilder {
|
||||
self
|
||||
}
|
||||
|
||||
/// Добавить одну реакцию
|
||||
pub fn add_reaction(mut self, reaction: ReactionInfo) -> Self {
|
||||
self.reactions.push(reaction);
|
||||
/// Установить медиа-контент
|
||||
pub fn media(mut self, media: MediaInfo) -> Self {
|
||||
self.media = Some(media);
|
||||
self
|
||||
}
|
||||
|
||||
/// Установить ID медиа-альбома
|
||||
pub fn media_album_id(mut self, id: i64) -> Self {
|
||||
self.media_album_id = id;
|
||||
self
|
||||
}
|
||||
|
||||
/// Построить MessageInfo из данных builder'а
|
||||
pub fn build(self) -> MessageInfo {
|
||||
MessageInfo::new(
|
||||
let mut msg = MessageInfo::new(
|
||||
self.id,
|
||||
self.sender_name,
|
||||
self.is_outgoing,
|
||||
@@ -389,11 +487,13 @@ impl MessageBuilder {
|
||||
self.reply_to,
|
||||
self.forward_from,
|
||||
self.reactions,
|
||||
)
|
||||
);
|
||||
msg.content.media = self.media;
|
||||
msg.metadata.media_album_id = self.media_album_id;
|
||||
msg
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -434,11 +534,11 @@ mod tests {
|
||||
let message = MessageBuilder::new(MessageId::new(789))
|
||||
.text("Original text")
|
||||
.date(1640000000)
|
||||
.edited()
|
||||
.edit_date(1640000060)
|
||||
.build();
|
||||
|
||||
assert!(message.is_edited());
|
||||
assert_eq!(message.edit_date(), 1640000060);
|
||||
assert_eq!(message.metadata.edit_date, 1640000060);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -461,14 +561,12 @@ mod tests {
|
||||
#[test]
|
||||
fn test_message_builder_with_reactions() {
|
||||
let reaction = ReactionInfo {
|
||||
emoji: "👍".to_string(),
|
||||
count: 5,
|
||||
is_chosen: true,
|
||||
emoji: "👍".to_string(), count: 5, is_chosen: true
|
||||
};
|
||||
|
||||
let message = MessageBuilder::new(MessageId::new(300))
|
||||
.text("Cool message")
|
||||
.add_reaction(reaction.clone())
|
||||
.reactions(vec![reaction.clone()])
|
||||
.build();
|
||||
|
||||
assert_eq!(message.reactions().len(), 1);
|
||||
@@ -495,6 +593,39 @@ mod tests {
|
||||
assert!(message.can_be_edited());
|
||||
assert!(message.can_be_deleted_for_all_users());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_message_has_mention() {
|
||||
// Message without mentions
|
||||
let message = MessageBuilder::new(MessageId::new(1))
|
||||
.text("Hello world")
|
||||
.build();
|
||||
assert!(!message.has_mention());
|
||||
|
||||
// Message with @mention
|
||||
let message_with_mention = MessageBuilder::new(MessageId::new(2))
|
||||
.text("Hello @user")
|
||||
.entities(vec![TextEntity {
|
||||
offset: 6,
|
||||
length: 5,
|
||||
r#type: TextEntityType::Mention,
|
||||
}])
|
||||
.build();
|
||||
assert!(message_with_mention.has_mention());
|
||||
|
||||
// Message with MentionName
|
||||
let message_with_mention_name = MessageBuilder::new(MessageId::new(3))
|
||||
.text("Hello John")
|
||||
.entities(vec![TextEntity {
|
||||
offset: 6,
|
||||
length: 4,
|
||||
r#type: TextEntityType::MentionName(tdlib_rs::types::TextEntityTypeMentionName {
|
||||
user_id: 123,
|
||||
}),
|
||||
}])
|
||||
.build();
|
||||
assert!(message_with_mention_name.has_mention());
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -550,3 +681,44 @@ pub enum UserOnlineStatus {
|
||||
/// Оффлайн с указанием времени (unix timestamp)
|
||||
Offline(i32),
|
||||
}
|
||||
|
||||
/// Состояние модального окна для просмотра изображения
|
||||
#[cfg(feature = "images")]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ImageModalState {
|
||||
/// ID сообщения с фото
|
||||
pub message_id: MessageId,
|
||||
/// Путь к файлу изображения
|
||||
pub photo_path: String,
|
||||
/// Ширина оригинального изображения
|
||||
pub photo_width: i32,
|
||||
/// Высота оригинального изображения
|
||||
pub photo_height: i32,
|
||||
}
|
||||
|
||||
/// Состояние воспроизведения голосового сообщения
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PlaybackState {
|
||||
/// ID сообщения, которое воспроизводится
|
||||
pub message_id: MessageId,
|
||||
/// Статус воспроизведения
|
||||
pub status: PlaybackStatus,
|
||||
/// Текущая позиция (секунды)
|
||||
pub position: f32,
|
||||
/// Общая длительность (секунды)
|
||||
pub duration: f32,
|
||||
/// Громкость (0.0 - 1.0)
|
||||
pub volume: f32,
|
||||
}
|
||||
|
||||
/// Статус воспроизведения
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum PlaybackStatus {
|
||||
Playing,
|
||||
Paused,
|
||||
Stopped,
|
||||
Loading,
|
||||
Error(String),
|
||||
}
|
||||
|
||||
327
src/tdlib/update_handlers.rs
Normal file
327
src/tdlib/update_handlers.rs
Normal file
@@ -0,0 +1,327 @@
|
||||
//! Update handlers for TDLib events.
|
||||
//!
|
||||
//! This module contains functions that process various types of updates from TDLib.
|
||||
//! Each handler is responsible for updating the application state based on the received update.
|
||||
|
||||
use crate::types::{ChatId, MessageId, UserId};
|
||||
use std::time::Instant;
|
||||
use tdlib_rs::enums::{AuthorizationState, ChatAction, ChatList, MessageSender};
|
||||
use tdlib_rs::types::{
|
||||
UpdateChatAction, UpdateChatDraftMessage, UpdateChatPosition, UpdateMessageInteractionInfo,
|
||||
UpdateMessageSendSucceeded, UpdateNewMessage, UpdateUser,
|
||||
};
|
||||
|
||||
use super::auth::AuthState;
|
||||
use super::client::TdClient;
|
||||
use super::types::ReactionInfo;
|
||||
|
||||
/// Обрабатывает Update::NewMessage - добавление нового сообщения
|
||||
pub fn handle_new_message_update(client: &mut TdClient, new_msg: UpdateNewMessage) {
|
||||
let chat_id = ChatId::new(new_msg.message.chat_id);
|
||||
|
||||
// Если сообщение НЕ для текущего открытого чата - отправляем уведомление
|
||||
if Some(chat_id) != client.current_chat_id() {
|
||||
// Find and clone chat info to avoid borrow checker issues
|
||||
if let Some(chat) = client.chats().iter().find(|c| c.id == chat_id).cloned() {
|
||||
let msg_info =
|
||||
crate::tdlib::message_converter::convert_message(client, &new_msg.message, chat_id);
|
||||
|
||||
// Get sender name (from message or user cache)
|
||||
let sender_name = msg_info.sender_name();
|
||||
|
||||
// Send notification
|
||||
let _ = client
|
||||
.notification_manager
|
||||
.notify_new_message(&chat, &msg_info, sender_name);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Добавляем новое сообщение если это текущий открытый чат
|
||||
|
||||
let msg_info =
|
||||
crate::tdlib::message_converter::convert_message(client, &new_msg.message, chat_id);
|
||||
let msg_id = msg_info.id();
|
||||
let is_incoming = !msg_info.is_outgoing();
|
||||
|
||||
// Проверяем, есть ли уже сообщение с таким id
|
||||
let existing_idx = client
|
||||
.current_chat_messages()
|
||||
.iter()
|
||||
.position(|m| m.id() == msg_info.id());
|
||||
|
||||
match existing_idx {
|
||||
Some(idx) => {
|
||||
// Сообщение уже есть - обновляем
|
||||
if is_incoming {
|
||||
client.current_chat_messages_mut()[idx] = msg_info;
|
||||
} else {
|
||||
// Для исходящих: обновляем can_be_edited и другие поля,
|
||||
// но сохраняем reply_to (добавленный при отправке)
|
||||
let existing = &mut client.current_chat_messages_mut()[idx];
|
||||
existing.state.can_be_edited = msg_info.state.can_be_edited;
|
||||
existing.state.can_be_deleted_only_for_self =
|
||||
msg_info.state.can_be_deleted_only_for_self;
|
||||
existing.state.can_be_deleted_for_all_users =
|
||||
msg_info.state.can_be_deleted_for_all_users;
|
||||
existing.state.is_read = msg_info.state.is_read;
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// Нового сообщения нет - добавляем
|
||||
client.push_message(msg_info.clone());
|
||||
// Если это входящее сообщение — добавляем в очередь для отметки как прочитанное
|
||||
if is_incoming {
|
||||
client
|
||||
.pending_view_messages_mut()
|
||||
.push((chat_id, vec![msg_id]));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Обрабатывает Update::ChatAction - статус набора текста/отправки файлов
|
||||
pub fn handle_chat_action_update(client: &mut TdClient, update: UpdateChatAction) {
|
||||
// Обрабатываем только для текущего открытого чата
|
||||
if Some(ChatId::new(update.chat_id)) != client.current_chat_id() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Извлекаем user_id из sender_id
|
||||
let MessageSender::User(user) = update.sender_id else {
|
||||
return; // Игнорируем действия от имени чата
|
||||
};
|
||||
let user_id = UserId::new(user.user_id);
|
||||
|
||||
// Определяем текст действия
|
||||
let action_text = match update.action {
|
||||
ChatAction::Typing => Some("печатает...".to_string()),
|
||||
ChatAction::RecordingVideo => Some("записывает видео...".to_string()),
|
||||
ChatAction::UploadingVideo(_) => Some("отправляет видео...".to_string()),
|
||||
ChatAction::RecordingVoiceNote => Some("записывает голосовое...".to_string()),
|
||||
ChatAction::UploadingVoiceNote(_) => Some("отправляет голосовое...".to_string()),
|
||||
ChatAction::UploadingPhoto(_) => Some("отправляет фото...".to_string()),
|
||||
ChatAction::UploadingDocument(_) => Some("отправляет файл...".to_string()),
|
||||
ChatAction::ChoosingSticker => Some("выбирает стикер...".to_string()),
|
||||
ChatAction::RecordingVideoNote => Some("записывает видеосообщение...".to_string()),
|
||||
ChatAction::UploadingVideoNote(_) => Some("отправляет видеосообщение...".to_string()),
|
||||
_ => None, // Отмена или неизвестное действие
|
||||
};
|
||||
|
||||
match action_text {
|
||||
Some(text) => client.set_typing_status(Some((user_id, text, Instant::now()))),
|
||||
None => client.set_typing_status(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Обрабатывает Update::ChatPosition - изменение позиции чата в списке.
|
||||
///
|
||||
/// Обновляет order и is_pinned для чатов в Main списке,
|
||||
/// управляет folder_ids для чатов в папках.
|
||||
pub fn handle_chat_position_update(client: &mut TdClient, update: UpdateChatPosition) {
|
||||
let chat_id = ChatId::new(update.chat_id);
|
||||
match &update.position.list {
|
||||
ChatList::Main => {
|
||||
if update.position.order == 0 {
|
||||
// Чат больше не в Main (перемещён в архив и т.д.)
|
||||
client.chats_mut().retain(|c| c.id != chat_id);
|
||||
} else {
|
||||
// Обновляем позицию существующего чата
|
||||
crate::tdlib::chat_helpers::update_chat(client, chat_id, |chat| {
|
||||
chat.order = update.position.order;
|
||||
chat.is_pinned = update.position.is_pinned;
|
||||
});
|
||||
}
|
||||
// Пересортируем по order
|
||||
client.chats_mut().sort_by(|a, b| b.order.cmp(&a.order));
|
||||
}
|
||||
ChatList::Folder(folder) => {
|
||||
// Обновляем folder_ids для чата
|
||||
crate::tdlib::chat_helpers::update_chat(client, chat_id, |chat| {
|
||||
if update.position.order == 0 {
|
||||
// Чат удалён из папки
|
||||
chat.folder_ids.retain(|&id| id != folder.chat_folder_id);
|
||||
} else {
|
||||
// Чат добавлен в папку
|
||||
if !chat.folder_ids.contains(&folder.chat_folder_id) {
|
||||
chat.folder_ids.push(folder.chat_folder_id);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
ChatList::Archive => {
|
||||
// Архив пока не обрабатываем
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Обрабатывает Update::User - обновление информации о пользователе.
|
||||
///
|
||||
/// Сохраняет display name и username в кэше,
|
||||
/// обновляет username в связанных чатах,
|
||||
/// удаляет "Deleted Account" из списка чатов.
|
||||
pub fn handle_user_update(client: &mut TdClient, update: UpdateUser) {
|
||||
let user = update.user;
|
||||
|
||||
// Пропускаем удалённые аккаунты (пустое имя)
|
||||
if user.first_name.is_empty() && user.last_name.is_empty() {
|
||||
// Удаляем чаты с этим пользователем из списка
|
||||
let user_id = user.id;
|
||||
// Clone chat_user_ids to avoid borrow conflict
|
||||
let chat_user_ids = client.user_cache.chat_user_ids.clone();
|
||||
client
|
||||
.chats_mut()
|
||||
.retain(|c| chat_user_ids.get(&c.id) != Some(&UserId::new(user_id)));
|
||||
return;
|
||||
}
|
||||
|
||||
// Сохраняем display name (first_name + last_name)
|
||||
let display_name = if user.last_name.is_empty() {
|
||||
user.first_name.clone()
|
||||
} else {
|
||||
format!("{} {}", user.first_name, user.last_name)
|
||||
};
|
||||
client
|
||||
.user_cache
|
||||
.user_names
|
||||
.insert(UserId::new(user.id), display_name);
|
||||
|
||||
// Сохраняем username если есть (с упрощённым извлечением через and_then)
|
||||
if let Some(username) = user
|
||||
.usernames
|
||||
.as_ref()
|
||||
.and_then(|u| u.active_usernames.first())
|
||||
{
|
||||
client
|
||||
.user_cache
|
||||
.user_usernames
|
||||
.insert(UserId::new(user.id), username.to_string());
|
||||
// Обновляем username в чатах, связанных с этим пользователем
|
||||
for (&chat_id, &user_id) in &client.user_cache.chat_user_ids.clone() {
|
||||
if user_id == UserId::new(user.id) {
|
||||
crate::tdlib::chat_helpers::update_chat(client, chat_id, |chat| {
|
||||
chat.username = Some(format!("@{}", username));
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
// LRU-кэш автоматически удаляет старые записи при вставке
|
||||
}
|
||||
|
||||
/// Обрабатывает Update::MessageInteractionInfo - обновление реакций на сообщение.
|
||||
///
|
||||
/// Обновляет список реакций для сообщения в текущем открытом чате.
|
||||
pub fn handle_message_interaction_info_update(
|
||||
client: &mut TdClient,
|
||||
update: UpdateMessageInteractionInfo,
|
||||
) {
|
||||
// Обновляем реакции в текущем открытом чате
|
||||
if Some(ChatId::new(update.chat_id)) != client.current_chat_id() {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(msg) = client
|
||||
.current_chat_messages_mut()
|
||||
.iter_mut()
|
||||
.find(|m| m.id() == MessageId::new(update.message_id))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Извлекаем реакции из interaction_info
|
||||
msg.interactions.reactions = update
|
||||
.interaction_info
|
||||
.as_ref()
|
||||
.and_then(|info| info.reactions.as_ref())
|
||||
.map(|reactions| {
|
||||
reactions
|
||||
.reactions
|
||||
.iter()
|
||||
.filter_map(|reaction| {
|
||||
let emoji = match &reaction.r#type {
|
||||
tdlib_rs::enums::ReactionType::Emoji(e) => e.emoji.clone(),
|
||||
tdlib_rs::enums::ReactionType::CustomEmoji(_) => return None,
|
||||
};
|
||||
|
||||
Some(ReactionInfo {
|
||||
emoji,
|
||||
count: reaction.total_count,
|
||||
is_chosen: reaction.is_chosen,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
}
|
||||
|
||||
/// Обрабатывает Update::MessageSendSucceeded - успешная отправка сообщения.
|
||||
///
|
||||
/// Заменяет временный ID сообщения на настоящий ID от сервера,
|
||||
/// сохраняя reply_info из временного сообщения.
|
||||
pub fn handle_message_send_succeeded_update(
|
||||
client: &mut TdClient,
|
||||
update: UpdateMessageSendSucceeded,
|
||||
) {
|
||||
let old_id = MessageId::new(update.old_message_id);
|
||||
let chat_id = ChatId::new(update.message.chat_id);
|
||||
|
||||
// Обрабатываем только если это текущий открытый чат
|
||||
if Some(chat_id) != client.current_chat_id() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Находим сообщение с временным ID
|
||||
let Some(idx) = client
|
||||
.current_chat_messages()
|
||||
.iter()
|
||||
.position(|m| m.id() == old_id)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Конвертируем новое сообщение
|
||||
let mut new_msg =
|
||||
crate::tdlib::message_converter::convert_message(client, &update.message, chat_id);
|
||||
|
||||
// Сохраняем reply_info из старого сообщения (если было)
|
||||
let old_reply = client.current_chat_messages()[idx]
|
||||
.interactions
|
||||
.reply_to
|
||||
.clone();
|
||||
if let Some(reply) = old_reply {
|
||||
new_msg.interactions.reply_to = Some(reply);
|
||||
}
|
||||
|
||||
// Заменяем старое сообщение на новое
|
||||
client.current_chat_messages_mut()[idx] = new_msg;
|
||||
}
|
||||
|
||||
/// Обрабатывает Update::ChatDraftMessage - обновление черновика сообщения в чате.
|
||||
///
|
||||
/// Извлекает текст черновика и сохраняет его в ChatInfo для отображения в списке чатов.
|
||||
pub fn handle_chat_draft_message_update(client: &mut TdClient, update: UpdateChatDraftMessage) {
|
||||
crate::tdlib::chat_helpers::update_chat(client, ChatId::new(update.chat_id), |chat| {
|
||||
chat.draft_text = update.draft_message.as_ref().and_then(|draft| {
|
||||
// Извлекаем текст из InputMessageText с помощью pattern matching
|
||||
match &draft.input_message_text {
|
||||
tdlib_rs::enums::InputMessageContent::InputMessageText(text_msg) => {
|
||||
Some(text_msg.text.text.clone())
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Обрабатывает изменение состояния авторизации
|
||||
pub fn handle_auth_state(client: &mut TdClient, state: AuthorizationState) {
|
||||
client.auth.state = match state {
|
||||
AuthorizationState::WaitTdlibParameters => AuthState::WaitTdlibParameters,
|
||||
AuthorizationState::WaitPhoneNumber => AuthState::WaitPhoneNumber,
|
||||
AuthorizationState::WaitCode(_) => AuthState::WaitCode,
|
||||
AuthorizationState::WaitPassword(_) => AuthState::WaitPassword,
|
||||
AuthorizationState::Ready => AuthState::Ready,
|
||||
AuthorizationState::Closed => AuthState::Closed,
|
||||
_ => client.auth.state.clone(),
|
||||
};
|
||||
}
|
||||
@@ -89,12 +89,6 @@ where
|
||||
pub fn contains_key(&self, key: &K) -> bool {
|
||||
self.map.contains_key(key)
|
||||
}
|
||||
|
||||
/// Количество элементов
|
||||
#[allow(dead_code)]
|
||||
pub fn len(&self) -> usize {
|
||||
self.map.len()
|
||||
}
|
||||
}
|
||||
|
||||
/// Кэш информации о пользователях Telegram.
|
||||
@@ -158,21 +152,6 @@ impl UserCache {
|
||||
}
|
||||
}
|
||||
|
||||
/// Получить username пользователя
|
||||
pub fn get_username(&mut self, user_id: &UserId) -> Option<&String> {
|
||||
self.user_usernames.get(user_id)
|
||||
}
|
||||
|
||||
/// Получить имя пользователя
|
||||
pub fn get_name(&mut self, user_id: &UserId) -> Option<&String> {
|
||||
self.user_names.get(user_id)
|
||||
}
|
||||
|
||||
/// Получить user_id по chat_id
|
||||
pub fn get_user_id_by_chat(&self, chat_id: ChatId) -> Option<UserId> {
|
||||
self.chat_user_ids.get(&chat_id).copied()
|
||||
}
|
||||
|
||||
/// Получить статус пользователя по chat_id
|
||||
pub fn get_status_by_chat_id(&self, chat_id: ChatId) -> Option<&UserOnlineStatus> {
|
||||
let user_id = self.chat_user_ids.get(&chat_id)?;
|
||||
@@ -187,21 +166,22 @@ impl UserCache {
|
||||
///
|
||||
/// * `user_enum` - Обновление пользователя от TDLib
|
||||
pub fn handle_user_update(&mut self, user_enum: &User) {
|
||||
if let User::User(user) = user_enum {
|
||||
let user_id = user.id;
|
||||
let User::User(user) = user_enum;
|
||||
let user_id = user.id;
|
||||
|
||||
// Сохраняем username
|
||||
if let Some(username) = user.usernames.as_ref().map(|u| u.editable_username.clone()) {
|
||||
self.user_usernames.insert(UserId::new(user_id), username);
|
||||
}
|
||||
|
||||
// Сохраняем имя
|
||||
let display_name = format!("{} {}", user.first_name, user.last_name).trim().to_string();
|
||||
self.user_names.insert(UserId::new(user_id), display_name);
|
||||
|
||||
// Обновляем статус
|
||||
self.update_status(UserId::new(user_id), &user.status);
|
||||
// Сохраняем username
|
||||
if let Some(username) = user.usernames.as_ref().map(|u| u.editable_username.clone()) {
|
||||
self.user_usernames.insert(UserId::new(user_id), username);
|
||||
}
|
||||
|
||||
// Сохраняем имя
|
||||
let display_name = format!("{} {}", user.first_name, user.last_name)
|
||||
.trim()
|
||||
.to_string();
|
||||
self.user_names.insert(UserId::new(user_id), display_name);
|
||||
|
||||
// Обновляем статус
|
||||
self.update_status(UserId::new(user_id), &user.status);
|
||||
}
|
||||
|
||||
/// Обновляет онлайн-статус пользователя.
|
||||
@@ -222,11 +202,6 @@ impl UserCache {
|
||||
self.user_statuses.insert(user_id, online_status);
|
||||
}
|
||||
|
||||
/// Сохранить связь chat_id -> user_id
|
||||
pub fn register_private_chat(&mut self, chat_id: ChatId, user_id: UserId) {
|
||||
self.chat_user_ids.insert(chat_id, user_id);
|
||||
}
|
||||
|
||||
/// Получает имя пользователя из кэша или загружает из TDLib.
|
||||
///
|
||||
/// Сначала проверяет кэш, затем при необходимости загружает из API.
|
||||
@@ -238,6 +213,7 @@ impl UserCache {
|
||||
/// # Returns
|
||||
///
|
||||
/// Имя пользователя (first_name + last_name) или "User {id}" если не найден.
|
||||
#[allow(dead_code)]
|
||||
pub async fn get_user_name(&self, user_id: UserId) -> String {
|
||||
// Сначала пытаемся получить из кэша
|
||||
if let Some(name) = self.user_names.peek(&user_id) {
|
||||
@@ -247,7 +223,9 @@ impl UserCache {
|
||||
// Загружаем пользователя
|
||||
match functions::get_user(user_id.as_i64(), self.client_id).await {
|
||||
Ok(User::User(user)) => {
|
||||
let name = format!("{} {}", user.first_name, user.last_name).trim().to_string();
|
||||
let name = format!("{} {}", user.first_name, user.last_name)
|
||||
.trim()
|
||||
.to_string();
|
||||
name
|
||||
}
|
||||
_ => format!("User {}", user_id.as_i64()),
|
||||
@@ -284,8 +262,7 @@ impl UserCache {
|
||||
}
|
||||
Err(_) => {
|
||||
// Если не удалось загрузить, сохраняем placeholder
|
||||
self.user_names
|
||||
.insert(user_id, format!("User {}", user_id));
|
||||
self.user_names.insert(user_id, format!("User {}", user_id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
/// Type-safe ID wrappers to prevent mixing up different ID types
|
||||
//! Type-safe ID wrappers to prevent mixing up different ID types.
|
||||
//!
|
||||
//! Provides `ChatId` and `MessageId` newtypes for compile-time safety.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
@@ -134,7 +136,7 @@ mod tests {
|
||||
// let chat_id = ChatId::new(1);
|
||||
// let message_id = MessageId::new(1);
|
||||
// if chat_id == message_id { } // ERROR: mismatched types
|
||||
|
||||
|
||||
// Runtime values can be the same, but types are different
|
||||
let chat_id = ChatId::new(1);
|
||||
let message_id = MessageId::new(1);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::app::App;
|
||||
use crate::tdlib::AuthState;
|
||||
use crate::tdlib::TdClientTrait;
|
||||
use ratatui::{
|
||||
layout::{Alignment, Constraint, Direction, Layout},
|
||||
style::{Color, Modifier, Style},
|
||||
@@ -8,7 +9,7 @@ use ratatui::{
|
||||
Frame,
|
||||
};
|
||||
|
||||
pub fn render(f: &mut Frame, app: &App) {
|
||||
pub fn render<T: TdClientTrait>(f: &mut Frame, app: &App<T>) {
|
||||
let area = f.area();
|
||||
|
||||
let vertical_chunks = Layout::default()
|
||||
@@ -66,7 +67,7 @@ pub fn render(f: &mut Frame, app: &App) {
|
||||
.block(Block::default().borders(Borders::NONE));
|
||||
f.render_widget(instructions_widget, auth_chunks[1]);
|
||||
|
||||
let input_text = format!("📱 {}", app.phone_input);
|
||||
let input_text = format!("📱 {}", app.phone_input());
|
||||
let input = Paragraph::new(input_text)
|
||||
.style(Style::default().fg(Color::Yellow))
|
||||
.alignment(Alignment::Center)
|
||||
@@ -88,7 +89,7 @@ pub fn render(f: &mut Frame, app: &App) {
|
||||
.block(Block::default().borders(Borders::NONE));
|
||||
f.render_widget(instructions_widget, auth_chunks[1]);
|
||||
|
||||
let input_text = format!("🔐 {}", app.code_input);
|
||||
let input_text = format!("🔐 {}", app.code_input());
|
||||
let input = Paragraph::new(input_text)
|
||||
.style(Style::default().fg(Color::Yellow))
|
||||
.alignment(Alignment::Center)
|
||||
@@ -110,7 +111,7 @@ pub fn render(f: &mut Frame, app: &App) {
|
||||
.block(Block::default().borders(Borders::NONE));
|
||||
f.render_widget(instructions_widget, auth_chunks[1]);
|
||||
|
||||
let masked_password = "*".repeat(app.password_input.len());
|
||||
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))
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
//! Chat list panel: search box, chat items, and user online status.
|
||||
|
||||
use crate::app::methods::{compose::ComposeMethods, search::SearchMethods};
|
||||
use crate::app::App;
|
||||
use crate::tdlib::TdClientTrait;
|
||||
use crate::tdlib::UserOnlineStatus;
|
||||
use crate::ui::components;
|
||||
use ratatui::{
|
||||
@@ -8,7 +12,7 @@ use ratatui::{
|
||||
Frame,
|
||||
};
|
||||
|
||||
pub fn render(f: &mut Frame, area: Rect, app: &mut App) {
|
||||
pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &mut App<T>) {
|
||||
let chat_chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
@@ -31,7 +35,7 @@ pub fn render(f: &mut Frame, area: Rect, app: &mut App) {
|
||||
let search_style = if app.is_searching {
|
||||
Style::default().fg(Color::Yellow)
|
||||
} else {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
Style::default().fg(Color::Rgb(160, 160, 160))
|
||||
};
|
||||
let search = Paragraph::new(search_text)
|
||||
.block(Block::default().borders(Borders::ALL))
|
||||
@@ -67,55 +71,18 @@ pub fn render(f: &mut Frame, area: Rect, app: &mut App) {
|
||||
|
||||
f.render_stateful_widget(chats_list, chat_chunks[1], &mut app.chat_list_state);
|
||||
|
||||
// User status - показываем статус выбранного чата
|
||||
let (status_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), // Для групп/каналов
|
||||
}
|
||||
// User status - показываем статус выбранного или выделенного чата
|
||||
let status_chat_id = if app.selected_chat_id.is_some() {
|
||||
app.selected_chat_id
|
||||
} 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)
|
||||
}
|
||||
app.chat_list_state
|
||||
.selected()
|
||||
.and_then(|i| filtered.get(i).map(|c| c.id))
|
||||
};
|
||||
let (status_text, status_color) = match status_chat_id {
|
||||
Some(chat_id) => format_user_status(app.td_client.get_user_status_by_chat_id(chat_id)),
|
||||
None => ("".to_string(), Color::DarkGray),
|
||||
};
|
||||
|
||||
let status = Paragraph::new(status_text)
|
||||
@@ -124,7 +91,17 @@ pub fn render(f: &mut Frame, area: Rect, app: &mut App) {
|
||||
f.render_widget(status, chat_chunks[2]);
|
||||
}
|
||||
|
||||
/// Форматирование времени "был(а) в ..."
|
||||
fn format_was_online(timestamp: i32) -> String {
|
||||
crate::utils::format_was_online(timestamp)
|
||||
/// Форматирует статус пользователя для отображения в статус-баре
|
||||
fn format_user_status(status: Option<&UserOnlineStatus>) -> (String, Color) {
|
||||
match status {
|
||||
Some(UserOnlineStatus::Online) => ("● онлайн".to_string(), Color::Green),
|
||||
Some(UserOnlineStatus::Recently) => ("был(а) недавно".to_string(), Color::Yellow),
|
||||
Some(UserOnlineStatus::Offline(was_online)) => {
|
||||
(crate::utils::format_was_online(*was_online), 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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ pub fn render_emoji_picker(
|
||||
) {
|
||||
// Размеры модалки (зависят от количества реакций)
|
||||
let emojis_per_row = 8;
|
||||
let rows = (available_reactions.len() + emojis_per_row - 1) / emojis_per_row;
|
||||
let rows = available_reactions.len().div_ceil(emojis_per_row);
|
||||
let modal_width = 50u16;
|
||||
let modal_height = (rows + 4) as u16; // +4 для заголовка, отступов и подсказки
|
||||
|
||||
@@ -29,12 +29,7 @@ pub fn render_emoji_picker(
|
||||
let x = area.x + (area.width.saturating_sub(modal_width)) / 2;
|
||||
let y = area.y + (area.height.saturating_sub(modal_height)) / 2;
|
||||
|
||||
let modal_area = Rect::new(
|
||||
x,
|
||||
y,
|
||||
modal_width.min(area.width),
|
||||
modal_height.min(area.height),
|
||||
);
|
||||
let modal_area = Rect::new(x, y, modal_width.min(area.width), modal_height.min(area.height));
|
||||
|
||||
// Очищаем область под модалкой
|
||||
f.render_widget(Clear, modal_area);
|
||||
@@ -87,10 +82,7 @@ pub fn render_emoji_picker(
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::raw("Добавить "),
|
||||
Span::styled(
|
||||
" [Esc] ",
|
||||
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(" [Esc] ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
|
||||
Span::raw("Отмена"),
|
||||
]));
|
||||
|
||||
|
||||
@@ -34,10 +34,7 @@ pub fn render_input_field(
|
||||
// Символ под курсором (или █ если курсор в конце)
|
||||
if safe_cursor_pos < chars.len() {
|
||||
let cursor_char = chars[safe_cursor_pos].to_string();
|
||||
spans.push(Span::styled(
|
||||
cursor_char,
|
||||
Style::default().fg(Color::Black).bg(color),
|
||||
));
|
||||
spans.push(Span::styled(cursor_char, Style::default().fg(Color::Black).bg(color)));
|
||||
} else {
|
||||
// Курсор в конце - показываем блок
|
||||
spans.push(Span::styled("█", Style::default().fg(color)));
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user