Compare commits
54 Commits
1d0bfb53e0
...
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 | ||
| 7ca9ea29ea | |||
| d10dc6599a | |||
| 0cd477f294 | |||
| 8855a07ccd | |||
| 9cc63952f4 | |||
| 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.
|
# 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.
|
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
|
||||||
fixed_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
|
|
||||||
2379
CONTEXT.md
2379
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).
|
|
||||||
661
Cargo.lock
generated
661
Cargo.lock
generated
@@ -28,6 +28,24 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aligned"
|
||||||
|
version = "0.4.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685"
|
||||||
|
dependencies = [
|
||||||
|
"as-slice",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aligned-vec"
|
||||||
|
version = "0.6.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b"
|
||||||
|
dependencies = [
|
||||||
|
"equator",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "allocator-api2"
|
name = "allocator-api2"
|
||||||
version = "0.2.21"
|
version = "0.2.21"
|
||||||
@@ -55,6 +73,12 @@ version = "1.0.13"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
|
checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anyhow"
|
||||||
|
version = "1.0.101"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "arbitrary"
|
name = "arbitrary"
|
||||||
version = "1.4.2"
|
version = "1.4.2"
|
||||||
@@ -84,6 +108,32 @@ dependencies = [
|
|||||||
"x11rb",
|
"x11rb",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "arg_enum_proc_macro"
|
||||||
|
version = "0.3.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "arrayvec"
|
||||||
|
version = "0.7.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "as-slice"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516"
|
||||||
|
dependencies = [
|
||||||
|
"stable_deref_trait",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-broadcast"
|
name = "async-broadcast"
|
||||||
version = "0.7.2"
|
version = "0.7.2"
|
||||||
@@ -227,18 +277,86 @@ version = "1.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "av-scenechange"
|
||||||
|
version = "0.14.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394"
|
||||||
|
dependencies = [
|
||||||
|
"aligned",
|
||||||
|
"anyhow",
|
||||||
|
"arg_enum_proc_macro",
|
||||||
|
"arrayvec",
|
||||||
|
"log",
|
||||||
|
"num-rational",
|
||||||
|
"num-traits",
|
||||||
|
"pastey",
|
||||||
|
"rayon",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"v_frame",
|
||||||
|
"y4m",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "av1-grain"
|
||||||
|
version = "0.2.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"arrayvec",
|
||||||
|
"log",
|
||||||
|
"nom",
|
||||||
|
"num-rational",
|
||||||
|
"v_frame",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "avif-serialize"
|
||||||
|
version = "0.8.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "47c8fbc0f831f4519fe8b810b6a7a91410ec83031b8233f730a0480029f6a23f"
|
||||||
|
dependencies = [
|
||||||
|
"arrayvec",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "base64"
|
name = "base64"
|
||||||
version = "0.22.1"
|
version = "0.22.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base64-simd"
|
||||||
|
version = "0.8.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195"
|
||||||
|
dependencies = [
|
||||||
|
"outref",
|
||||||
|
"vsimd",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bit_field"
|
||||||
|
version = "0.10.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "2.10.0"
|
version = "2.10.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
|
checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bitstream-io"
|
||||||
|
version = "4.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "60d4bd9d1db2c6bdf285e223a7fa369d5ce98ec767dec949c6ca62863ce61757"
|
||||||
|
dependencies = [
|
||||||
|
"core2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "block-buffer"
|
name = "block-buffer"
|
||||||
version = "0.10.4"
|
version = "0.10.4"
|
||||||
@@ -270,6 +388,12 @@ dependencies = [
|
|||||||
"piper",
|
"piper",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "built"
|
||||||
|
version = "0.8.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f4ad8f11f288f48ca24471bbd51ac257aaeaaa07adae295591266b792902ae64"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bumpalo"
|
name = "bumpalo"
|
||||||
version = "3.19.1"
|
version = "3.19.1"
|
||||||
@@ -443,6 +567,12 @@ dependencies = [
|
|||||||
"error-code",
|
"error-code",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "color_quant"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "compact_str"
|
name = "compact_str"
|
||||||
version = "0.8.1"
|
version = "0.8.1"
|
||||||
@@ -500,6 +630,15 @@ version = "0.8.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "core2"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cpufeatures"
|
name = "cpufeatures"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -865,6 +1004,26 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "equator"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc"
|
||||||
|
dependencies = [
|
||||||
|
"equator-macro",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "equator-macro"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "equivalent"
|
name = "equivalent"
|
||||||
version = "1.0.2"
|
version = "1.0.2"
|
||||||
@@ -908,6 +1067,21 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "exr"
|
||||||
|
version = "1.74.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be"
|
||||||
|
dependencies = [
|
||||||
|
"bit_field",
|
||||||
|
"half",
|
||||||
|
"lebe",
|
||||||
|
"miniz_oxide",
|
||||||
|
"rayon-core",
|
||||||
|
"smallvec",
|
||||||
|
"zune-inflate",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastrand"
|
name = "fastrand"
|
||||||
version = "2.3.0"
|
version = "2.3.0"
|
||||||
@@ -995,6 +1169,16 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fs2"
|
||||||
|
version = "0.4.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"winapi",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-channel"
|
name = "futures-channel"
|
||||||
version = "0.3.31"
|
version = "0.3.31"
|
||||||
@@ -1103,6 +1287,16 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gif"
|
||||||
|
version = "0.14.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f5df2ba84018d80c213569363bdcd0c64e6933c67fe4c1d60ecf822971a3c35e"
|
||||||
|
dependencies = [
|
||||||
|
"color_quant",
|
||||||
|
"weezl",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "h2"
|
name = "h2"
|
||||||
version = "0.4.13"
|
version = "0.4.13"
|
||||||
@@ -1407,6 +1601,12 @@ dependencies = [
|
|||||||
"zerovec",
|
"zerovec",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "icy_sixel"
|
||||||
|
version = "0.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ccc0a9c4770bc47b0a933256a496cfb8b6531f753ea9bccb19c6dff0ff7273fc"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ident_case"
|
name = "ident_case"
|
||||||
version = "1.0.1"
|
version = "1.0.1"
|
||||||
@@ -1442,12 +1642,38 @@ checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
"byteorder-lite",
|
"byteorder-lite",
|
||||||
|
"color_quant",
|
||||||
|
"exr",
|
||||||
|
"gif",
|
||||||
|
"image-webp",
|
||||||
"moxcms",
|
"moxcms",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
"png",
|
"png",
|
||||||
|
"qoi",
|
||||||
|
"ravif",
|
||||||
|
"rayon",
|
||||||
|
"rgb",
|
||||||
"tiff",
|
"tiff",
|
||||||
|
"zune-core 0.5.1",
|
||||||
|
"zune-jpeg 0.5.12",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "image-webp"
|
||||||
|
version = "0.2.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
|
||||||
|
dependencies = [
|
||||||
|
"byteorder-lite",
|
||||||
|
"quick-error",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "imgref"
|
||||||
|
version = "1.12.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "indexmap"
|
name = "indexmap"
|
||||||
version = "1.9.3"
|
version = "1.9.3"
|
||||||
@@ -1514,6 +1740,17 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "interpolate_name"
|
||||||
|
version = "0.2.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ipnet"
|
name = "ipnet"
|
||||||
version = "2.11.0"
|
version = "2.11.0"
|
||||||
@@ -1578,6 +1815,15 @@ dependencies = [
|
|||||||
"either",
|
"either",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itertools"
|
||||||
|
version = "0.14.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
|
||||||
|
dependencies = [
|
||||||
|
"either",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itoa"
|
name = "itoa"
|
||||||
version = "1.0.17"
|
version = "1.0.17"
|
||||||
@@ -1610,12 +1856,28 @@ version = "1.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lebe"
|
||||||
|
version = "0.5.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libc"
|
name = "libc"
|
||||||
version = "0.2.180"
|
version = "0.2.180"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
|
checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libfuzzer-sys"
|
||||||
|
version = "0.4.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5037190e1f70cbeef565bd267599242926f724d3b8a9f510fd7e0b540cfa4404"
|
||||||
|
dependencies = [
|
||||||
|
"arbitrary",
|
||||||
|
"cc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libredox"
|
name = "libredox"
|
||||||
version = "0.1.12"
|
version = "0.1.12"
|
||||||
@@ -1659,6 +1921,15 @@ version = "0.4.29"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "loop9"
|
||||||
|
version = "0.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062"
|
||||||
|
dependencies = [
|
||||||
|
"imgref",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lru"
|
name = "lru"
|
||||||
version = "0.12.5"
|
version = "0.12.5"
|
||||||
@@ -1710,6 +1981,16 @@ dependencies = [
|
|||||||
"regex-automata",
|
"regex-automata",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "maybe-rayon"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"rayon",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memchr"
|
name = "memchr"
|
||||||
version = "2.7.6"
|
version = "2.7.6"
|
||||||
@@ -1780,6 +2061,27 @@ dependencies = [
|
|||||||
"tempfile",
|
"tempfile",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "new_debug_unreachable"
|
||||||
|
version = "1.0.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nom"
|
||||||
|
version = "8.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "noop_proc_macro"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "notify-rust"
|
name = "notify-rust"
|
||||||
version = "4.12.0"
|
version = "4.12.0"
|
||||||
@@ -1803,12 +2105,53 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-bigint"
|
||||||
|
version = "0.4.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
|
||||||
|
dependencies = [
|
||||||
|
"num-integer",
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-conv"
|
name = "num-conv"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050"
|
checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-derive"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-integer"
|
||||||
|
version = "0.1.46"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
|
||||||
|
dependencies = [
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-rational"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
|
||||||
|
dependencies = [
|
||||||
|
"num-bigint",
|
||||||
|
"num-integer",
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-traits"
|
name = "num-traits"
|
||||||
version = "0.2.19"
|
version = "0.2.19"
|
||||||
@@ -1976,6 +2319,12 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "outref"
|
||||||
|
version = "0.5.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "parking"
|
name = "parking"
|
||||||
version = "2.2.1"
|
version = "2.2.1"
|
||||||
@@ -2011,6 +2360,12 @@ version = "1.0.15"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pastey"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pathdiff"
|
name = "pathdiff"
|
||||||
version = "0.2.3"
|
version = "0.2.3"
|
||||||
@@ -2132,6 +2487,15 @@ version = "0.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ppv-lite86"
|
||||||
|
version = "0.2.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
|
||||||
|
dependencies = [
|
||||||
|
"zerocopy",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proc-macro-crate"
|
name = "proc-macro-crate"
|
||||||
version = "3.4.0"
|
version = "3.4.0"
|
||||||
@@ -2150,6 +2514,25 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "profiling"
|
||||||
|
version = "1.0.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773"
|
||||||
|
dependencies = [
|
||||||
|
"profiling-procmacros",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "profiling-procmacros"
|
||||||
|
version = "1.0.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b"
|
||||||
|
dependencies = [
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pxfm"
|
name = "pxfm"
|
||||||
version = "0.1.27"
|
version = "0.1.27"
|
||||||
@@ -2159,6 +2542,15 @@ dependencies = [
|
|||||||
"num-traits",
|
"num-traits",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "qoi"
|
||||||
|
version = "0.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001"
|
||||||
|
dependencies = [
|
||||||
|
"bytemuck",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quick-error"
|
name = "quick-error"
|
||||||
version = "2.0.1"
|
version = "2.0.1"
|
||||||
@@ -2189,6 +2581,65 @@ version = "5.3.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand"
|
||||||
|
version = "0.8.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"rand_chacha 0.3.1",
|
||||||
|
"rand_core 0.6.4",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand"
|
||||||
|
version = "0.9.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
|
||||||
|
dependencies = [
|
||||||
|
"rand_chacha 0.9.0",
|
||||||
|
"rand_core 0.9.5",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_chacha"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||||
|
dependencies = [
|
||||||
|
"ppv-lite86",
|
||||||
|
"rand_core 0.6.4",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_chacha"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||||
|
dependencies = [
|
||||||
|
"ppv-lite86",
|
||||||
|
"rand_core 0.9.5",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_core"
|
||||||
|
version = "0.6.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom 0.2.17",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_core"
|
||||||
|
version = "0.9.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom 0.3.4",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ratatui"
|
name = "ratatui"
|
||||||
version = "0.29.0"
|
version = "0.29.0"
|
||||||
@@ -2210,6 +2661,72 @@ dependencies = [
|
|||||||
"unicode-width 0.2.0",
|
"unicode-width 0.2.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ratatui-image"
|
||||||
|
version = "8.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3ecc67e9f7d0ac69e0f712f58b1a9d5a04d8daeeb3628f4d6b67580abb88b7cb"
|
||||||
|
dependencies = [
|
||||||
|
"base64-simd",
|
||||||
|
"icy_sixel",
|
||||||
|
"image",
|
||||||
|
"rand 0.8.5",
|
||||||
|
"ratatui",
|
||||||
|
"rustix 0.38.44",
|
||||||
|
"thiserror 1.0.69",
|
||||||
|
"windows 0.58.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rav1e"
|
||||||
|
version = "0.8.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b"
|
||||||
|
dependencies = [
|
||||||
|
"aligned-vec",
|
||||||
|
"arbitrary",
|
||||||
|
"arg_enum_proc_macro",
|
||||||
|
"arrayvec",
|
||||||
|
"av-scenechange",
|
||||||
|
"av1-grain",
|
||||||
|
"bitstream-io",
|
||||||
|
"built",
|
||||||
|
"cfg-if",
|
||||||
|
"interpolate_name",
|
||||||
|
"itertools 0.14.0",
|
||||||
|
"libc",
|
||||||
|
"libfuzzer-sys",
|
||||||
|
"log",
|
||||||
|
"maybe-rayon",
|
||||||
|
"new_debug_unreachable",
|
||||||
|
"noop_proc_macro",
|
||||||
|
"num-derive",
|
||||||
|
"num-traits",
|
||||||
|
"paste",
|
||||||
|
"profiling",
|
||||||
|
"rand 0.9.2",
|
||||||
|
"rand_chacha 0.9.0",
|
||||||
|
"simd_helpers",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"v_frame",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ravif"
|
||||||
|
version = "0.12.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ef69c1990ceef18a116855938e74793a5f7496ee907562bd0857b6ac734ab285"
|
||||||
|
dependencies = [
|
||||||
|
"avif-serialize",
|
||||||
|
"imgref",
|
||||||
|
"loop9",
|
||||||
|
"quick-error",
|
||||||
|
"rav1e",
|
||||||
|
"rayon",
|
||||||
|
"rgb",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rayon"
|
name = "rayon"
|
||||||
version = "1.11.0"
|
version = "1.11.0"
|
||||||
@@ -2352,6 +2869,12 @@ dependencies = [
|
|||||||
"web-sys",
|
"web-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rgb"
|
||||||
|
version = "0.8.52"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ring"
|
name = "ring"
|
||||||
version = "0.17.14"
|
version = "0.17.14"
|
||||||
@@ -2677,6 +3200,15 @@ version = "0.3.8"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
|
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "simd_helpers"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6"
|
||||||
|
dependencies = [
|
||||||
|
"quote",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "similar"
|
name = "similar"
|
||||||
version = "2.7.0"
|
version = "2.7.0"
|
||||||
@@ -2811,7 +3343,7 @@ checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"quick-xml",
|
"quick-xml",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"windows",
|
"windows 0.61.3",
|
||||||
"windows-version",
|
"windows-version",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2855,15 +3387,19 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"arboard",
|
"arboard",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
"base64",
|
||||||
"chrono",
|
"chrono",
|
||||||
"criterion",
|
"criterion",
|
||||||
"crossterm",
|
"crossterm",
|
||||||
"dirs 5.0.1",
|
"dirs 5.0.1",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
|
"fs2",
|
||||||
|
"image",
|
||||||
"insta",
|
"insta",
|
||||||
"notify-rust",
|
"notify-rust",
|
||||||
"open",
|
"open",
|
||||||
"ratatui",
|
"ratatui",
|
||||||
|
"ratatui-image",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tdlib-rs",
|
"tdlib-rs",
|
||||||
@@ -2948,7 +3484,7 @@ dependencies = [
|
|||||||
"half",
|
"half",
|
||||||
"quick-error",
|
"quick-error",
|
||||||
"weezl",
|
"weezl",
|
||||||
"zune-jpeg",
|
"zune-jpeg 0.4.21",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3355,6 +3891,17 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "v_frame"
|
||||||
|
version = "0.3.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2"
|
||||||
|
dependencies = [
|
||||||
|
"aligned-vec",
|
||||||
|
"num-traits",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "valuable"
|
name = "valuable"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
@@ -3373,6 +3920,12 @@ version = "0.9.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "vsimd"
|
||||||
|
version = "0.8.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "walkdir"
|
name = "walkdir"
|
||||||
version = "2.5.0"
|
version = "2.5.0"
|
||||||
@@ -3513,6 +4066,16 @@ version = "0.4.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows"
|
||||||
|
version = "0.58.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6"
|
||||||
|
dependencies = [
|
||||||
|
"windows-core 0.58.0",
|
||||||
|
"windows-targets 0.52.6",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows"
|
name = "windows"
|
||||||
version = "0.61.3"
|
version = "0.61.3"
|
||||||
@@ -3535,14 +4098,27 @@ dependencies = [
|
|||||||
"windows-core 0.61.2",
|
"windows-core 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-core"
|
||||||
|
version = "0.58.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99"
|
||||||
|
dependencies = [
|
||||||
|
"windows-implement 0.58.0",
|
||||||
|
"windows-interface 0.58.0",
|
||||||
|
"windows-result 0.2.0",
|
||||||
|
"windows-strings 0.1.0",
|
||||||
|
"windows-targets 0.52.6",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-core"
|
name = "windows-core"
|
||||||
version = "0.61.2"
|
version = "0.61.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
|
checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-implement",
|
"windows-implement 0.60.2",
|
||||||
"windows-interface",
|
"windows-interface 0.59.3",
|
||||||
"windows-link 0.1.3",
|
"windows-link 0.1.3",
|
||||||
"windows-result 0.3.4",
|
"windows-result 0.3.4",
|
||||||
"windows-strings 0.4.2",
|
"windows-strings 0.4.2",
|
||||||
@@ -3554,8 +4130,8 @@ version = "0.62.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-implement",
|
"windows-implement 0.60.2",
|
||||||
"windows-interface",
|
"windows-interface 0.59.3",
|
||||||
"windows-link 0.2.1",
|
"windows-link 0.2.1",
|
||||||
"windows-result 0.4.1",
|
"windows-result 0.4.1",
|
||||||
"windows-strings 0.5.1",
|
"windows-strings 0.5.1",
|
||||||
@@ -3572,6 +4148,17 @@ dependencies = [
|
|||||||
"windows-threading",
|
"windows-threading",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-implement"
|
||||||
|
version = "0.58.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-implement"
|
name = "windows-implement"
|
||||||
version = "0.60.2"
|
version = "0.60.2"
|
||||||
@@ -3583,6 +4170,17 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-interface"
|
||||||
|
version = "0.58.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-interface"
|
name = "windows-interface"
|
||||||
version = "0.59.3"
|
version = "0.59.3"
|
||||||
@@ -3627,6 +4225,15 @@ dependencies = [
|
|||||||
"windows-strings 0.5.1",
|
"windows-strings 0.5.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-result"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets 0.52.6",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-result"
|
name = "windows-result"
|
||||||
version = "0.3.4"
|
version = "0.3.4"
|
||||||
@@ -3645,6 +4252,16 @@ dependencies = [
|
|||||||
"windows-link 0.2.1",
|
"windows-link 0.2.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-strings"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10"
|
||||||
|
dependencies = [
|
||||||
|
"windows-result 0.2.0",
|
||||||
|
"windows-targets 0.52.6",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-strings"
|
name = "windows-strings"
|
||||||
version = "0.4.2"
|
version = "0.4.2"
|
||||||
@@ -3959,6 +4576,12 @@ dependencies = [
|
|||||||
"lzma-sys",
|
"lzma-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "y4m"
|
||||||
|
version = "0.8.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yoke"
|
name = "yoke"
|
||||||
version = "0.8.1"
|
version = "0.8.1"
|
||||||
@@ -4219,13 +4842,37 @@ version = "0.4.12"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a"
|
checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zune-core"
|
||||||
|
version = "0.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zune-inflate"
|
||||||
|
version = "0.2.54"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02"
|
||||||
|
dependencies = [
|
||||||
|
"simd-adler32",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zune-jpeg"
|
name = "zune-jpeg"
|
||||||
version = "0.4.21"
|
version = "0.4.21"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713"
|
checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"zune-core",
|
"zune-core 0.4.12",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zune-jpeg"
|
||||||
|
version = "0.5.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "410e9ecef634c709e3831c2cfdb8d9c32164fae1c67496d5b68fff728eec37fe"
|
||||||
|
dependencies = [
|
||||||
|
"zune-core 0.5.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -10,10 +10,11 @@ keywords = ["telegram", "tui", "terminal", "cli"]
|
|||||||
categories = ["command-line-utilities"]
|
categories = ["command-line-utilities"]
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["clipboard", "url-open", "notifications"]
|
default = ["clipboard", "url-open", "notifications", "images"]
|
||||||
clipboard = ["dep:arboard"]
|
clipboard = ["dep:arboard"]
|
||||||
url-open = ["dep:open"]
|
url-open = ["dep:open"]
|
||||||
notifications = ["dep:notify-rust"]
|
notifications = ["dep:notify-rust"]
|
||||||
|
images = ["dep:ratatui-image", "dep:image"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
ratatui = "0.29"
|
ratatui = "0.29"
|
||||||
@@ -28,11 +29,15 @@ chrono = "0.4"
|
|||||||
open = { version = "5.0", optional = true }
|
open = { version = "5.0", optional = true }
|
||||||
arboard = { version = "3.4", optional = true }
|
arboard = { version = "3.4", optional = true }
|
||||||
notify-rust = { version = "4.11", 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"
|
toml = "0.8"
|
||||||
dirs = "5.0"
|
dirs = "5.0"
|
||||||
thiserror = "1.0"
|
thiserror = "1.0"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
base64 = "0.22.1"
|
||||||
|
fs2 = "0.4"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
insta = "1.34"
|
insta = "1.34"
|
||||||
|
|||||||
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.
|
|
||||||
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,453 +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/
|
tele-tui/
|
||||||
├── .github/ # GitHub конфигурация
|
├── src/
|
||||||
│ ├── ISSUE_TEMPLATE/ # Шаблоны для issue
|
│ ├── main.rs # Точка входа, event loop
|
||||||
│ │ ├── bug_report.md
|
│ ├── lib.rs # Экспорт модулей для тестов
|
||||||
│ │ └── feature_request.md
|
│ ├── types.rs # ChatId, MessageId (newtype wrappers)
|
||||||
│ ├── workflows/ # GitHub Actions CI/CD
|
│ ├── constants.rs # MAX_MESSAGES_IN_CHAT, etc.
|
||||||
│ │ └── ci.yml
|
│ ├── 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
|
│ └── pull_request_template.md
|
||||||
│
|
│
|
||||||
├── docs/ # Дополнительная документация
|
├── Cargo.toml # Манифест проекта
|
||||||
│ └── TDLIB_INTEGRATION.md
|
├── Cargo.lock # Точные версии зависимостей
|
||||||
|
├── build.rs # Build script (TDLib)
|
||||||
|
├── rustfmt.toml # cargo fmt конфигурация
|
||||||
|
├── .editorconfig # Настройки IDE
|
||||||
|
├── .gitignore # Git ignore
|
||||||
│
|
│
|
||||||
├── src/ # Исходный код
|
├── config.toml.example # Пример конфигурации
|
||||||
│ ├── app/ # Состояние приложения
|
├── credentials.example # Пример credentials
|
||||||
│ │ ├── mod.rs
|
|
||||||
│ │ └── state.rs
|
|
||||||
│ ├── input/ # Обработка пользовательского ввода
|
|
||||||
│ │ ├── mod.rs
|
|
||||||
│ │ ├── auth.rs
|
|
||||||
│ │ └── main_input.rs
|
|
||||||
│ ├── audio/ # Прослушивание голосовых (PLANNED)
|
|
||||||
│ │ ├── mod.rs # Экспорт публичных типов
|
|
||||||
│ │ ├── player.rs # AudioPlayer на rodio
|
|
||||||
│ │ ├── cache.rs # VoiceCache для OGG файлов
|
|
||||||
│ │ └── state.rs # PlaybackState
|
|
||||||
│ ├── media/ # Работа с изображениями (PLANNED)
|
|
||||||
│ │ ├── mod.rs # Экспорт публичных типов
|
|
||||||
│ │ ├── image_cache.rs # LRU кэш для загруженных изображений
|
|
||||||
│ │ ├── image_loader.rs # Асинхронная загрузка через TDLib
|
|
||||||
│ │ └── image_renderer.rs # Рендеринг изображений в ratatui
|
|
||||||
│ ├── notifications.rs # Desktop уведомления
|
|
||||||
│ ├── tdlib/ # TDLib интеграция
|
|
||||||
│ │ ├── mod.rs
|
|
||||||
│ │ └── client.rs
|
|
||||||
│ ├── ui/ # Рендеринг интерфейса
|
|
||||||
│ │ ├── mod.rs
|
|
||||||
│ │ ├── auth.rs
|
|
||||||
│ │ ├── chat_list.rs
|
|
||||||
│ │ ├── footer.rs
|
|
||||||
│ │ ├── loading.rs
|
|
||||||
│ │ ├── main_screen.rs
|
|
||||||
│ │ └── messages.rs
|
|
||||||
│ ├── config.rs # Конфигурация приложения
|
|
||||||
│ ├── main.rs # Точка входа
|
|
||||||
│ └── utils.rs # Утилиты
|
|
||||||
│
|
│
|
||||||
├── tdlib_data/ # TDLib сессия (НЕ коммитится)
|
├── CLAUDE.md # Инструкции для AI
|
||||||
├── target/ # Артефакты сборки (НЕ коммитится)
|
├── CONTEXT.md # Текущий статус
|
||||||
│
|
├── ROADMAP.md # План развития
|
||||||
├── .editorconfig # EditorConfig для IDE
|
├── DEVELOPMENT.md # Правила разработки
|
||||||
├── .gitignore # Git ignore правила
|
├── REQUIREMENTS.md # Требования
|
||||||
├── Cargo.lock # Зависимости (точные версии)
|
├── ARCHITECTURE.md # C4, sequence diagrams
|
||||||
├── Cargo.toml # Манифест проекта
|
├── PROJECT_STRUCTURE.md # Этот файл
|
||||||
├── rustfmt.toml # Конфигурация форматирования
|
├── E2E_TESTING.md # Гайд по тестированию
|
||||||
│
|
├── HOTKEYS.md # Горячие клавиши
|
||||||
├── config.toml.example # Пример конфигурации
|
├── CHANGELOG.md # История изменений
|
||||||
├── credentials.example # Пример credentials
|
├── README.md # Главная документация
|
||||||
│
|
├── INSTALL.md # Установка
|
||||||
├── CHANGELOG.md # История изменений
|
├── FAQ.md # FAQ
|
||||||
├── CLAUDE.md # Инструкции для Claude AI
|
├── CONTRIBUTING.md # Гайд по контрибуции
|
||||||
├── CONTRIBUTING.md # Гайд по контрибуции
|
├── SECURITY.md # Безопасность
|
||||||
├── CONTEXT.md # Текущий статус разработки
|
└── LICENSE # MIT лицензия
|
||||||
├── DEVELOPMENT.md # Правила разработки
|
|
||||||
├── FAQ.md # Часто задаваемые вопросы
|
|
||||||
├── HOTKEYS.md # Список горячих клавиш
|
|
||||||
├── INSTALL.md # Инструкция по установке
|
|
||||||
├── LICENSE # MIT лицензия
|
|
||||||
├── PROJECT_STRUCTURE.md # Этот файл
|
|
||||||
├── README.md # Главная документация
|
|
||||||
├── REQUIREMENTS.md # Функциональные требования
|
|
||||||
├── ROADMAP.md # План развития
|
|
||||||
└── SECURITY.md # Политика безопасности
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Исходный код (src/)
|
## Ключевые модули
|
||||||
|
|
||||||
### main.rs
|
|
||||||
**Точка входа приложения**
|
|
||||||
- Инициализация TDLib клиента
|
|
||||||
- Event loop (60 FPS)
|
|
||||||
- Обработка Ctrl+C (graceful shutdown)
|
|
||||||
- Координация между UI, input и TDLib
|
|
||||||
|
|
||||||
### config.rs
|
|
||||||
**Конфигурация приложения**
|
|
||||||
- Загрузка/сохранение TOML конфига
|
|
||||||
- Парсинг timezone и цветов
|
|
||||||
- Загрузка credentials (приоритетная система)
|
|
||||||
- XDG directory support
|
|
||||||
|
|
||||||
### utils.rs
|
|
||||||
**Утилитарные функции**
|
|
||||||
- `disable_tdlib_logs()` — отключение TDLib логов через FFI
|
|
||||||
- `format_timestamp_with_tz()` — форматирование времени с учётом timezone
|
|
||||||
- `format_date()` — форматирование дат для разделителей
|
|
||||||
- `format_datetime()` — полное форматирование даты и времени
|
|
||||||
- `format_was_online()` — "был(а) X мин. назад"
|
|
||||||
|
|
||||||
### app/ — Состояние приложения
|
### app/ — Состояние приложения
|
||||||
|
|
||||||
#### mod.rs
|
`App<T: TdClientTrait>` — главная структура, параметризована trait'ом для DI.
|
||||||
- `App` struct — главная структура состояния
|
|
||||||
- `needs_redraw` — флаг для оптимизации рендеринга
|
|
||||||
- Состояние модалок (delete confirm, reaction picker, profile)
|
|
||||||
- Состояние поиска и черновиков
|
|
||||||
- Методы для работы с UI state
|
|
||||||
|
|
||||||
#### state.rs
|
**State machine** (`ChatState` enum):
|
||||||
- `AppScreen` enum — текущий экран (Loading, Auth, Main)
|
```
|
||||||
|
Normal → MessageSelection → Editing
|
||||||
|
→ Reply
|
||||||
|
→ Forward
|
||||||
|
→ DeleteConfirmation
|
||||||
|
→ ReactionPicker
|
||||||
|
→ Profile
|
||||||
|
→ SearchInChat
|
||||||
|
→ PinnedMessages
|
||||||
|
```
|
||||||
|
|
||||||
### audio/ — Прослушивание голосовых сообщений (PLANNED - Фаза 12)
|
**Trait-based methods** (5 traits на `App<T>`):
|
||||||
|
| Trait | Методы | Описание |
|
||||||
#### player.rs
|
|-------|--------|----------|
|
||||||
- `AudioPlayer` — управление воспроизведением голосовых сообщений
|
| NavigationMethods | 7 | next/previous_chat, close_chat, select_current_chat |
|
||||||
- Использует rodio для кроссплатформенного аудио
|
| MessageMethods | 8 | is_editing, is_replying, get_selected_message, etc. |
|
||||||
- API методы: play(), pause(), resume(), stop(), seek(), set_volume()
|
| ComposeMethods | 10 | start_reply, cancel_editing, load_draft, etc. |
|
||||||
- Обработка OGG Opus файлов (формат голосовых в Telegram)
|
| SearchMethods | 15 | start_search, enter_message_search_mode, etc. |
|
||||||
- Отдельный поток для воспроизведения (через rodio Sink)
|
| ModalMethods | 27 | enter_profile_mode, exit_pinned_mode, etc. |
|
||||||
|
|
||||||
#### cache.rs
|
|
||||||
- `VoiceCache` — LRU кэш для загруженных голосовых файлов
|
|
||||||
- Хранение в ~/.cache/tele-tui/voice/
|
|
||||||
- Лимит по размеру (MB) с автоматической очисткой
|
|
||||||
- MAX_VOICE_CACHE_SIZE = 100 MB (настраивается в config)
|
|
||||||
- Проверка существования файла перед воспроизведением
|
|
||||||
|
|
||||||
#### state.rs
|
|
||||||
- `PlaybackState` — текущее состояние воспроизведения
|
|
||||||
- Поля: message_id, status, position, duration, volume
|
|
||||||
- `PlaybackStatus` enum — Stopped, Playing, Paused, Loading
|
|
||||||
- Ticker для обновления позиции (каждые 100ms)
|
|
||||||
|
|
||||||
#### mod.rs
|
|
||||||
- Экспорт публичных типов
|
|
||||||
- `VoiceNoteInfo` struct — метаданные голосового (file_id, duration, waveform)
|
|
||||||
- `AudioConfig` — конфигурация из config.toml
|
|
||||||
- Fallback на системный плеер (mpv, ffplay)
|
|
||||||
|
|
||||||
### media/ — Работа с изображениями (PLANNED - Фаза 11)
|
|
||||||
|
|
||||||
#### image_cache.rs
|
|
||||||
- `ImageCache` — LRU кэш для загруженных изображений
|
|
||||||
- Лимит по размеру (MB) с автоматической очисткой
|
|
||||||
- Хранение как в памяти (DynamicImage), так и на диске (PathBuf)
|
|
||||||
- MAX_IMAGE_CACHE_SIZE = 100 MB (настраивается в config)
|
|
||||||
|
|
||||||
#### image_loader.rs
|
|
||||||
- `ImageLoader` — асинхронная загрузка изображений через TDLib
|
|
||||||
- Метод `load_photo(file_id)` — получить изображение из кэша или загрузить
|
|
||||||
- Метод `download_and_cache(file)` — загрузка через TDLib downloadFile API
|
|
||||||
- Обработка состояний загрузки (pending/downloading/ready)
|
|
||||||
- Приоритизация видимых изображений
|
|
||||||
|
|
||||||
#### image_renderer.rs
|
|
||||||
- `ImageRenderer` — рендеринг изображений в ratatui
|
|
||||||
- Auto-detection протокола терминала (Sixel/Kitty/iTerm2/Halfblocks)
|
|
||||||
- Автоматическое масштабирование под размер области
|
|
||||||
- Сохранение aspect ratio
|
|
||||||
- Fast resize для превью
|
|
||||||
- Fallback на текстовую заглушку
|
|
||||||
|
|
||||||
#### mod.rs
|
|
||||||
- Экспорт публичных типов
|
|
||||||
- `PhotoInfo` struct — метаданные изображения (file_id, width, height)
|
|
||||||
- `TerminalProtocol` enum — поддерживаемые протоколы отображения
|
|
||||||
|
|
||||||
### notifications.rs — Desktop уведомления
|
|
||||||
|
|
||||||
- `NotificationManager` — управление desktop уведомлениями
|
|
||||||
- Интеграция с notify-rust для кроссплатформенных уведомлений
|
|
||||||
- Фильтрация по muted чатам и mentions
|
|
||||||
- Beautification медиа-типов с emoji
|
|
||||||
- Настраиваемый timeout и urgency (Linux)
|
|
||||||
|
|
||||||
### tdlib/ — Telegram интеграция
|
|
||||||
|
|
||||||
#### client.rs
|
|
||||||
- `TdClient` — обёртка над TDLib
|
|
||||||
- Авторизация (телефон, код, 2FA)
|
|
||||||
- Загрузка чатов и сообщений
|
|
||||||
- Отправка/редактирование/удаление сообщений
|
|
||||||
- Reply, Forward
|
|
||||||
- Реакции (`ReactionInfo`)
|
|
||||||
- LRU кеши (users, statuses)
|
|
||||||
- `NetworkState` enum
|
|
||||||
|
|
||||||
#### mod.rs
|
|
||||||
- Экспорт публичных типов
|
|
||||||
|
|
||||||
### ui/ — Рендеринг интерфейса
|
|
||||||
|
|
||||||
#### mod.rs
|
|
||||||
- `render()` — роутинг по экранам
|
|
||||||
- Проверка минимального размера терминала (80x20)
|
|
||||||
|
|
||||||
#### loading.rs
|
|
||||||
- Экран "Loading..."
|
|
||||||
|
|
||||||
#### auth.rs
|
|
||||||
- Экран авторизации (ввод телефона, кода, пароля)
|
|
||||||
|
|
||||||
#### main_screen.rs
|
|
||||||
- Главный экран
|
|
||||||
- Отображение папок сверху
|
|
||||||
|
|
||||||
#### chat_list.rs
|
|
||||||
- Список чатов
|
|
||||||
- Индикаторы: 📌, 🔇, @, (N)
|
|
||||||
- Онлайн-статус (●)
|
|
||||||
- Поиск по чатам
|
|
||||||
|
|
||||||
#### messages.rs
|
|
||||||
- Область сообщений
|
|
||||||
- Группировка по дате и отправителю
|
|
||||||
- Markdown форматирование
|
|
||||||
- Реакции под сообщениями
|
|
||||||
- Emoji picker modal
|
|
||||||
- Profile modal
|
|
||||||
- Delete confirmation modal
|
|
||||||
- Pinned message
|
|
||||||
- Динамический инпут
|
|
||||||
- Блочный курсор
|
|
||||||
|
|
||||||
#### footer.rs
|
|
||||||
- Футер с командами
|
|
||||||
- Индикатор состояния сети
|
|
||||||
|
|
||||||
### input/ — Обработка ввода
|
### 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
|
**Dependency Injection**: `TdClientTrait` позволяет подменять TdClient на `FakeTdClient` в тестах.
|
||||||
- Обработка ввода на главном экране
|
|
||||||
- **Важно**: порядок обработчиков имеет значение!
|
|
||||||
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
|
|
||||||
- Поддержка русской раскладки
|
|
||||||
|
|
||||||
## Конфигурационные файлы
|
**MessageManager** — управление сообщениями:
|
||||||
|
- `convert.rs` — конвертация TDLib JSON → MessageInfo
|
||||||
|
- `operations.rs` — 11 API операций (get_history, send, edit, delete, forward, search, etc.)
|
||||||
|
|
||||||
### Cargo.toml
|
### ui/ — Рендеринг
|
||||||
Манифест проекта:
|
|
||||||
- Metadata (name, version, authors, license)
|
|
||||||
- Dependencies
|
|
||||||
- Build dependencies (tdlib-rs)
|
|
||||||
|
|
||||||
### rustfmt.toml
|
**Компоненты** (`ui/components/`):
|
||||||
Конфигурация `cargo fmt`:
|
| Компонент | Описание |
|
||||||
- max_width = 100
|
|-----------|----------|
|
||||||
- imports_granularity = "Crate"
|
| message_bubble | Рендеринг пузыря сообщения с реакциями |
|
||||||
- Стиль комментариев
|
| message_list | Элемент списка сообщений (search/pinned) |
|
||||||
|
| chat_list_item | Элемент списка чатов |
|
||||||
|
| input_field | Поле ввода с курсором |
|
||||||
|
| emoji_picker | Сетка выбора реакций |
|
||||||
|
| modal | Центрированная модалка |
|
||||||
|
|
||||||
### .editorconfig
|
### config/ — Конфигурация
|
||||||
Универсальные настройки для IDE:
|
|
||||||
- Unix line endings (LF)
|
|
||||||
- UTF-8 encoding
|
|
||||||
- Отступы (4 spaces для Rust)
|
|
||||||
|
|
||||||
## Рантайм файлы
|
- **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/
|
**500+ тестов** через `cargo test` (без TDLib).
|
||||||
XDG config directory:
|
|
||||||
- `config.toml` — пользовательская конфигурация
|
|
||||||
- `credentials` — API_ID и API_HASH
|
|
||||||
|
|
||||||
## Документация
|
**Инфраструктура**:
|
||||||
|
- `TestAppBuilder` — fluent API для создания App с нужным состоянием
|
||||||
|
- `FakeTdClient` — мок TDLib, реализует TdClientTrait
|
||||||
|
- `TestMessageBuilder` — создание тестовых сообщений
|
||||||
|
|
||||||
### Пользовательская
|
**Типы тестов**:
|
||||||
- **README.md** — главная страница, overview
|
- Unit-тесты — в `#[cfg(test)]` секциях модулей
|
||||||
- **INSTALL.md** — установка и настройка
|
- Integration-тесты — в `tests/` (навигация, отправка, UI рендеринг)
|
||||||
- **HOTKEYS.md** — все горячие клавиши
|
- Doc-тесты — примеры в документации
|
||||||
- **FAQ.md** — часто задаваемые вопросы
|
- E2E — smoke и user journey тесты
|
||||||
|
|
||||||
### Разработчика
|
|
||||||
- **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>,
|
|
||||||
view_image_mode: Option<ViewImageState>, // PLANNED - Фаза 11
|
|
||||||
|
|
||||||
// Search
|
|
||||||
search_query: String,
|
|
||||||
search_results: Vec<i64>,
|
|
||||||
|
|
||||||
// Drafts
|
|
||||||
drafts: HashMap<i64, String>,
|
|
||||||
|
|
||||||
// Audio (PLANNED - Фаза 12)
|
|
||||||
audio_player: Option<AudioPlayer>,
|
|
||||||
playback_state: Option<PlaybackState>,
|
|
||||||
voice_cache: VoiceCache,
|
|
||||||
|
|
||||||
// Media (PLANNED - Фаза 11)
|
|
||||||
image_loader: ImageLoader,
|
|
||||||
image_protocol: StatefulProtocol, // Terminal capabilities
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Потоки выполнения
|
## Потоки выполнения
|
||||||
|
|
||||||
### Main thread
|
```
|
||||||
- Event loop (16ms tick для 60 FPS)
|
Main thread TDLib thread
|
||||||
- UI rendering
|
│ │
|
||||||
- Input handling
|
│ ◄── mpsc ─────── │ td_client.receive() в Tokio task
|
||||||
- App state updates
|
│ │
|
||||||
|
├── poll events │
|
||||||
|
├── handle input │
|
||||||
|
├── update state │
|
||||||
|
├── render UI │
|
||||||
|
└── sleep 16ms ──► │
|
||||||
|
```
|
||||||
|
|
||||||
### TDLib thread
|
## Рантайм файлы
|
||||||
- `td_client.receive()` в отдельном Tokio task
|
|
||||||
- Updates отправляются через `mpsc::channel`
|
|
||||||
- Неблокирующий для main thread
|
|
||||||
|
|
||||||
### Blocking operations
|
| Путь | Описание |
|
||||||
- Загрузка конфига (при запуске)
|
|------|----------|
|
||||||
- Авторизация (блокирует до ввода кода)
|
| `~/.config/tele-tui/config.toml` | Пользовательская конфигурация |
|
||||||
- Graceful shutdown (2 sec timeout)
|
| `~/.config/tele-tui/credentials` | API_ID и API_HASH |
|
||||||
|
| `tdlib_data/` | TDLib сессия (НЕ коммитится) |
|
||||||
|
|
||||||
## Зависимости
|
## Зависимости
|
||||||
|
|
||||||
### UI
|
| Категория | Крейт | Назначение |
|
||||||
- `ratatui` 0.29 — TUI framework
|
|-----------|-------|------------|
|
||||||
- `crossterm` 0.28 — terminal control
|
| UI | ratatui 0.29 | TUI framework |
|
||||||
- `ratatui-image` 1.0 — отображение изображений в TUI (PLANNED)
|
| UI | crossterm 0.28 | Terminal control |
|
||||||
|
| Telegram | tdlib-rs 1.1 | TDLib bindings |
|
||||||
### Audio (PLANNED)
|
| Async | tokio 1.x | Async runtime |
|
||||||
- `rodio` 0.17 — Pure Rust аудио библиотека (кроссплатформенная)
|
| Config | serde + toml | Serialization |
|
||||||
|
| Time | chrono 0.4 | Date/time |
|
||||||
### Media (PLANNED)
|
| System | dirs 5.0 | XDG directories |
|
||||||
- `image` — загрузка и обработка изображений
|
| System | arboard 3.4 | Clipboard |
|
||||||
- `ratatui-image` — рендеринг в ratatui с поддержкой Sixel/Kitty/iTerm2
|
| Notify | notify-rust 4.11 | Desktop уведомления (feature) |
|
||||||
|
| URL | open 5.0 | Открытие URL (feature) |
|
||||||
### Notifications
|
|
||||||
- `notify-rust` 4.11 — desktop уведомления (feature flag)
|
|
||||||
|
|
||||||
### 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/`
|
|
||||||
|
|||||||
@@ -1,855 +0,0 @@
|
|||||||
# Возможности для рефакторинга
|
|
||||||
|
|
||||||
> Результаты аудита кодовой базы от 2026-02-02
|
|
||||||
> Обновлено: 2026-02-04
|
|
||||||
> Статус: В работе (2/10 категорий полностью завершены, 3 частично)
|
|
||||||
|
|
||||||
## Оглавление
|
|
||||||
|
|
||||||
1. [Дублирование кода](#1-дублирование-кода)
|
|
||||||
2. [Большие файлы/функции](#2-большие-файлыфункции)
|
|
||||||
3. [Сложная вложенность](#3-сложная-вложенность)
|
|
||||||
4. [Нарушение Single Responsibility](#4-нарушение-single-responsibility)
|
|
||||||
5. [Плохая инкапсуляция](#5-плохая-инкапсуляция)
|
|
||||||
6. [Отсутствующие абстракции](#6-отсутствующие-абстракции)
|
|
||||||
7. [Несогласованность](#7-несогласованность)
|
|
||||||
8. [Перекрытие функциональности](#8-перекрытие-функциональности)
|
|
||||||
9. [Проблемы производительности](#9-проблемы-производительности)
|
|
||||||
10. [Отсутствующие архитектурные паттерны](#10-отсутствующие-архитектурные-паттерны)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Дублирование кода
|
|
||||||
|
|
||||||
**Приоритет:** 🔴 Высокий
|
|
||||||
**Статус:** ✅ ПОЛНОСТЬЮ ЗАВЕРШЕНО! (2026-02-02)
|
|
||||||
**Объем:** 15-20% кодовой базы → Устранено!
|
|
||||||
|
|
||||||
### Проблемы
|
|
||||||
|
|
||||||
- **Timeout/Retry паттерны** (~20 экземпляров в обработке ввода)
|
|
||||||
- Повторяющаяся логика таймаутов в `src/input/main_input.rs`
|
|
||||||
- Одинаковые паттерны retry в разных обработчиках
|
|
||||||
|
|
||||||
- **Обработка модальных окон** (5+ мест)
|
|
||||||
- Логика открытия/закрытия модалок дублируется
|
|
||||||
- Валидация ввода в модальных окнах повторяется
|
|
||||||
- Обработка Escape для закрытия модалок в каждом месте
|
|
||||||
|
|
||||||
- **Паттерны валидации**
|
|
||||||
- Проверка пустых строк
|
|
||||||
- Валидация ID чатов/сообщений
|
|
||||||
- Проверка длины текста
|
|
||||||
|
|
||||||
### Решение
|
|
||||||
|
|
||||||
- [x] Создать `retry_utils.rs` с функциями `with_timeout()`, `with_retry()` - **Выполнено и интегрировано** (2026-02-02)
|
|
||||||
- Создан `src/utils/retry.rs` с тремя функциями: `with_timeout()`, `with_timeout_msg()`, `with_timeout_ignore()`
|
|
||||||
- Заменены ВСЕ прямые использования `tokio::time::timeout` (8+ мест: main_input.rs, auth.rs, main.rs)
|
|
||||||
- Код стал чище и короче (убрано вложенное Ok/Err матчинг)
|
|
||||||
- **100% покрытие** - больше нет прямых timeout вызовов
|
|
||||||
- [x] Создать `modal_handler.rs` с общей логикой модальных окон - **Выполнено** (2026-02-01)
|
|
||||||
- Создан `src/utils/modal_handler.rs` (120+ строк)
|
|
||||||
- 4 функции: `handle_modal_key()`, `should_close_modal()`, `should_confirm_modal()`, `handle_yes_no()`
|
|
||||||
- Enum `ModalAction` для type-safe обработки
|
|
||||||
- Поддержка английской и русской раскладки (y/д, n/т)
|
|
||||||
- 4 unit теста (все проходят)
|
|
||||||
- [x] Создать `validation.rs` с переиспользуемыми валидаторами - **Выполнено и интегрировано** (2026-02-02)
|
|
||||||
- Создан `src/utils/validation.rs` (180+ строк)
|
|
||||||
- 7 функций валидации: `is_non_empty()`, `is_within_length()`, `is_valid_chat_id()`, `is_valid_message_id()`, `is_valid_user_id()`, `has_items()`, `validate_text_input()`
|
|
||||||
- Покрывает все основные паттерны валидации
|
|
||||||
- 7 unit тестов (все проходят)
|
|
||||||
- **Интегрировано в 4 местах:** auth.rs (phone/code/password), main_input.rs (message validation)
|
|
||||||
|
|
||||||
### Файлы
|
|
||||||
|
|
||||||
- `src/input/main_input.rs`
|
|
||||||
- `src/app/handlers/*.rs`
|
|
||||||
- `src/ui/modals/*.rs`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Большие файлы/функции
|
|
||||||
|
|
||||||
**Приоритет:** 🔴 Высокий
|
|
||||||
**Статус:** ✅ **ПОЛНОСТЬЮ ЗАВЕРШЕНО!** (обновлено 2026-02-04)
|
|
||||||
**Объем:** Все 4 файла отрефакторены! (4/4, 100%! 🎉)
|
|
||||||
|
|
||||||
### Проблемы
|
|
||||||
|
|
||||||
| Файл | Строки | Проблема | Статус |
|
|
||||||
|------|--------|----------|--------|
|
|
||||||
| `src/input/main_input.rs` | ~~1164~~ → ~1200 | ~~Одна функция `handle()` на ~800 строк~~ | ✅ **РЕШЕНО** (handle() → 82 строки) |
|
|
||||||
| `src/tdlib/client.rs` | ~~1259~~ → 599 | ~~Смешение facade и бизнес-логики~~ | ✅ **РЕШЕНО** (1259 → 599 строк, -52%) |
|
|
||||||
| `src/ui/messages.rs` | 905 | ~~Рендеринг всех типов сообщений~~ | ✅ **НЕ ТРЕБУЕТСЯ** (render() → 92 строки, Phase 5) |
|
|
||||||
| `src/tdlib/messages.rs` | ~~850~~ → 757 | ~~Обработка всех типов обновлений сообщений~~ | ✅ **РЕШЕНО** (convert_message() → 57 строк, -62%) |
|
|
||||||
|
|
||||||
### Решение
|
|
||||||
|
|
||||||
#### 2.1. Разделить `src/input/main_input.rs` - ✅ **ЗАВЕРШЕНО** (2026-02-03)
|
|
||||||
|
|
||||||
**Phase 1-2** (2026-02-02):
|
|
||||||
- [x] Создана структура `src/input/handlers/` (7 модулей) - ПОДГОТОВКА
|
|
||||||
- [x] Создан `handlers/clipboard.rs` (~100 строк) - извлечён из main_input
|
|
||||||
- [x] Создан `handlers/global.rs` (~90 строк) - извлечён из main_input
|
|
||||||
- [x] Созданы заглушки: `profile.rs`, `search.rs`, `modal.rs`, `messages.rs`, `chat_list.rs`
|
|
||||||
|
|
||||||
**Phase 2-3** (2026-02-03):
|
|
||||||
- [x] **Извлечено 13 специализированных функций-обработчиков** (~946 строк):
|
|
||||||
- `handle_open_chat_keyboard_input()` (~129 строк)
|
|
||||||
- `handle_chat_list_navigation()` (~34 строки)
|
|
||||||
- `handle_profile_mode()` (~120 строк)
|
|
||||||
- `handle_message_search_mode()` (~73 строки)
|
|
||||||
- `handle_pinned_mode()` (~42 строки)
|
|
||||||
- `handle_reaction_picker_mode()` (~90 строк)
|
|
||||||
- `handle_delete_confirmation()` (~60 строк)
|
|
||||||
- `handle_forward_mode()` (~52 строки)
|
|
||||||
- `handle_chat_search_mode()` (~43 строки)
|
|
||||||
- `handle_enter_key()` (~145 строк)
|
|
||||||
- `handle_escape_key()` (~35 строк)
|
|
||||||
- `handle_message_selection()` (~95 строк)
|
|
||||||
- `handle_profile_open()` (~28 строк)
|
|
||||||
|
|
||||||
**Phase 4** (2026-02-03):
|
|
||||||
- [x] **Упрощена вложенность** (early returns, let-else guards)
|
|
||||||
- [x] **Извлечено 3 вспомогательных функции**:
|
|
||||||
- `edit_message()` (~50 строк)
|
|
||||||
- `send_new_message()` (~55 строк)
|
|
||||||
- `perform_message_search()` (~20 строк)
|
|
||||||
|
|
||||||
**Итоговый результат**:
|
|
||||||
- ✅ Функция `handle()` сократилась с **891 до 82 строк** (91% сокращение! 🎉)
|
|
||||||
- ✅ Глубина вложенности: **6+ уровней → 2-3 уровня**
|
|
||||||
- ✅ Все 196 тестов проходят успешно
|
|
||||||
- ✅ Код стал **линейным и простым для понимания**
|
|
||||||
|
|
||||||
**Примечание**: Вместо создания отдельных файлов в handlers/ (что привело бы к поломке), мы выбрали подход извлечения функций внутри main_input.rs. Это позволило радикально упростить код без риска регрессий.
|
|
||||||
|
|
||||||
#### 2.2. Разделить `src/tdlib/client.rs` - ✅ **ЗАВЕРШЕНО** (2026-02-04)
|
|
||||||
|
|
||||||
**Этап 1** (2026-02-04): Извлечение Update Handlers
|
|
||||||
- [x] Создан модуль `src/tdlib/update_handlers.rs` (302 строки)
|
|
||||||
- [x] **Извлечено 8 handler функций** (~350 строк):
|
|
||||||
- `handle_new_message_update()` — добавление новых сообщений (44 строки)
|
|
||||||
- `handle_chat_action_update()` — статус набора текста (32 строки)
|
|
||||||
- `handle_chat_position_update()` — управление позициями чатов (36 строк)
|
|
||||||
- `handle_user_update()` — обработка информации о пользователях (40 строк)
|
|
||||||
- `handle_message_interaction_info_update()` — обновление реакций (44 строки)
|
|
||||||
- `handle_message_send_succeeded_update()` — успешная отправка (35 строк)
|
|
||||||
- `handle_chat_draft_message_update()` — черновики сообщений (15 строк)
|
|
||||||
- `handle_auth_state()` — изменение состояния авторизации (10 строк)
|
|
||||||
- [x] Обновлён `handle_update()` для делегирования в update_handlers
|
|
||||||
- [x] Результат: **client.rs 1259 → 983 строки** (22% сокращение)
|
|
||||||
|
|
||||||
**Этап 2** (2026-02-04): Извлечение Message Converter
|
|
||||||
- [x] Создан модуль `src/tdlib/message_converter.rs` (250 строк)
|
|
||||||
- [x] **Извлечено 6 conversion функций** (~240 строк):
|
|
||||||
- `convert_message()` — основная конвертация TDLib → MessageInfo (150+ строк)
|
|
||||||
- `extract_reply_info()` — извлечение reply информации (30 строк)
|
|
||||||
- `extract_forward_info()` — извлечение forward информации (25 строк)
|
|
||||||
- `extract_reactions()` — извлечение реакций (20 строк)
|
|
||||||
- `get_origin_sender_name()` — получение имени отправителя (15 строк)
|
|
||||||
- `update_reply_info_from_loaded_messages()` — обновление reply из кэша (30 строк)
|
|
||||||
- [x] Исправлены ошибки компиляции с неверными именами полей
|
|
||||||
- [x] Обновлены вызовы в update_handlers.rs
|
|
||||||
- [x] Результат: **client.rs 983 → 754 строки** (23% сокращение)
|
|
||||||
|
|
||||||
**Этап 3** (2026-02-04): Извлечение Chat Helpers
|
|
||||||
- [x] Создан модуль `src/tdlib/chat_helpers.rs` (149 строк)
|
|
||||||
- [x] **Извлечено 3 helper функции** (~140 строк):
|
|
||||||
- `find_chat_mut()` — поиск чата по ID (15 строк)
|
|
||||||
- `update_chat()` — обновление чата через closure (15 строк, используется 9+ раз)
|
|
||||||
- `add_or_update_chat()` — добавление/обновление чата в списке (110+ строк)
|
|
||||||
- [x] Использован sed для замены вызовов методов по всей кодовой базе
|
|
||||||
- [x] Результат: **client.rs 754 → 599 строк** (21% сокращение)
|
|
||||||
|
|
||||||
**Итоговый результат**:
|
|
||||||
- ✅ Файл `client.rs` сократился с **1259 до 599 строк** (52% сокращение! 🎉)
|
|
||||||
- ✅ Создано **3 новых модуля** с чёткой ответственностью:
|
|
||||||
- `update_handlers.rs` — обработка всех типов TDLib Update
|
|
||||||
- `message_converter.rs` — конвертация TDLib Message → MessageInfo
|
|
||||||
- `chat_helpers.rs` — утилиты для работы с чатами
|
|
||||||
- ✅ Все **590+ тестов** проходят успешно
|
|
||||||
- ✅ Код стал **модульным и лучше организованным**
|
|
||||||
- ✅ `TdClient` теперь ближе к **facade pattern** (делегирует в специализированные модули)
|
|
||||||
|
|
||||||
#### 2.3. Упростить `src/ui/messages.rs` - ✅ **ЗАВЕРШЕНО** (Phase 5, 2026-02-03)
|
|
||||||
|
|
||||||
**Уже выполнено в Phase 5**:
|
|
||||||
- [x] Извлечены 4 функции рендеринга (~350 строк):
|
|
||||||
- `render_chat_header()` — заголовок с typing status (~47 строк)
|
|
||||||
- `render_pinned_bar()` — панель закреплённого сообщения (~30 строк)
|
|
||||||
- `render_message_list()` — список сообщений с автоскроллом (~98 строк)
|
|
||||||
- `render_input_box()` — input с режимами (forward/select/edit/reply) (~146 строк)
|
|
||||||
- [x] Функция `render()` сократилась с **390 до 92 строк** (76% сокращение! 🎉)
|
|
||||||
- [x] Глубина вложенности: **6+ уровней → 2-3 уровня**
|
|
||||||
- [x] Код стал **модульным и простым для понимания**
|
|
||||||
|
|
||||||
**Итоговый результат**:
|
|
||||||
- ✅ Файл остался цельным (905 строк), но хорошо организован
|
|
||||||
- ✅ Главная функция `render()` компактная (92 строки)
|
|
||||||
- ✅ Все вспомогательные функции извлечены (render_search_mode, render_pinned_mode, и др.)
|
|
||||||
- ✅ **Дальнейшее разделение не требуется** — цели достигнуты
|
|
||||||
|
|
||||||
#### 2.4. Упростить `src/tdlib/messages.rs` - ✅ **ЗАВЕРШЕНО** (2026-02-04)
|
|
||||||
|
|
||||||
**Этап 1** (2026-02-04): Извлечение Message Conversion Helpers
|
|
||||||
- [x] Создан модуль `src/tdlib/message_conversion.rs` (158 строк)
|
|
||||||
- [x] **Извлечено 6 вспомогательных функций**:
|
|
||||||
- `extract_content_text()` — извлечение текста из различных типов сообщений (~80 строк)
|
|
||||||
- `extract_entities()` — извлечение форматирования (~10 строк)
|
|
||||||
- `extract_sender_name()` — получение имени отправителя с API вызовом (~15 строк)
|
|
||||||
- `extract_forward_info()` — информация о пересылке (~12 строк)
|
|
||||||
- `extract_reply_info()` — информация об ответе (~15 строк)
|
|
||||||
- `extract_reactions()` — реакции на сообщение (~26 строк)
|
|
||||||
- [x] Метод `convert_message()` сократился с **150 до 57 строк** (62% сокращение! 🎉)
|
|
||||||
- [x] Результат: **messages.rs 850 → 757 строк** (11% сокращение)
|
|
||||||
|
|
||||||
**Итоговый результат**:
|
|
||||||
- ✅ Файл `messages.rs` сократился до **757 строк**
|
|
||||||
- ✅ Создан модуль **message_conversion.rs** с переиспользуемыми функциями
|
|
||||||
- ✅ Метод `convert_message()` теперь **компактный и читаемый** (57 строк)
|
|
||||||
- ✅ Все **629 тестов** проходят успешно
|
|
||||||
- ✅ **Дальнейшее разделение не требуется** — MessageManager хорошо организован
|
|
||||||
|
|
||||||
### Файлы
|
|
||||||
|
|
||||||
- `src/input/main_input.rs`
|
|
||||||
- `src/tdlib/client.rs`
|
|
||||||
- `src/ui/messages.rs`
|
|
||||||
- `src/tdlib/messages.rs`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Сложная вложенность
|
|
||||||
|
|
||||||
**Приоритет:** 🟡 Средний
|
|
||||||
**Статус:** ✅ **ПОЛНОСТЬЮ ЗАВЕРШЕНО!** (обновлено 2026-02-04)
|
|
||||||
**Объем:** ~30 функций → 0 функций (все проблемные решены)
|
|
||||||
|
|
||||||
### Проблемы
|
|
||||||
|
|
||||||
- ~~4-5 уровней вложенности в обработке ввода~~ ✅ **Решено в main_input.rs**
|
|
||||||
- Глубокая вложенность в обработке обновлений TDLib
|
|
||||||
- ~~Множественные `if let` / `match` вложенные друг в друга~~ ✅ **Решено в main_input.rs**
|
|
||||||
|
|
||||||
### Примеры
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// src/input/main_input.rs - было (типичный пример)
|
|
||||||
if let Some(chat_id) = app.selected_chat {
|
|
||||||
if let Some(message_id) = app.selected_message {
|
|
||||||
if app.is_message_outgoing(chat_id, message_id) {
|
|
||||||
match key.code {
|
|
||||||
// еще больше вложенности (6+ уровней)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Стало (после Phase 4 рефакторинга)
|
|
||||||
let Some(chat_id) = app.selected_chat else { return Ok(false) };
|
|
||||||
let Some(message_id) = app.selected_message else { return Ok(false) };
|
|
||||||
|
|
||||||
if !app.is_message_outgoing(chat_id, message_id) {
|
|
||||||
return Ok(false); // early return
|
|
||||||
}
|
|
||||||
// Линейная логика (2-3 уровня максимум)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Решение
|
|
||||||
|
|
||||||
#### Выполнено в `src/input/main_input.rs` (2026-02-03)
|
|
||||||
|
|
||||||
- [x] **Применены early returns** - уменьшили вложенность с 6+ до 2-3 уровней
|
|
||||||
- [x] **Извлечена вложенная логика** в 3 функции:
|
|
||||||
- `edit_message()` — редактирование сообщения (~50 строк)
|
|
||||||
- `send_new_message()` — отправка нового сообщения (~55 строк)
|
|
||||||
- `perform_message_search()` — поиск по сообщениям (~20 строк)
|
|
||||||
- [x] **Использованы let-else guard clauses** — современный Rust паттерн
|
|
||||||
- [x] **Упрощены 6 функций**:
|
|
||||||
- `handle_profile_mode()` — упрощён блок Enter с let-else
|
|
||||||
- `handle_profile_open()` — применён early return guard
|
|
||||||
- `handle_enter_key()` — разделена на части, сокращена с ~130 до ~40 строк
|
|
||||||
- `handle_message_search_mode()` — извлечена логика поиска
|
|
||||||
- `handle_escape_key()` — преобразован в early returns
|
|
||||||
- `handle_message_selection()` — применены let-else guards
|
|
||||||
|
|
||||||
**Результат Phase 4**:
|
|
||||||
- ✅ Глубина вложенности: **6+ уровней → 2-3 уровня**
|
|
||||||
- ✅ Код стал **максимально линейным и читаемым**
|
|
||||||
- ✅ Применены современные Rust паттерны (let-else, guards)
|
|
||||||
|
|
||||||
#### Выполнено в `src/tdlib/client.rs` (2026-02-03, Этап 3)
|
|
||||||
|
|
||||||
- [x] **Добавлены helper методы** для устранения дублирования:
|
|
||||||
- `find_chat_mut()` — поиск чата по ID
|
|
||||||
- `update_chat()` — обновление чата через closure (использовано 9+ раз)
|
|
||||||
- [x] **Извлечено 5 handler методов** из `handle_update()`:
|
|
||||||
- `handle_chat_position_update()` — управление позициями чатов (43 строки)
|
|
||||||
- `handle_user_update()` — обработка информации о пользователях (46 строк)
|
|
||||||
- `handle_message_interaction_info_update()` — обновление реакций (44 строки)
|
|
||||||
- `handle_message_send_succeeded_update()` — успешная отправка (38 строк)
|
|
||||||
- `handle_chat_draft_message_update()` — черновики (18 строк)
|
|
||||||
- [x] **Упрощено 7 функций** с применением let-else guards, early returns, unwrap_or_else:
|
|
||||||
- `handle_chat_action_update()` — статус набора текста (4 → 2 уровня)
|
|
||||||
- `handle_new_message_update()` — добавление сообщений (3 → 2 уровня)
|
|
||||||
- `handle_chat_draft_message_update()` — черновики (if-let → match)
|
|
||||||
- `handle_user_update()` — usernames (вложенные if-let → and_then)
|
|
||||||
- `convert_message()` — кэш имён (if-let → unwrap_or_else)
|
|
||||||
- `extract_reply_info()` — reply информация (вложенные if-let → map/or_else)
|
|
||||||
- `update_reply_info_from_loaded_messages()` — обновление reply (4 → 1-2 уровня)
|
|
||||||
|
|
||||||
**Результат Этапа 3 (client.rs)**:
|
|
||||||
- ✅ Функция `handle_update()` сократилась с **268 до 122 строк** (54% сокращение!)
|
|
||||||
- ✅ Устранено дублирование: ~9 повторений pattern → 2 helper метода
|
|
||||||
- ✅ Глубина вложенности: **4-5 уровней → 2-3 уровня**
|
|
||||||
- ✅ Применены modern patterns: let-else guards, early returns, filter chains
|
|
||||||
|
|
||||||
#### Дополнительные улучшения вложенности (2026-02-04)
|
|
||||||
|
|
||||||
- [x] **Упрощена `src/tdlib/messages.rs`** (строки 718-755)
|
|
||||||
- `fetch_missing_reply_info()`: 7 уровней → 2-3 уровня
|
|
||||||
- Извлечена функция `fetch_and_update_reply()`
|
|
||||||
- Использованы let-else guards и iterator chains
|
|
||||||
- Максимальная вложенность: **44 → 28 пробелов**
|
|
||||||
|
|
||||||
- [x] **Упрощена `src/tdlib/messages.rs`** (строки 147-182)
|
|
||||||
- `get_chat_history()` retry loop: 6 уровней → 3 уровня
|
|
||||||
- Извлечен `messages_obj` после match
|
|
||||||
- Early continue для пустых результатов
|
|
||||||
- Использован `.flatten()` вместо вложенного if-let
|
|
||||||
|
|
||||||
- [x] **Упрощена `src/input/main_input.rs`** (строки 500-546)
|
|
||||||
- `handle_forward_mode()`: 7 уровней → 2-3 уровня
|
|
||||||
- Извлечена функция `forward_selected_message()`
|
|
||||||
- Использованы early returns (let-else guards)
|
|
||||||
- Максимальная вложенность: **40 → 36 пробелов**
|
|
||||||
|
|
||||||
- [x] **Упрощена `src/input/main_input.rs`** (reaction picker)
|
|
||||||
- Извлечена функция `send_reaction()`
|
|
||||||
- Использованы let-else guards
|
|
||||||
- Вложенность: 5 уровней → 2-3 уровня
|
|
||||||
|
|
||||||
- [x] **Упрощена `src/input/main_input.rs`** (scroll + load older)
|
|
||||||
- Извлечена функция `load_older_messages_if_needed()`
|
|
||||||
- Использованы early returns
|
|
||||||
- Вложенность: 6 уровней → 2-3 уровня
|
|
||||||
|
|
||||||
- [x] **Упрощена `src/config.rs`** (строки 563-609)
|
|
||||||
- `load_credentials()`: 7 уровней → 2-3 уровня
|
|
||||||
- Извлечены функции `load_credentials_from_file()` и `load_credentials_from_env()`
|
|
||||||
- Использованы `?` operator для Option chains
|
|
||||||
- Максимальная вложенность: **36 → 32 пробелов**
|
|
||||||
|
|
||||||
**Итоговый результат**:
|
|
||||||
- ✅ Все файлы с вложенностью >32 пробелов обработаны
|
|
||||||
- ✅ Применены современные Rust паттерны (let-else guards, early returns, ? operator, iterator chains)
|
|
||||||
- ✅ Извлечено 8 новых функций для разделения ответственности
|
|
||||||
- ✅ Максимальная вложенность во всем проекте: **≤32 пробелов (8 уровней)**
|
|
||||||
|
|
||||||
### Файлы
|
|
||||||
|
|
||||||
- ✅ `src/input/main_input.rs` — **ПОЛНОСТЬЮ ЗАВЕРШЕНО** (Phase 4 + доп. улучшения: 40 → 36 пробелов)
|
|
||||||
- ✅ `src/tdlib/client.rs` — **ЗАВЕРШЕНО** (Этап 3: 268 → 122 строки в handle_update)
|
|
||||||
- ✅ `src/tdlib/messages.rs` — **ПОЛНОСТЬЮ ЗАВЕРШЕНО** (44 → 28 пробелов)
|
|
||||||
- ✅ `src/config.rs` — **ПОЛНОСТЬЮ ЗАВЕРШЕНО** (36 → 32 пробелов)
|
|
||||||
- ✅ Все остальные модули — **проверены, вложенность приемлема** (≤32 пробелов)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Нарушение Single Responsibility
|
|
||||||
|
|
||||||
**Приоритет:** 🟡 Средний
|
|
||||||
**Статус:** ❌ Не начато
|
|
||||||
**Объем:** 2 основных структуры
|
|
||||||
|
|
||||||
### Проблемы
|
|
||||||
|
|
||||||
#### 4.1. `App` struct (50+ методов)
|
|
||||||
|
|
||||||
Смешивает ответственности:
|
|
||||||
- UI state management
|
|
||||||
- Business logic
|
|
||||||
- TDLib interaction
|
|
||||||
- Input handling
|
|
||||||
- Search logic
|
|
||||||
- Profile management
|
|
||||||
- Folder management
|
|
||||||
|
|
||||||
#### 4.2. `TdClient` (facade + бизнес-логика)
|
|
||||||
|
|
||||||
Смешивает:
|
|
||||||
- Facade pattern (делегирование)
|
|
||||||
- Update processing
|
|
||||||
- Cache management
|
|
||||||
- Network operations
|
|
||||||
|
|
||||||
### Решение
|
|
||||||
|
|
||||||
#### Разделить `App`
|
|
||||||
|
|
||||||
- [ ] Создать `ChatListState` (состояние списка чатов)
|
|
||||||
- [ ] Создать `MessageViewState` (состояние просмотра сообщений)
|
|
||||||
- [ ] Создать `ComposeState` (состояние написания сообщения)
|
|
||||||
- [ ] Создать `SearchState` (состояние поиска)
|
|
||||||
- [ ] Создать `ProfileState` (состояние профиля)
|
|
||||||
- [ ] `App` становится координатором этих state объектов
|
|
||||||
|
|
||||||
#### Разделить `TdClient`
|
|
||||||
|
|
||||||
- [ ] `TdClient` только facade (делегирование)
|
|
||||||
- [ ] Бизнес-логика в `MessageService`, `ChatService`, etc.
|
|
||||||
- [ ] Update processing в отдельном модуле
|
|
||||||
|
|
||||||
### Файлы
|
|
||||||
|
|
||||||
- `src/app/mod.rs`
|
|
||||||
- `src/tdlib/client.rs`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Плохая инкапсуляция
|
|
||||||
|
|
||||||
**Приоритет:** 🔴 Высокий
|
|
||||||
**Статус:** ✅ Частично выполнено (2026-02-01)
|
|
||||||
**Объем:** Вся структура `App`
|
|
||||||
|
|
||||||
### Проблемы
|
|
||||||
|
|
||||||
- **22 публичных поля** в `App`
|
|
||||||
```rust
|
|
||||||
pub struct App {
|
|
||||||
pub td_client: TdClient,
|
|
||||||
pub chats: Vec<ChatInfo>,
|
|
||||||
pub selected_chat: Option<ChatId>,
|
|
||||||
pub messages: HashMap<ChatId, Vec<MessageInfo>>,
|
|
||||||
// ... еще 18 полей
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Прямой доступ везде**
|
|
||||||
```rust
|
|
||||||
app.selected_chat = Some(chat_id); // Плохо
|
|
||||||
app.chats.push(new_chat); // Плохо
|
|
||||||
app.messages.clear(); // Плохо
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Тесты манипулируют внутренностями**
|
|
||||||
```rust
|
|
||||||
app.td_client.user_cache.chat_user_ids.insert(...); // Слишком глубоко
|
|
||||||
```
|
|
||||||
|
|
||||||
### Решение
|
|
||||||
|
|
||||||
- [x] Сделать критичные поля приватными - **Частично выполнено** (2026-02-01)
|
|
||||||
- ✅ `config` сделан приватным (readonly через getter `app.config()`)
|
|
||||||
- ✅ Добавлены 30+ методов-геттеров и сеттеров для всех полей
|
|
||||||
- ⏳ Остальные поля оставлены pub для совместимости (требуется массовый рефакторинг)
|
|
||||||
- [x] Добавить getter методы где нужно - **Выполнено**
|
|
||||||
- 30+ методов: `phone_input()`, `set_phone_input()`, `screen()`, `set_screen()`, `is_loading()`, и т.д.
|
|
||||||
- [ ] Полная инкапсуляция всех полей (требует обновления 170+ мест в коде)
|
|
||||||
- [ ] Создать методы для операций (вместо прямого доступа)
|
|
||||||
```rust
|
|
||||||
// Вместо app.selected_chat = Some(chat_id)
|
|
||||||
app.select_chat(chat_id); // Уже есть!
|
|
||||||
|
|
||||||
// Вместо app.chats.push(new_chat)
|
|
||||||
app.add_chat(new_chat); // TODO
|
|
||||||
```
|
|
||||||
|
|
||||||
### Файлы
|
|
||||||
|
|
||||||
- `src/app/mod.rs`
|
|
||||||
- `src/app/state.rs` (новый)
|
|
||||||
- Все тесты
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Отсутствующие абстракции
|
|
||||||
|
|
||||||
**Приоритет:** 🟡 Средний
|
|
||||||
**Статус:** ✅ Частично выполнено (2026-02-04)
|
|
||||||
**Объем:** 3 основные абстракции (2/3 завершены, 1/3 уже была)
|
|
||||||
|
|
||||||
### Проблемы
|
|
||||||
|
|
||||||
#### 6.1. Создать `KeyHandler` trait ✅ ЗАВЕРШЕНО! (2026-02-04)
|
|
||||||
|
|
||||||
- [x] Создать `src/input/key_handler.rs` - **Выполнено** (380+ строк)
|
|
||||||
- Enum `KeyResult` (Handled, HandledNeedsRedraw, NotHandled, Quit)
|
|
||||||
- Trait `KeyHandler` с методом `handle_key()` и `priority()`
|
|
||||||
- Struct `GlobalKeyHandler` - обработчик глобальных команд (Quit, OpenSearch)
|
|
||||||
- Struct `ChatListKeyHandler` - навигация по списку чатов, выбор папок
|
|
||||||
- Struct `MessageViewKeyHandler` - скролл сообщений, поиск в чате
|
|
||||||
- Struct `MessageSelectionKeyHandler` - действия с выбранным сообщением
|
|
||||||
- Struct `KeyHandlerChain` - цепочка обработчиков с приоритетами
|
|
||||||
- 3 unit теста (все проходят)
|
|
||||||
- [ ] Интегрировать в main_input.rs (заменить текущую логику)
|
|
||||||
- [ ] Добавить недостающие методы в App (enter_search_mode и т.д.)
|
|
||||||
|
|
||||||
#### 6.2. Нет абстракции для network operations
|
|
||||||
|
|
||||||
Timeout/retry логика дублируется:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// Повторяется ~20 раз
|
|
||||||
let result = tokio::time::timeout(
|
|
||||||
Duration::from_millis(100),
|
|
||||||
operation()
|
|
||||||
).await;
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 6.3. Хардкод горячих клавиш
|
|
||||||
|
|
||||||
Невозможно изменить без правки кода:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
KeyCode::Char('e') => edit_message(), // Хардкод
|
|
||||||
KeyCode::Char('d') => delete_message(), // Хардкод
|
|
||||||
```
|
|
||||||
|
|
||||||
### Решение
|
|
||||||
|
|
||||||
#### 6.1. Создать `KeyHandler` trait
|
|
||||||
|
|
||||||
- [ ] Создать `src/input/key_handler.rs`
|
|
||||||
```rust
|
|
||||||
trait KeyHandler {
|
|
||||||
fn handle_key(&mut self, app: &mut App, key: KeyEvent) -> Result<bool>;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
- [ ] Реализовать для каждого экрана:
|
|
||||||
- `ChatListKeyHandler`
|
|
||||||
- `MessagesKeyHandler`
|
|
||||||
- `ComposeKeyHandler`
|
|
||||||
- `SearchKeyHandler`
|
|
||||||
|
|
||||||
#### 6.2. Создать network utilities
|
|
||||||
|
|
||||||
- [ ] Создать `src/utils/network.rs`
|
|
||||||
```rust
|
|
||||||
async fn with_timeout<F, T>(f: F, timeout_ms: u64) -> Result<T>
|
|
||||||
async fn with_retry<F, T>(f: F, max_retries: u32) -> Result<T>
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 6.3. Создать систему горячих клавиш ✅ ЗАВЕРШЕНО! (2026-02-04)
|
|
||||||
|
|
||||||
- [x] Создать `src/config/keybindings.rs` - **Выполнено**
|
|
||||||
- Enum `Command` с 40+ командами (навигация, чат, сообщения, input)
|
|
||||||
- Struct `KeyBinding` с поддержкой модификаторов (Ctrl, Shift, Alt и т.д.)
|
|
||||||
- Struct `Keybindings` с HashMap<Command, Vec<KeyBinding>>
|
|
||||||
- Поддержка множественных bindings для одной команды (EN/RU раскладки)
|
|
||||||
- Сериализация/десериализация KeyCode и KeyModifiers
|
|
||||||
- 4 unit теста (все проходят)
|
|
||||||
- [ ] Интегрировать в приложение (вместо HotkeysConfig)
|
|
||||||
- [ ] Загружать из конфига (опционально, с fallback на defaults)
|
|
||||||
|
|
||||||
### Файлы
|
|
||||||
|
|
||||||
- `src/input/key_handler.rs` (новый)
|
|
||||||
- `src/utils/network.rs` (новый)
|
|
||||||
- `src/config/keybindings.rs` (новый)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Несогласованность
|
|
||||||
|
|
||||||
**Приоритет:** 🟢 Низкий
|
|
||||||
**Статус:** ❌ Не начато
|
|
||||||
**Объем:** Вся кодовая база
|
|
||||||
|
|
||||||
### Проблемы
|
|
||||||
|
|
||||||
#### 7.1. Разные типы ошибок
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// В одних местах
|
|
||||||
Result<T, String>
|
|
||||||
|
|
||||||
// В других
|
|
||||||
Result<T, Box<dyn Error>>
|
|
||||||
|
|
||||||
// В третьих
|
|
||||||
Result<T> // с неявным типом ошибки
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 7.2. Разные паттерны state management
|
|
||||||
|
|
||||||
- В одних местах флаги (`is_editing: bool`)
|
|
||||||
- В других энумы (`EditMode::Active`)
|
|
||||||
- В третьих Option (`editing_message: Option<MessageId>`)
|
|
||||||
|
|
||||||
#### 7.3. Разные подходы к валидации
|
|
||||||
|
|
||||||
- Иногда в UI слое
|
|
||||||
- Иногда в бизнес-логике
|
|
||||||
- Иногда в обработчиках ввода
|
|
||||||
|
|
||||||
### Решение
|
|
||||||
|
|
||||||
- [ ] Стандартизировать обработку ошибок (один тип ошибки)
|
|
||||||
- [ ] Выбрать единый подход к state management (enum-based)
|
|
||||||
- [ ] Определить слой для валидации (бизнес-логика)
|
|
||||||
- [ ] Создать style guide в документации
|
|
||||||
|
|
||||||
### Файлы
|
|
||||||
|
|
||||||
- Вся кодовая база
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Перекрытие функциональности
|
|
||||||
|
|
||||||
**Приоритет:** 🟡 Средний
|
|
||||||
**Статус:** ✅ Выполнено (2026-02-04)
|
|
||||||
**Объем:** 2 основные области (2/2 завершены)
|
|
||||||
|
|
||||||
### Проблемы
|
|
||||||
|
|
||||||
#### 8.1. Централизовать фильтрацию чатов ✅ ЗАВЕРШЕНО! (2026-02-04)
|
|
||||||
|
|
||||||
- [x] Создать `src/app/chat_filter.rs` - **Выполнено** (470+ строк)
|
|
||||||
- Struct `ChatFilterCriteria` с builder pattern
|
|
||||||
- Поддержка фильтрации по: папке, поиску, pinned, unread, mentions, muted, archived
|
|
||||||
- Struct `ChatFilter` с методами фильтрации
|
|
||||||
- Enum `ChatSortOrder` для сортировки (ByLastMessage, ByTitle, ByUnreadCount, PinnedFirst)
|
|
||||||
- Методы подсчёта: count, count_unread, count_unread_mentions
|
|
||||||
- 6 unit тестов (все проходят)
|
|
||||||
- [ ] Заменить дублирующуюся логику в App и UI на ChatFilter
|
|
||||||
- [ ] Удалить старые методы фильтрации из App
|
|
||||||
|
|
||||||
#### 8.2. Централизовать обработку сообщений ✅ ЗАВЕРШЕНО! (2026-02-04)
|
|
||||||
|
|
||||||
- [x] Создать `src/app/message_service.rs` - **Выполнено** (508+ строк)
|
|
||||||
- Struct `MessageGroup` для группировки по дате
|
|
||||||
- Struct `SenderGroup` для группировки по отправителю
|
|
||||||
- Struct `MessageSearchResult` с контекстом поиска
|
|
||||||
- Struct `MessageService` с 13 методами бизнес-логики:
|
|
||||||
- `group_by_date()` - группировка сообщений по датам
|
|
||||||
- `group_by_sender()` - группировка по отправителю
|
|
||||||
- `search()` - полнотекстовый поиск с контекстом
|
|
||||||
- `find_next()` / `find_previous()` - навигация по результатам
|
|
||||||
- `filter_by_sender()` / `filter_unread()` - фильтрация
|
|
||||||
- `find_by_id()` / `find_index_by_id()` - поиск по ID
|
|
||||||
- `get_last_n()` - получение последних N сообщений
|
|
||||||
- `get_in_date_range()` - фильтрация по диапазону дат
|
|
||||||
- `count_by_sender_type()` - статистика по типам
|
|
||||||
- `create_index()` - создание быстрого индекса
|
|
||||||
- 7 unit тестов (все проходят)
|
|
||||||
- [ ] Заменить разрозненную логику в App/UI на MessageService
|
|
||||||
- [ ] Чёткое разделение: TDLib → Service → UI
|
|
||||||
|
|
||||||
### Решение
|
|
||||||
|
|
||||||
#### 8.1. Централизовать фильтрацию ✅
|
|
||||||
|
|
||||||
- [x] Создать `src/app/chat_filter.rs` - **Выполнено**
|
|
||||||
- [x] Один источник правды для фильтрации - **Выполнено**
|
|
||||||
- [ ] UI и App используют его - TODO (требует интеграции)
|
|
||||||
|
|
||||||
#### 8.2. Четко разделить слои обработки сообщений ✅
|
|
||||||
|
|
||||||
- [x] `tdlib/messages.rs` - только получение и преобразование - **Выполнено**
|
|
||||||
- [x] `app/message_service.rs` - бизнес-логика - **Выполнено**
|
|
||||||
- [x] `ui/messages.rs` - только рендеринг - **Было уже реализовано**
|
|
||||||
|
|
||||||
### Файлы
|
|
||||||
|
|
||||||
- `src/app/chat_filter.rs` (новый)
|
|
||||||
- `src/app/message_service.rs` (новый)
|
|
||||||
- `src/tdlib/messages.rs`
|
|
||||||
- `src/ui/messages.rs`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Проблемы производительности
|
|
||||||
|
|
||||||
**Приоритет:** 🟢 Низкий
|
|
||||||
**Статус:** ❌ Не начато
|
|
||||||
**Объем:** Локальные оптимизации
|
|
||||||
|
|
||||||
### Проблемы
|
|
||||||
|
|
||||||
#### 9.1. Множественные клоны в обработке ввода
|
|
||||||
|
|
||||||
```rust
|
|
||||||
let text = app.input_text.clone(); // Клон
|
|
||||||
let chat_id = app.selected_chat.clone(); // Клон
|
|
||||||
// Используются только для чтения
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 9.2. Нет кеширования результатов поиска
|
|
||||||
|
|
||||||
- Каждый поиск выполняется заново
|
|
||||||
- Нет инвалидации кеша при изменениях
|
|
||||||
|
|
||||||
#### 9.3. Неэффективная LRU cache
|
|
||||||
|
|
||||||
- `Vec::retain()` + `Vec::push()` на каждый доступ
|
|
||||||
- O(n) вместо потенциального O(1)
|
|
||||||
|
|
||||||
### Решение
|
|
||||||
|
|
||||||
- [ ] Заменить клоны на borrowing где возможно
|
|
||||||
- [ ] Добавить `SearchCache` с TTL
|
|
||||||
- [ ] Оптимизировать `LruCache` (использовать `VecDeque` или готовую библиотеку)
|
|
||||||
|
|
||||||
### Файлы
|
|
||||||
|
|
||||||
- `src/input/main_input.rs`
|
|
||||||
- `src/app/search.rs`
|
|
||||||
- `src/tdlib/users.rs` (LruCache)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Отсутствующие архитектурные паттерны
|
|
||||||
|
|
||||||
**Приоритет:** 🟢 Низкий
|
|
||||||
**Статус:** ❌ Не начато
|
|
||||||
**Объем:** Архитектурные изменения
|
|
||||||
|
|
||||||
### Проблемы
|
|
||||||
|
|
||||||
#### 10.1. Нет Event Bus
|
|
||||||
|
|
||||||
Компоненты напрямую вызывают друг друга:
|
|
||||||
- Сложно тестировать
|
|
||||||
- Сильная связанность
|
|
||||||
- Тяжело добавлять новые фичи
|
|
||||||
|
|
||||||
#### 10.2. Нет Repository паттерна
|
|
||||||
|
|
||||||
Прямой доступ к данным везде:
|
|
||||||
- `app.messages.get(chat_id)`
|
|
||||||
- `app.chats.iter().find(...)`
|
|
||||||
- Нет единой точки доступа к данным
|
|
||||||
|
|
||||||
#### 10.3. Нет Service Layer
|
|
||||||
|
|
||||||
Бизнес-логика размазана:
|
|
||||||
- Часть в `App`
|
|
||||||
- Часть в `TdClient`
|
|
||||||
- Часть в UI handlers
|
|
||||||
|
|
||||||
### Решение
|
|
||||||
|
|
||||||
#### 10.1. Event Bus (опционально)
|
|
||||||
|
|
||||||
- [ ] Создать `src/event_bus.rs`
|
|
||||||
- [ ] Pub/Sub для событий между компонентами
|
|
||||||
- [ ] Decoupling
|
|
||||||
|
|
||||||
#### 10.2. Repository Pattern
|
|
||||||
|
|
||||||
- [ ] Создать `src/repositories/chat_repository.rs`
|
|
||||||
- [ ] Создать `src/repositories/message_repository.rs`
|
|
||||||
- [ ] Создать `src/repositories/user_repository.rs`
|
|
||||||
- [ ] Единая точка доступа к данным
|
|
||||||
|
|
||||||
#### 10.3. Service Layer
|
|
||||||
|
|
||||||
- [ ] Создать `src/services/chat_service.rs`
|
|
||||||
- [ ] Создать `src/services/message_service.rs`
|
|
||||||
- [ ] Создать `src/services/search_service.rs`
|
|
||||||
- [ ] Вся бизнес-логика в сервисах
|
|
||||||
|
|
||||||
### Файлы
|
|
||||||
|
|
||||||
- `src/event_bus.rs` (новый, опционально)
|
|
||||||
- `src/repositories/*.rs` (новые)
|
|
||||||
- `src/services/*.rs` (новые)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Приоритизация
|
|
||||||
|
|
||||||
### 🔴 Высокий приоритет (начать первым)
|
|
||||||
|
|
||||||
1. **Дублирование кода** - быстрый win, улучшит поддерживаемость
|
|
||||||
2. **Большие файлы** - критично для навигации и понимания кода
|
|
||||||
3. **Плохая инкапсуляция** - защитит от ошибок, улучшит API
|
|
||||||
|
|
||||||
### 🟡 Средний приоритет (после высокого)
|
|
||||||
|
|
||||||
4. **Сложная вложенность** - улучшит читаемость
|
|
||||||
5. **Single Responsibility** - улучшит архитектуру
|
|
||||||
6. **Отсутствующие абстракции** - упростит расширение
|
|
||||||
7. **Перекрытие функциональности** - уберет путаницу
|
|
||||||
|
|
||||||
### 🟢 Низкий приоритет (когда будет время)
|
|
||||||
|
|
||||||
8. **Несогласованность** - косметические улучшения
|
|
||||||
9. **Производительность** - пока не critical path
|
|
||||||
10. **Архитектурные паттерны** - nice to have
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## План выполнения
|
|
||||||
|
|
||||||
### Фаза 1: Быстрые победы (1-2 дня)
|
|
||||||
|
|
||||||
- [x] #1: Создать утилиты для дублирующегося кода - **ЗАВЕРШЕНО** (2026-02-02)
|
|
||||||
- retry utils: 100% покрытие (все timeout заменены)
|
|
||||||
- modal_handler: интегрирован в 2 диалогах
|
|
||||||
- validation: интегрирован в 4 местах
|
|
||||||
- [ ] #5: Инкапсулировать поля App - **Частично** (геттеры добавлены)
|
|
||||||
|
|
||||||
### Фаза 2: Разделение больших файлов (3-5 дней)
|
|
||||||
|
|
||||||
- [ ] #2.1: Разделить `main_input.rs`
|
|
||||||
- [ ] #2.2: Разделить `client.rs`
|
|
||||||
- [ ] #2.3: Разделить `messages.rs`
|
|
||||||
|
|
||||||
### Фаза 3: Улучшение архитектуры (5-7 дней)
|
|
||||||
|
|
||||||
- [ ] #4: Разделить ответственности App/TdClient
|
|
||||||
- [ ] #6: Добавить абстракции (KeyHandler, network utils)
|
|
||||||
- [ ] #8: Убрать перекрытие функциональности
|
|
||||||
|
|
||||||
### Фаза 4: Полировка (2-3 дня)
|
|
||||||
|
|
||||||
- [x] #3: Упростить вложенность - **Частично** (main_input.rs завершён 2026-02-03)
|
|
||||||
- [ ] #7: Стандартизировать подходы
|
|
||||||
- [ ] #9: Оптимизировать производительность
|
|
||||||
|
|
||||||
### Фаза 5: Архитектурные паттерны (опционально)
|
|
||||||
|
|
||||||
- [ ] #10: Рассмотреть Event Bus / Repository / Service Layer
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Метрики
|
|
||||||
|
|
||||||
### До рефакторинга
|
|
||||||
|
|
||||||
- Строк кода: ~15,000
|
|
||||||
- Файлов: ~50
|
|
||||||
- Средний размер файла: 300 строк
|
|
||||||
- Максимальный файл: 1167 строк
|
|
||||||
- Дублирование: ~15-20%
|
|
||||||
- Публичных полей в App: 22
|
|
||||||
- Прямые вызовы timeout: 8+
|
|
||||||
|
|
||||||
### Текущее состояние (2026-02-04)
|
|
||||||
|
|
||||||
- ✅ Дублирование timeout: **УСТРАНЕНО** (0 прямых вызовов, все через retry utils)
|
|
||||||
- ✅ Дублирование modal: **УСТРАНЕНО** (используется modal_handler)
|
|
||||||
- ✅ Дублирование validation: **УСТРАНЕНО** (используется validation utils)
|
|
||||||
- ✅ Вложенность в main_input.rs: **УПРОЩЕНА** (6+ уровней → 2-3 уровня)
|
|
||||||
- ✅ Размер handle() в main_input.rs: **СОКРАЩЁН** (891 строк → 82 строки, 91% сокращение)
|
|
||||||
- ✅ Размер client.rs: **СОКРАЩЁН** (1259 строк → 599 строк, 52% сокращение)
|
|
||||||
- ✅ Размер render() в ui/messages.rs: **СОКРАЩЁН** (390 строк → 92 строки, 76% сокращение)
|
|
||||||
- ✅ Размер convert_message() в tdlib/messages.rs: **СОКРАЩЁН** (150 строк → 57 строк, 62% сокращение)
|
|
||||||
- ⏳ Публичных полей в App: 22 → 21 (config приватный, геттеры добавлены)
|
|
||||||
- ✅ **Все большие функции отрефакторены!** 🎉
|
|
||||||
|
|
||||||
### Цели после рефакторинга
|
|
||||||
|
|
||||||
- Максимальный файл: <500 строк
|
|
||||||
- Дублирование: <5% ✅ **ДОСТИГНУТО для категории #1!**
|
|
||||||
- Глубина вложенности: ≤3 уровня ✅ **ДОСТИГНУТО для main_input.rs!**
|
|
||||||
- Публичных полей в App: 0
|
|
||||||
- Все файлы <400 строк (в идеале)
|
|
||||||
- Улучшенная тестируемость
|
|
||||||
- Более четкое разделение ответственностей
|
|
||||||
File diff suppressed because it is too large
Load Diff
791
ROADMAP.md
791
ROADMAP.md
@@ -1,681 +1,168 @@
|
|||||||
# Roadmap
|
# Roadmap
|
||||||
|
|
||||||
## Фаза 1: Базовая инфраструктура [DONE]
|
## Завершённые фазы
|
||||||
|
|
||||||
- [x] Настройка проекта (Cargo.toml)
|
| Фаза | Описание | Ключевые результаты |
|
||||||
- [x] TUI фреймворк (ratatui + crossterm)
|
|------|----------|---------------------|
|
||||||
- [x] Базовый layout (папки, список чатов, область сообщений)
|
| 1 | Базовая инфраструктура | ratatui + crossterm, vim-навигация, русская раскладка |
|
||||||
- [x] Vim-style навигация (hjkl, стрелки)
|
| 2 | TDLib интеграция | tdlib-rs, авторизация, загрузка чатов и сообщений |
|
||||||
- [x] Русская раскладка (ролд)
|
| 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
|
## Фаза 11: Inline просмотр фото в чате [DONE]
|
||||||
- [x] Авторизация (телефон + код + 2FA)
|
|
||||||
- [x] Сохранение сессии
|
|
||||||
- [x] Загрузка списка чатов
|
|
||||||
- [x] Загрузка истории сообщений
|
|
||||||
- [x] Отключение логов TDLib
|
|
||||||
|
|
||||||
## Фаза 3: Улучшение UX [DONE]
|
**UX**: Always-show inline preview (50 chars, Halfblocks) -> `v`/`м` открывает fullscreen modal (iTerm2/Sixel) -> `←`/`→` навигация между фото.
|
||||||
|
|
||||||
- [x] Отправка сообщений
|
### Реализовано:
|
||||||
- [x] Фильтрация чатов (только Main, без архива)
|
- [x] **Dual renderer архитектура**:
|
||||||
- [x] Поиск по чатам (Ctrl+S)
|
- `inline_image_renderer`: Halfblocks (быстро, Unicode блоки) для навигации
|
||||||
- [x] Скролл истории сообщений
|
- `modal_image_renderer`: iTerm2/Sixel (медленно, высокое качество) для просмотра
|
||||||
- [x] Загрузка имён пользователей (вместо User_ID)
|
- [x] **Performance optimizations**:
|
||||||
- [x] Отметка сообщений как прочитанные
|
- Frame throttling: inline 15 FPS, текст 60 FPS
|
||||||
- [x] Реальное время: новые сообщения
|
- 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
|
## Фаза 12: Прослушивание голосовых сообщений [DONE]
|
||||||
- [x] Переключение между папками (1-9)
|
|
||||||
- [x] Фильтрация чатов по папке
|
|
||||||
|
|
||||||
## Фаза 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] Отображение онлайн-статуса (зелёная точка ●)
|
### Этап 2: Интеграция с TDLib [DONE]
|
||||||
- [x] Статус доставки/прочтения (✓, ✓✓)
|
- [x] Типы: `VoiceInfo`, `VoiceDownloadState`, `PlaybackState`, `PlaybackStatus`
|
||||||
- [x] Поддержка медиа-заглушек (фото, видео, голосовые, стикеры и др.)
|
- [x] Конвертация `MessageVoiceNote` в `message_conversion.rs`
|
||||||
- [x] Mentions (@) — индикатор непрочитанных упоминаний
|
- [x] `download_voice_note()` в TdClientTrait + client_impl + fake
|
||||||
- [x] Muted чаты (иконка 🔇)
|
- [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] Оптимизация использования памяти (базовая)
|
### Этап 4: Хоткеи [DONE]
|
||||||
- Очистка сообщений при закрытии чата
|
- [x] Space — play/pause toggle (запуск + пауза/возобновление с откатом 1s)
|
||||||
- Лимит кэша пользователей (500)
|
- [x] ←/→ — seek ±5 сек (через `resume_from()` — перезапуск ffplay с `-ss`)
|
||||||
- Периодическая очистка неактивных записей
|
- [x] Seek работает и при воспроизведении, и на паузе (на паузе двигает позицию, при resume стартует с неё)
|
||||||
- [x] Оптимизация 60 FPS
|
- [x] MoveLeft/MoveRight как alias для SeekBackward/SeekForward (HashMap non-deterministic order fix)
|
||||||
- Poll таймаут 16ms
|
- [x] Автоматическая остановка при навигации на другое сообщение
|
||||||
- Флаг `needs_redraw` — рендеринг только при изменениях
|
- [x] Остановка ffplay при выходе из приложения (Ctrl+C)
|
||||||
- Обработка Event::Resize для перерисовки при изменении размера
|
|
||||||
- [x] Минимальное разрешение (80x20)
|
|
||||||
- Предупреждение если терминал слишком мал
|
|
||||||
- [x] Обработка ошибок сети
|
|
||||||
- NetworkState enum (WaitingForNetwork, Connecting, etc.)
|
|
||||||
- Индикатор в футере с цветовой индикацией
|
|
||||||
- [x] Graceful shutdown
|
|
||||||
- AtomicBool флаг для остановки polling
|
|
||||||
- Корректное закрытие TDLib клиента
|
|
||||||
- Таймаут ожидания завершения задач
|
|
||||||
- [x] Динамический инпут
|
|
||||||
- Автоматическое расширение до 10 строк
|
|
||||||
- Wrap для длинного текста
|
|
||||||
- [x] Перенос длинных сообщений
|
|
||||||
- Автоматический wrap на несколько строк
|
|
||||||
- Правильное выравнивание для исходящих/входящих
|
|
||||||
|
|
||||||
## Фаза 7: Глубокий рефакторинг памяти [DONE]
|
### Этап 5: Конфигурация и кэш [DONE]
|
||||||
|
- [x] `AudioConfig` в config.toml (`cache_size_mb`, `auto_download_voice`)
|
||||||
- [x] Удалить дублирование current_messages между App и TdClient
|
- [x] `DEFAULT_AUDIO_CACHE_SIZE_MB` константа (100 MB)
|
||||||
- [x] Использовать единый источник данных для сообщений
|
- [x] Ticker для progress bar в event loop (delta-based position tracking)
|
||||||
- [x] Реализовать LRU-кэш для user_names/user_statuses вместо простого лимита
|
- [x] VoiceCache интеграция: проверка кэша перед загрузкой, кэширование после download
|
||||||
- [x] Lazy loading для имён пользователей (батчевая загрузка последних 5 за цикл)
|
|
||||||
- [x] Лимиты памяти:
|
|
||||||
- MAX_MESSAGES_IN_CHAT = 500
|
|
||||||
- MAX_CHATS = 200
|
|
||||||
- MAX_CHAT_USER_IDS = 500
|
|
||||||
- MAX_USER_CACHE_SIZE = 500 (LRU)
|
|
||||||
|
|
||||||
## Фаза 8: Дополнительные фичи [DONE]
|
|
||||||
|
|
||||||
- [x] Markdown форматирование в сообщениях
|
|
||||||
- Bold, Italic, Underline, Strikethrough
|
|
||||||
- Code (inline, Pre, PreCode)
|
|
||||||
- Spoiler (скрытый текст)
|
|
||||||
- URLs, упоминания (@)
|
|
||||||
- [x] Редактирование сообщений
|
|
||||||
- ↑ при пустом инпуте → выбор сообщения
|
|
||||||
- Enter для начала редактирования
|
|
||||||
- Подсветка выбранного сообщения (▶)
|
|
||||||
- Esc для отмены
|
|
||||||
- [x] Удаление сообщений
|
|
||||||
- d / в / Delete в режиме выбора
|
|
||||||
- Модалка подтверждения (y/n)
|
|
||||||
- Удаление для всех если возможно
|
|
||||||
- [x] Индикатор редактирования (✎)
|
|
||||||
- Отображается рядом с временем для отредактированных сообщений
|
|
||||||
- [x] Блочный курсор в поле ввода
|
|
||||||
- Vim-style курсор █
|
|
||||||
- Перемещение ←/→, Home/End
|
|
||||||
- Редактирование в любой позиции
|
|
||||||
- [x] Reply на сообщения
|
|
||||||
- `r` / `к` в режиме выбора → режим ответа
|
|
||||||
- Превью сообщения в поле ввода
|
|
||||||
- Esc для отмены
|
|
||||||
- [x] Forward сообщений
|
|
||||||
- `f` / `а` в режиме выбора → режим пересылки
|
|
||||||
- Превью сообщения в поле ввода
|
|
||||||
- Выбор чата стрелками, Enter для пересылки
|
|
||||||
- Esc для отмены
|
|
||||||
- Отображение "↪ Переслано от" для пересланных сообщений
|
|
||||||
|
|
||||||
## Фаза 9: Расширенные возможности [DONE]
|
|
||||||
|
|
||||||
- [x] Typing indicator ("печатает...")
|
|
||||||
- Показывать когда собеседник печатает
|
|
||||||
- Отправлять свой статус печати при наборе текста
|
|
||||||
- [x] Закреплённые сообщения (Pinned)
|
|
||||||
- Отображать pinned message вверху открытого чата
|
|
||||||
- Клик/хоткей для перехода к закреплённому сообщению
|
|
||||||
- [x] Поиск по сообщениям в чате
|
|
||||||
- `Ctrl+F` — поиск текста внутри открытого чата
|
|
||||||
- Навигация по результатам (n/N или стрелки)
|
|
||||||
- Подсветка найденных совпадений
|
|
||||||
- [x] Черновики
|
|
||||||
- Сохранять набранный текст при переключении между чатами
|
|
||||||
- Индикатор черновика в списке чатов
|
|
||||||
- Восстановление текста при возврате в чат
|
|
||||||
- [x] Профиль пользователя/чата
|
|
||||||
- `Ctrl+i` — открыть информацию о чате/собеседнике
|
|
||||||
- Для личных чатов: имя, username, телефон, био
|
|
||||||
- Для групп: название, описание, количество участников
|
|
||||||
- [x] Копирование сообщений
|
|
||||||
- `y` / `н` в режиме выбора — скопировать текст в системный буфер обмена
|
|
||||||
- Использовать clipboard crate для кроссплатформенности
|
|
||||||
- [x] Реакции
|
|
||||||
- Отображение реакций под сообщениями
|
|
||||||
- `e` в режиме выбора — добавить реакцию (emoji picker)
|
|
||||||
- Список доступных реакций чата
|
|
||||||
- [x] Конфигурационный файл
|
|
||||||
- `~/.config/tele-tui/config.toml`
|
|
||||||
- Настройки: цветовая схема, часовой пояс, хоткеи
|
|
||||||
- Загрузка конфига при старте
|
|
||||||
|
|
||||||
## Фаза 10: Desktop уведомления [DONE - 83%]
|
|
||||||
|
|
||||||
### Стадия 1: Базовая реализация [DONE]
|
|
||||||
- [x] NotificationManager модуль
|
|
||||||
- notify-rust интеграция (версия 4.11)
|
|
||||||
- Feature flag "notifications" в Cargo.toml
|
|
||||||
- Базовая структура с настройками
|
|
||||||
- [x] Конфигурация уведомлений
|
|
||||||
- NotificationsConfig в config.toml
|
|
||||||
- enabled: bool - вкл/выкл уведомлений
|
|
||||||
- only_mentions: bool - только упоминания
|
|
||||||
- show_preview: bool - показывать превью текста
|
|
||||||
- [x] Интеграция с TdClient
|
|
||||||
- Поле notification_manager в TdClient
|
|
||||||
- Метод configure_notifications()
|
|
||||||
- Обработка в handle_new_message_update()
|
|
||||||
- [x] Базовая отправка уведомлений
|
|
||||||
- Уведомления для сообщений не из текущего чата
|
|
||||||
- Форматирование title (имя чата) и body (текст/медиа-заглушка)
|
|
||||||
- Sender name из MessageInfo
|
|
||||||
|
|
||||||
### Стадия 2: Улучшения [IN PROGRESS]
|
|
||||||
- [x] Синхронизация muted чатов
|
|
||||||
- Загрузка списка muted чатов из Telegram
|
|
||||||
- Вызов sync_muted_chats() при инициализации и обновлении (Ctrl+R)
|
|
||||||
- Muted чаты автоматически фильтруются из уведомлений
|
|
||||||
- [x] Фильтрация по упоминаниям
|
|
||||||
- Метод MessageInfo::has_mention() проверяет TextEntityType::Mention и MentionName
|
|
||||||
- NotificationManager применяет фильтр only_mentions из конфига
|
|
||||||
- Работает для @username и inline mentions
|
|
||||||
- [x] Поддержка типов медиа
|
|
||||||
- Метод beautify_media_labels() заменяет текстовые заглушки на emoji
|
|
||||||
- Поддержка: 📷 Фото, 🎥 Видео, 🎞️ GIF, 🎤 Голосовое, 🎨 Стикер
|
|
||||||
- Также: 📎 Файл, 🎵 Аудио, 📹 Видеосообщение, 📍 Локация, 👤 Контакт, 📊 Опрос
|
|
||||||
- [ ] Кастомизация звуков
|
|
||||||
- Настройка звуков уведомлений в config.toml
|
|
||||||
- Разные звуки для разных типов сообщений
|
|
||||||
|
|
||||||
### Стадия 3: Полировка [DONE]
|
|
||||||
- [x] Обработка ошибок
|
|
||||||
- Graceful fallback если уведомления недоступны (возвращает Ok без паники)
|
|
||||||
- Логирование ошибок через tracing::warn!
|
|
||||||
- Детальное логирование причин пропуска уведомлений (debug level)
|
|
||||||
- [x] Дополнительные настройки
|
|
||||||
- timeout_ms - продолжительность показа (0 = системное значение)
|
|
||||||
- urgency - уровень важности: "low", "normal", "critical" (только Linux)
|
|
||||||
- Красивые эмодзи для типов медиа
|
|
||||||
- [ ] Опциональные улучшения (не критично)
|
|
||||||
- Кросс-платформенное тестирование (требует ручного тестирования)
|
|
||||||
- icon - кастомная иконка приложения
|
|
||||||
- Actions в уведомлениях (кнопки "Ответить", "Прочитано")
|
|
||||||
|
|
||||||
## Фаза 11: Показ изображений в чате [PLANNED]
|
|
||||||
|
|
||||||
### Этап 1: Инфраструктура [TODO]
|
|
||||||
- [ ] Модуль src/media/
|
|
||||||
- image_cache.rs - LRU кэш для загруженных изображений
|
|
||||||
- image_loader.rs - Асинхронная загрузка через TDLib
|
|
||||||
- image_renderer.rs - Рендеринг в ratatui
|
|
||||||
- [ ] Зависимости
|
|
||||||
- ratatui-image 1.0 - поддержка изображений в TUI
|
|
||||||
- Определение протокола терминала (Sixel/Kitty/iTerm2/Halfblocks)
|
|
||||||
- [ ] ImageCache с лимитами
|
|
||||||
- LRU кэш с максимальным размером в МБ
|
|
||||||
- Автоматическая очистка старых изображений
|
|
||||||
- MAX_IMAGE_CACHE_SIZE = 100 MB (по умолчанию)
|
|
||||||
|
|
||||||
### Этап 2: Интеграция с TDLib [TODO]
|
|
||||||
- [ ] Обработка MessageContentPhoto
|
|
||||||
- Добавить PhotoInfo в MessageInfo
|
|
||||||
- Извлечение file_id, width, height из Photo
|
|
||||||
- Выбор оптимального размера изображения (до 800px)
|
|
||||||
- [ ] Загрузка файлов
|
|
||||||
- Метод TdClient::download_photo(file_id)
|
|
||||||
- Асинхронная загрузка через downloadFile API
|
|
||||||
- Обработка состояний загрузки (pending/downloading/ready)
|
|
||||||
- [ ] Кэширование
|
|
||||||
- Сохранение путей к загруженным файлам
|
|
||||||
- Повторное использование уже загруженных изображений
|
|
||||||
|
|
||||||
### Этап 3: Рендеринг в UI [TODO]
|
|
||||||
- [ ] Модификация render_messages()
|
|
||||||
- Определение возможностей терминала при старте
|
|
||||||
- Рендеринг изображений через ratatui-image
|
|
||||||
- Автоматическое масштабирование под размер области
|
|
||||||
- Сохранение aspect ratio
|
|
||||||
- [ ] Превью в списке сообщений
|
|
||||||
- Миниатюры размером 20x10 символов
|
|
||||||
- Lazy loading (загрузка только видимых)
|
|
||||||
- Placeholder пока изображение грузится
|
|
||||||
- [ ] Индикатор загрузки
|
|
||||||
- Текстовая заглушка "[Загрузка фото...]"
|
|
||||||
- Progress bar для больших файлов
|
|
||||||
- Процент загрузки
|
|
||||||
|
|
||||||
### Этап 4: Полноэкранный просмотр [TODO]
|
|
||||||
- [ ] Новый режим: ViewImage
|
|
||||||
- `v` / `м` в режиме выбора - открыть изображение
|
|
||||||
- Показ на весь экран терминала
|
|
||||||
- `Esc` для закрытия
|
|
||||||
- [ ] Информация об изображении
|
|
||||||
- Размер файла
|
|
||||||
- Разрешение (width x height)
|
|
||||||
- Формат (JPEG/PNG/GIF)
|
|
||||||
- [ ] Навигация
|
|
||||||
- `←` / `→` - предыдущее/следующее изображение в чате
|
|
||||||
- Автоматическая загрузка соседних изображений
|
|
||||||
|
|
||||||
### Этап 5: Конфигурация и UX [TODO]
|
|
||||||
- [ ] MediaConfig в config.toml
|
|
||||||
- show_images: bool - включить/отключить показ изображений
|
|
||||||
- image_cache_mb: usize - размер кэша в МБ
|
|
||||||
- preview_quality: "low" | "medium" | "high"
|
|
||||||
- render_protocol: "auto" | "sixel" | "kitty" | "iterm2" | "halfblocks"
|
|
||||||
- [ ] Поддержка различных терминалов
|
|
||||||
- Auto-detection протокола при старте
|
|
||||||
- Fallback на Unicode halfblocks для любого терминала
|
|
||||||
- Опция отключения изображений если терминал не поддерживает
|
|
||||||
- [ ] Оптимизация производительности
|
|
||||||
- Асинхронная загрузка (не блокирует UI)
|
|
||||||
- Приоритизация видимых изображений
|
|
||||||
- Fast resize для превью
|
|
||||||
- Кэширование отмасштабированных версий
|
|
||||||
|
|
||||||
### Этап 6: Обработка ошибок [TODO]
|
|
||||||
- [ ] Graceful fallback
|
|
||||||
- Текстовая заглушка "[Фото]" если загрузка не удалась
|
|
||||||
- Повторная попытка по запросу пользователя
|
|
||||||
- Логирование проблем через tracing
|
|
||||||
- [ ] Ограничения
|
|
||||||
- Таймаут загрузки (30 сек)
|
|
||||||
- Максимальный размер файла для автозагрузки (10 MB)
|
|
||||||
- Предупреждение для больших файлов
|
|
||||||
|
|
||||||
### Технические детали
|
### Технические детали
|
||||||
- **Поддерживаемые протоколы:**
|
- **Аудио:** ffplay (subprocess), resume/seek через перезапуск с `-ss` offset
|
||||||
- Sixel (xterm, WezTerm, mintty)
|
- **Race conditions:** `starting` flag предотвращает false `is_stopped()` при старте ffplay; pid ownership guard в потоках предотвращает затирание pid нового процесса старым
|
||||||
- Kitty Graphics Protocol (Kitty terminal)
|
- **Keybinding conflict:** Left/Right привязаны к MoveLeft/MoveRight и SeekBackward/SeekForward; HashMap iteration order не гарантирован → оба варианта обрабатываются как seek в режиме выбора сообщения
|
||||||
- iTerm2 Inline Images (iTerm2 на macOS)
|
- **Платформы:** macOS, Linux (везде где есть ffmpeg)
|
||||||
- Unicode Halfblocks (fallback для всех)
|
- **Хоткеи:** Space (play/pause), ←/→ (seek ±5s)
|
||||||
- **Поддерживаемые форматы:**
|
|
||||||
- JPEG, PNG, GIF, WebP, BMP
|
|
||||||
- **Новые хоткеи:**
|
|
||||||
- `v` / `м` - открыть изображение в полном размере (режим выбора)
|
|
||||||
- `←` / `→` - навигация между изображениями (в режиме просмотра)
|
|
||||||
- `Esc` - закрыть полноэкранный просмотр
|
|
||||||
|
|
||||||
## Фаза 12: Прослушивание голосовых сообщений [PLANNED]
|
---
|
||||||
|
|
||||||
### Этап 1: Инфраструктура аудио [TODO]
|
## Фаза 14: Мультиаккаунт
|
||||||
- [ ] Модуль src/audio/
|
|
||||||
- player.rs - AudioPlayer на rodio
|
|
||||||
- cache.rs - VoiceCache для загруженных файлов
|
|
||||||
- state.rs - PlaybackState (статус, позиция, громкость)
|
|
||||||
- [ ] Зависимости
|
|
||||||
- rodio 0.17 - Pure Rust аудио библиотека
|
|
||||||
- Feature flag "audio" в Cargo.toml
|
|
||||||
- [ ] AudioPlayer API
|
|
||||||
- play() - воспроизведение файла
|
|
||||||
- pause() / resume() - пауза/возобновление
|
|
||||||
- stop() - остановка
|
|
||||||
- seek() - перемотка
|
|
||||||
- set_volume() - регулировка громкости
|
|
||||||
- get_position() - текущая позиция
|
|
||||||
- [ ] VoiceCache
|
|
||||||
- Кэш загруженных OGG файлов в ~/.cache/tele-tui/voice/
|
|
||||||
- LRU политика очистки
|
|
||||||
- MAX_VOICE_CACHE_SIZE = 100 MB
|
|
||||||
|
|
||||||
### Этап 2: Интеграция с TDLib [TODO]
|
**Цель**: поддержка нескольких Telegram-аккаунтов с мгновенным переключением внутри приложения.
|
||||||
- [ ] Обработка MessageContentVoiceNote
|
|
||||||
- Добавить VoiceNoteInfo в MessageInfo
|
|
||||||
- Извлечение file_id, duration, mime_type, waveform
|
|
||||||
- Метка формата (OGG Opus обычно)
|
|
||||||
- [ ] Загрузка файлов
|
|
||||||
- Метод TdClient::download_voice_note(file_id)
|
|
||||||
- Асинхронная загрузка через downloadFile API
|
|
||||||
- Обработка состояний (pending/downloading/ready)
|
|
||||||
- [ ] Кэширование
|
|
||||||
- Сохранение путей к загруженным файлам
|
|
||||||
- Не перезагружать уже скачанные голосовые
|
|
||||||
- Проверка существования файла перед воспроизведением
|
|
||||||
|
|
||||||
### Этап 3: UI для воспроизведения [TODO]
|
### UI: Индикатор в footer + хоткеи
|
||||||
- [ ] Индикатор в сообщении
|
|
||||||
- Иконка 🎤 и длительность голосового
|
|
||||||
- Progress bar во время воспроизведения
|
|
||||||
- Статус: ▶ (playing), ⏸ (paused), ⏹ (stopped), ⏳ (loading)
|
|
||||||
- Текущее время / общая длительность (0:08 / 0:15)
|
|
||||||
- [ ] Модификация render_messages()
|
|
||||||
- render_voice_note() для голосовых сообщений
|
|
||||||
- render_progress_bar() для индикатора воспроизведения
|
|
||||||
- Hint "[Space] Воспроизвести" если не играет
|
|
||||||
- [ ] Footer с управлением
|
|
||||||
- Отображение доступных команд при воспроизведении
|
|
||||||
- "[Space] Play/Pause [s] Stop [←/→] Seek [↑/↓] Volume"
|
|
||||||
- [ ] Waveform визуализация (опционально)
|
|
||||||
- Конвертация waveform данных из Telegram в ASCII bars
|
|
||||||
- Использование символов ▁▂▃▄▅▆▇█ для визуализации
|
|
||||||
|
|
||||||
### Этап 4: Хоткеи для управления [TODO]
|
```
|
||||||
- [ ] Новые команды
|
┌──────────────┬───────────────────────────┐
|
||||||
- PlayVoice - Space в режиме выбора голосового
|
│ Saved Msgs │ Привет! │
|
||||||
- PauseVoice - Space во время воспроизведения
|
│ Иван Петров │ Как дела? │
|
||||||
- StopVoice - s / ы
|
│ Работа чат │ │
|
||||||
- SeekBackward - ← (перемотка назад на 5 сек)
|
├──────────────┴───────────────────────────┤
|
||||||
- SeekForward - → (перемотка вперед на 5 сек)
|
│ [NORMAL] Михаил ⟨1/2⟩ Work(3) │ Ctrl+A │
|
||||||
- VolumeUp - ↑ (увеличить на 10%)
|
└──────────────────────────────────────────┘
|
||||||
- VolumeDown - ↓ (уменьшить на 10%)
|
|
||||||
- [ ] Контекстная обработка
|
|
||||||
- Space работает как play/pause в зависимости от состояния
|
|
||||||
- ← / → для seek только во время воспроизведения
|
|
||||||
- ↑ / ↓ для громкости только во время воспроизведения
|
|
||||||
- [ ] Поддержка русской раскладки
|
|
||||||
- s / ы - stop
|
|
||||||
- Остальные клавиши универсальны (Space, стрелки)
|
|
||||||
|
|
||||||
### Этап 5: Конфигурация и UX [TODO]
|
|
||||||
- [ ] AudioConfig в config.toml
|
|
||||||
- enabled: bool - включить/отключить аудио
|
|
||||||
- default_volume: f32 - громкость по умолчанию (0.0 - 1.0)
|
|
||||||
- seek_step_seconds: i32 - шаг перемотки в секундах
|
|
||||||
- autoplay: bool - автовоспроизведение при выборе
|
|
||||||
- cache_size_mb: usize - размер кэша голосовых
|
|
||||||
- show_waveform: bool - показывать waveform визуализацию
|
|
||||||
- system_player_fallback: bool - использовать системный плеер
|
|
||||||
- system_player: String - команда системного плеера (mpv, ffplay)
|
|
||||||
- [ ] Асинхронная загрузка
|
|
||||||
- Не блокировать UI во время загрузки файла
|
|
||||||
- Индикатор загрузки с процентами
|
|
||||||
- Возможность отмены загрузки
|
|
||||||
- [ ] Обновление UI
|
|
||||||
- Ticker для обновления progress bar (каждые 100ms)
|
|
||||||
- Плавное обновление позиции воспроизведения
|
|
||||||
- Автоматическая остановка при достижении конца
|
|
||||||
|
|
||||||
### Этап 6: Обработка ошибок [TODO]
|
|
||||||
- [ ] Graceful fallback на системный плеер
|
|
||||||
- Если rodio не работает - использовать mpv/ffplay
|
|
||||||
- Логирование ошибок через tracing
|
|
||||||
- Предупреждение пользователю если аудио недоступно
|
|
||||||
- [ ] Обработка ошибок загрузки
|
|
||||||
- Таймаут загрузки (30 сек)
|
|
||||||
- Повторная попытка по запросу
|
|
||||||
- Сообщение об ошибке в UI
|
|
||||||
- [ ] Ограничения
|
|
||||||
- Максимальный размер файла для кэша
|
|
||||||
- Автоматическая очистка старых файлов
|
|
||||||
- Предупреждение для очень длинных голосовых (>5 мин)
|
|
||||||
|
|
||||||
### Этап 7: Дополнительные улучшения [TODO]
|
|
||||||
- [ ] Управление воспроизведением
|
|
||||||
- Автоматическая остановка при закрытии чата
|
|
||||||
- Сохранение позиции при паузе
|
|
||||||
- Автопереход к следующему голосовому (опционально)
|
|
||||||
- [ ] Оптимизация
|
|
||||||
- Lazy loading (загрузка только при воспроизведении)
|
|
||||||
- Префетчинг следующего голосового (опционально)
|
|
||||||
- Минимальная задержка при нажатии Play
|
|
||||||
- [ ] Визуальные улучшения
|
|
||||||
- Анимация progress bar
|
|
||||||
- Цветовая индикация статуса (зеленый - playing, желтый - paused)
|
|
||||||
- Иконки в зависимости от статуса
|
|
||||||
|
|
||||||
### Технические детали
|
|
||||||
- **Аудио библиотека:**
|
|
||||||
- rodio 0.17 (Pure Rust, кроссплатформенная)
|
|
||||||
- Поддержка OGG Opus (формат голосовых в Telegram)
|
|
||||||
- Контроль воспроизведения через Sink API
|
|
||||||
- **Платформы:**
|
|
||||||
- Linux (ALSA, PulseAudio)
|
|
||||||
- macOS (CoreAudio)
|
|
||||||
- Windows (WASAPI)
|
|
||||||
- **Fallback:**
|
|
||||||
- mpv --no-video (универсальный плеер)
|
|
||||||
- ffplay -nodisp (из ffmpeg)
|
|
||||||
- **Новые хоткеи:**
|
|
||||||
- `Space` - воспроизвести/пауза (в режиме выбора голосового)
|
|
||||||
- `s` / `ы` - остановить воспроизведение
|
|
||||||
- `←` / `→` - перемотка -5с / +5с (во время воспроизведения)
|
|
||||||
- `↑` / `↓` - громкость +/- 10% (во время воспроизведения)
|
|
||||||
|
|
||||||
## Фаза 13: Глубокий рефакторинг архитектуры [PLANNED]
|
|
||||||
|
|
||||||
**Мотивация:** Код вырос до критических размеров - некоторые файлы содержат >1000 строк, что затрудняет поддержку и навигацию. Необходимо разбить монолитные файлы на логические модули.
|
|
||||||
|
|
||||||
**Проблемы:**
|
|
||||||
- `src/input/main_input.rs` - 1199 строк (самый большой файл!)
|
|
||||||
- `src/app/mod.rs` - 1015 строк, 116 функций (God Object)
|
|
||||||
- `src/ui/messages.rs` - 893 строки
|
|
||||||
- `src/tdlib/messages.rs` - 833 строки
|
|
||||||
- `src/config/mod.rs` - 642 строки
|
|
||||||
|
|
||||||
### Этап 1: Разбить input/main_input.rs (1199 → <200 строк) [DONE ✅]
|
|
||||||
|
|
||||||
**Текущая проблема:**
|
|
||||||
- Весь input handling в одном файле
|
|
||||||
- Функции по 300-400 строк
|
|
||||||
- Невозможно быстро найти нужный handler
|
|
||||||
|
|
||||||
**План:**
|
|
||||||
- [x] Создать `src/input/handlers/` директорию
|
|
||||||
- [x] Создать `handlers/chat.rs` - обработка ввода в открытом чате
|
|
||||||
- Переместить `handle_open_chat_keyboard_input()`
|
|
||||||
- Обработка скролла, выбора сообщений
|
|
||||||
- **452 строки** (7 функций)
|
|
||||||
- [x] Создать `handlers/chat_list.rs` - обработка в списке чатов
|
|
||||||
- Переместить `handle_chat_list_keyboard_input()`
|
|
||||||
- Навигация по чатам, папки
|
|
||||||
- **142 строки** (3 функции)
|
|
||||||
- [x] Создать `handlers/compose.rs` - режимы edit/reply/forward
|
|
||||||
- Обработка ввода в режимах редактирования
|
|
||||||
- Input field управление (курсор, backspace, delete)
|
|
||||||
- **80 строк** (2 функции)
|
|
||||||
- [x] Создать `handlers/modal.rs` - модалки
|
|
||||||
- Delete confirmation
|
|
||||||
- Emoji picker
|
|
||||||
- Profile modal
|
|
||||||
- **316 строк** (5 функций)
|
|
||||||
- [x] Создать `handlers/search.rs` - поиск
|
|
||||||
- Search mode в чате
|
|
||||||
- Search mode в списке чатов
|
|
||||||
- **140 строк** (3 функций)
|
|
||||||
- [x] Обновить `main_input.rs` - только роутинг
|
|
||||||
- Определение текущего режима
|
|
||||||
- Делегация в нужный handler
|
|
||||||
- **164 строки** (2 функции)
|
|
||||||
|
|
||||||
**Результат:** 1199 строк → **164 строки** (удалено 1035 строк, -86%)
|
|
||||||
- Создано 5 новых модулей обработки ввода
|
|
||||||
- Чистый router pattern в main_input.rs
|
|
||||||
- Каждый handler отвечает за свою область
|
|
||||||
- **Дополнительно:** Исправлен конфликт Ctrl+I → Ctrl+U для профиля
|
|
||||||
|
|
||||||
### Этап 2: Уменьшить app/mod.rs (116 функций → traits) [TODO]
|
|
||||||
|
|
||||||
**Текущая проблема:**
|
|
||||||
- God Object с 116 функциями
|
|
||||||
- Сложно найти нужный метод
|
|
||||||
- Нарушение Single Responsibility Principle
|
|
||||||
|
|
||||||
**План:**
|
|
||||||
- [ ] Создать `app/methods/` директорию
|
|
||||||
- [ ] Создать trait `NavigationMethods`
|
|
||||||
- `next_chat()`, `previous_chat()`
|
|
||||||
- `scroll_up()`, `scroll_down()`
|
|
||||||
- `select_chat()`, `open_chat()`
|
|
||||||
- ~15 методов
|
|
||||||
- [ ] Создать trait `MessageMethods`
|
|
||||||
- `send_message()`, `edit_message()`, `delete_message()`
|
|
||||||
- `reply_to_message()`, `forward_message()`
|
|
||||||
- `select_message()`, `deselect_message()`
|
|
||||||
- ~20 методов
|
|
||||||
- [ ] Создать trait `ComposeMethods`
|
|
||||||
- `enter_edit_mode()`, `enter_reply_mode()`, `enter_forward_mode()`
|
|
||||||
- `handle_input_char()`, `move_cursor_left()`, `move_cursor_right()`
|
|
||||||
- ~15 методов
|
|
||||||
- [ ] Создать trait `SearchMethods`
|
|
||||||
- `start_search()`, `search_next()`, `search_previous()`
|
|
||||||
- `clear_search()`
|
|
||||||
- ~5 методов
|
|
||||||
- [ ] Создать trait `ModalMethods`
|
|
||||||
- `show_delete_confirmation()`, `show_emoji_picker()`
|
|
||||||
- `show_profile()`, `close_modal()`
|
|
||||||
- ~10 методов
|
|
||||||
- [ ] Оставить в `app/mod.rs` только:
|
|
||||||
- Struct definition
|
|
||||||
- Constructor (new, with_client)
|
|
||||||
- Getters/setters для полей
|
|
||||||
- ~30-40 методов
|
|
||||||
|
|
||||||
**Структура:**
|
|
||||||
```rust
|
|
||||||
// app/mod.rs - только core
|
|
||||||
impl<T: TdClientTrait> App<T> {
|
|
||||||
pub fn new() -> Self { ... }
|
|
||||||
pub fn config(&self) -> &Config { ... }
|
|
||||||
}
|
|
||||||
|
|
||||||
// app/methods/navigation.rs
|
|
||||||
pub trait NavigationMethods {
|
|
||||||
fn next_chat(&mut self);
|
|
||||||
fn previous_chat(&mut self);
|
|
||||||
}
|
|
||||||
impl<T: TdClientTrait> NavigationMethods for App<T> { ... }
|
|
||||||
|
|
||||||
// app/methods/messages.rs
|
|
||||||
pub trait MessageMethods {
|
|
||||||
async fn send_message(&mut self, text: String);
|
|
||||||
}
|
|
||||||
impl<T: TdClientTrait> MessageMethods for App<T> { ... }
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Результат:** 116 функций → 6 trait impl блоков
|
- **Footer**: текущий аккаунт + номер `⟨1/2⟩` + бейджи непрочитанных с других аккаунтов
|
||||||
|
- **Быстрое переключение**: `Ctrl+1`..`Ctrl+9` — мгновенный switch без модалки
|
||||||
|
- **Модалка управления** (`Ctrl+A`): список аккаунтов, добавление/удаление, выбор активного
|
||||||
|
|
||||||
### Этап 3: Разбить ui/messages.rs (893 → <300 строк) [TODO]
|
### Модалка переключения аккаунтов
|
||||||
|
|
||||||
**Текущая проблема:**
|
|
||||||
- Весь UI рендеринг сообщений в одном файле
|
|
||||||
- Модалки смешаны с основным рендерингом
|
|
||||||
- Compose bar (input field) в том же файле
|
|
||||||
|
|
||||||
**План:**
|
|
||||||
- [ ] Создать `ui/modals/` директорию
|
|
||||||
- [ ] Создать `modals/delete_confirm.rs`
|
|
||||||
- Рендеринг модалки подтверждения удаления
|
|
||||||
- Обработка y/n input
|
|
||||||
- ~50 строк
|
|
||||||
- [ ] Создать `modals/emoji_picker.rs`
|
|
||||||
- Рендеринг сетки эмодзи
|
|
||||||
- Навигация по сетке
|
|
||||||
- ~100 строк
|
|
||||||
- [ ] Создать `modals/search_modal.rs`
|
|
||||||
- Поиск в чате
|
|
||||||
- Подсветка результатов
|
|
||||||
- Навигация по совпадениям
|
|
||||||
- ~80 строк
|
|
||||||
- [ ] Создать `modals/profile_modal.rs`
|
|
||||||
- Профиль пользователя/чата
|
|
||||||
- Отображение информации
|
|
||||||
- ~100 строк
|
|
||||||
- [ ] Создать `ui/compose_bar.rs`
|
|
||||||
- Поле ввода сообщения
|
|
||||||
- Превью для edit/reply/forward
|
|
||||||
- Курсор, автоматический wrap
|
|
||||||
- ~150 строк
|
|
||||||
- [ ] Оставить в `messages.rs`:
|
|
||||||
- Основной layout сообщений
|
|
||||||
- Рендеринг списка message bubbles
|
|
||||||
- Группировка по дате
|
|
||||||
- Pinned message
|
|
||||||
- ~300 строк
|
|
||||||
|
|
||||||
**Результат:** 893 строки → 6 файлов по <150 строк
|
|
||||||
|
|
||||||
### Этап 4: Разбить tdlib/messages.rs (833 → 2 файла) [TODO]
|
|
||||||
|
|
||||||
**Текущая проблема:**
|
|
||||||
- Смешивается конвертация из TDLib и операции
|
|
||||||
- Большой файл сложно читать
|
|
||||||
|
|
||||||
**План:**
|
|
||||||
- [ ] Создать `tdlib/messages/` директорию
|
|
||||||
- [ ] Создать `messages/convert.rs`
|
|
||||||
- Конвертация MessageContent из TDLib
|
|
||||||
- Парсинг всех типов (Text, Photo, Video, Voice, etc.)
|
|
||||||
- Обработка форматирования (entities)
|
|
||||||
- ~500 строк
|
|
||||||
- [ ] Создать `messages/operations.rs`
|
|
||||||
- send_message(), edit_message(), delete_message()
|
|
||||||
- forward_message(), reply_to_message()
|
|
||||||
- get_chat_history(), load_older_messages()
|
|
||||||
- ~300 строк
|
|
||||||
- [ ] Обновить `tdlib/messages.rs` → `tdlib/messages/mod.rs`
|
|
||||||
- Re-export публичных типов
|
|
||||||
- ~30 строк
|
|
||||||
|
|
||||||
**Результат:** 833 строки → 2 файла по <500 строк
|
|
||||||
|
|
||||||
### Этап 5: Разбить config/mod.rs (642 → 3 файла) [TODO]
|
|
||||||
|
|
||||||
**Текущая проблема:**
|
|
||||||
- Много default_* функций (по 1-3 строки каждая)
|
|
||||||
- Validation logic смешана с определениями
|
|
||||||
- Сложно найти нужную секцию конфига
|
|
||||||
|
|
||||||
**План:**
|
|
||||||
- [ ] Создать `config/defaults.rs`
|
|
||||||
- Все default_* функции
|
|
||||||
- ~100 строк
|
|
||||||
- [ ] Создать `config/validation.rs`
|
|
||||||
- Валидация timezone
|
|
||||||
- Валидация цветов
|
|
||||||
- Валидация notification settings
|
|
||||||
- ~150 строк
|
|
||||||
- [ ] Создать `config/loader.rs`
|
|
||||||
- Загрузка из файла
|
|
||||||
- Поиск путей (XDG, home, etc.)
|
|
||||||
- Обработка ошибок чтения
|
|
||||||
- ~100 строк
|
|
||||||
- [ ] Оставить в `config/mod.rs`:
|
|
||||||
- Struct definitions
|
|
||||||
- Default impls (вызывают defaults.rs)
|
|
||||||
- Re-exports
|
|
||||||
- ~200-300 строк
|
|
||||||
|
|
||||||
**Результат:** 642 строки → 4 файла по <200 строк
|
|
||||||
|
|
||||||
### Этап 6: Code Duplication Cleanup [TODO]
|
|
||||||
|
|
||||||
**План:**
|
|
||||||
- [ ] Найти дублированный код в handlers
|
|
||||||
- Общая логика обработки клавиш
|
|
||||||
- Вынести в `input/common.rs`
|
|
||||||
- [ ] Найти дублированный код в UI
|
|
||||||
- Общие компоненты рендеринга
|
|
||||||
- Вынести в `ui/components/`
|
|
||||||
- [ ] Использовать DRY принцип везде
|
|
||||||
|
|
||||||
### Этап 7: Documentation Update [TODO]
|
|
||||||
|
|
||||||
**План:**
|
|
||||||
- [ ] Обновить CONTEXT.md с новой структурой
|
|
||||||
- [ ] Обновить PROJECT_STRUCTURE.md
|
|
||||||
- [ ] Добавить module-level документацию
|
|
||||||
- [ ] Создать architecture diagram (ASCII)
|
|
||||||
|
|
||||||
### Метрики успеха
|
|
||||||
|
|
||||||
**До рефакторинга:**
|
|
||||||
```
|
```
|
||||||
input/main_input.rs: 1199 строк
|
┌──────────────────────────────────┐
|
||||||
app/mod.rs: 1015 строк (116 функций)
|
│ Аккаунты │
|
||||||
ui/messages.rs: 893 строки
|
│ │
|
||||||
tdlib/messages.rs: 833 строки
|
│ 1. Михаил (+7 900 ...) ● │ ← активный
|
||||||
config/mod.rs: 642 строки
|
│ 2. Work (+7 911 ...) (3) │ ← 3 непрочитанных
|
||||||
ИТОГО: 4582 строки в 5 файлах
|
│ 3. + Добавить аккаунт │
|
||||||
|
│ │
|
||||||
|
│ [j/k навигация, Enter выбор] │
|
||||||
|
│ [d — удалить аккаунт] │
|
||||||
|
└──────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
**После рефакторинга:**
|
### Техническая реализация: все клиенты одновременно
|
||||||
```
|
|
||||||
input/handlers/*.rs: ~6 файлов по <400 строк
|
|
||||||
app/methods/*.rs: ~6 traits с impl блоками
|
|
||||||
ui/modals/*.rs: ~4 файла по <150 строк
|
|
||||||
tdlib/messages/*.rs: 2 файла по <500 строк
|
|
||||||
config/*.rs: 4 файла по <200 строк
|
|
||||||
ИТОГО: те же строки, но в ~20+ файлах
|
|
||||||
```
|
|
||||||
|
|
||||||
**Преимущества:**
|
- **Несколько 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` — чаты и сообщения уже загружены
|
||||||
- ✅ Соблюдение Single Responsibility Principle
|
- **Общий конфиг**: `~/.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__/`
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
use criterion::{black_box, criterion_group, criterion_main, Criterion};
|
use criterion::{black_box, criterion_group, criterion_main, Criterion};
|
||||||
use tele_tui::formatting::format_text_with_entities;
|
|
||||||
use tdlib_rs::enums::{TextEntity, TextEntityType};
|
use tdlib_rs::enums::{TextEntity, TextEntityType};
|
||||||
|
use tele_tui::formatting::format_text_with_entities;
|
||||||
|
|
||||||
fn create_text_with_entities() -> (String, Vec<TextEntity>) {
|
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 text = "This is bold and italic text with code and a link and mention".to_string();
|
||||||
@@ -41,9 +41,7 @@ fn benchmark_format_simple_text(c: &mut Criterion) {
|
|||||||
let entities = vec![];
|
let entities = vec![];
|
||||||
|
|
||||||
c.bench_function("format_simple_text", |b| {
|
c.bench_function("format_simple_text", |b| {
|
||||||
b.iter(|| {
|
b.iter(|| format_text_with_entities(black_box(&text), black_box(&entities)));
|
||||||
format_text_with_entities(black_box(&text), black_box(&entities))
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,9 +49,7 @@ fn benchmark_format_markdown_text(c: &mut Criterion) {
|
|||||||
let (text, entities) = create_text_with_entities();
|
let (text, entities) = create_text_with_entities();
|
||||||
|
|
||||||
c.bench_function("format_markdown_text", |b| {
|
c.bench_function("format_markdown_text", |b| {
|
||||||
b.iter(|| {
|
b.iter(|| format_text_with_entities(black_box(&text), black_box(&entities)));
|
||||||
format_text_with_entities(black_box(&text), black_box(&entities))
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,9 +73,7 @@ fn benchmark_format_long_text(c: &mut Criterion) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
c.bench_function("format_long_text_with_100_entities", |b| {
|
c.bench_function("format_long_text_with_100_entities", |b| {
|
||||||
b.iter(|| {
|
b.iter(|| format_text_with_entities(black_box(&text), black_box(&entities)));
|
||||||
format_text_with_entities(black_box(&text), black_box(&entities))
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use criterion::{black_box, criterion_group, criterion_main, Criterion};
|
use criterion::{black_box, criterion_group, criterion_main, Criterion};
|
||||||
use tele_tui::utils::formatting::{format_timestamp_with_tz, format_date, get_day};
|
use tele_tui::utils::formatting::{format_date, format_timestamp_with_tz, get_day};
|
||||||
|
|
||||||
fn benchmark_format_timestamp(c: &mut Criterion) {
|
fn benchmark_format_timestamp(c: &mut Criterion) {
|
||||||
c.bench_function("format_timestamp_50_times", |b| {
|
c.bench_function("format_timestamp_50_times", |b| {
|
||||||
@@ -34,10 +34,5 @@ fn benchmark_get_day(c: &mut Criterion) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
criterion_group!(
|
criterion_group!(benches, benchmark_format_timestamp, benchmark_format_date, benchmark_get_day);
|
||||||
benches,
|
|
||||||
benchmark_format_timestamp,
|
|
||||||
benchmark_format_date,
|
|
||||||
benchmark_get_day
|
|
||||||
);
|
|
||||||
criterion_main!(benches);
|
criterion_main!(benches);
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ fn create_test_messages(count: usize) -> Vec<tele_tui::tdlib::MessageInfo> {
|
|||||||
.map(|i| {
|
.map(|i| {
|
||||||
let builder = MessageBuilder::new(MessageId::new(i as i64))
|
let builder = MessageBuilder::new(MessageId::new(i as i64))
|
||||||
.sender_name(&format!("User{}", i % 10))
|
.sender_name(&format!("User{}", i % 10))
|
||||||
.text(&format!("Test message number {} with some longer text to make it more realistic", i))
|
.text(&format!(
|
||||||
|
"Test message number {} with some longer text to make it more realistic",
|
||||||
|
i
|
||||||
|
))
|
||||||
.date(1640000000 + (i as i32 * 60));
|
.date(1640000000 + (i as i32 * 60));
|
||||||
|
|
||||||
if i % 2 == 0 {
|
if i % 2 == 0 {
|
||||||
@@ -24,9 +27,7 @@ fn benchmark_group_100_messages(c: &mut Criterion) {
|
|||||||
let messages = create_test_messages(100);
|
let messages = create_test_messages(100);
|
||||||
|
|
||||||
c.bench_function("group_100_messages", |b| {
|
c.bench_function("group_100_messages", |b| {
|
||||||
b.iter(|| {
|
b.iter(|| group_messages(black_box(&messages)));
|
||||||
group_messages(black_box(&messages))
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,9 +35,7 @@ fn benchmark_group_500_messages(c: &mut Criterion) {
|
|||||||
let messages = create_test_messages(500);
|
let messages = create_test_messages(500);
|
||||||
|
|
||||||
c.bench_function("group_500_messages", |b| {
|
c.bench_function("group_500_messages", |b| {
|
||||||
b.iter(|| {
|
b.iter(|| group_messages(black_box(&messages)));
|
||||||
group_messages(black_box(&messages))
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
# Telegram TUI Configuration Example
|
|
||||||
# Copy this file to ~/.config/tele-tui/config.toml
|
|
||||||
|
|
||||||
[general]
|
|
||||||
# Timezone offset (e.g., "+03:00", "-05:00")
|
|
||||||
timezone = "+03:00"
|
|
||||||
|
|
||||||
[colors]
|
|
||||||
# Colors: red, green, blue, yellow, cyan, magenta, white, black, gray
|
|
||||||
# Also available: lightred, lightgreen, lightblue, lightyellow, lightcyan, lightmagenta
|
|
||||||
incoming_message = "white"
|
|
||||||
outgoing_message = "green"
|
|
||||||
selected_message = "yellow"
|
|
||||||
reaction_chosen = "yellow"
|
|
||||||
reaction_other = "gray"
|
|
||||||
|
|
||||||
[notifications]
|
|
||||||
# Enable desktop notifications for new messages
|
|
||||||
enabled = true
|
|
||||||
|
|
||||||
# Only notify when you are mentioned (@username)
|
|
||||||
only_mentions = false
|
|
||||||
|
|
||||||
# Show message preview text in notifications
|
|
||||||
show_preview = true
|
|
||||||
|
|
||||||
# Notification timeout in milliseconds (0 = system default)
|
|
||||||
timeout_ms = 5000
|
|
||||||
|
|
||||||
# Notification urgency level: "low", "normal", "critical"
|
|
||||||
# Note: Only works on Linux (libnotify), ignored on macOS/Windows
|
|
||||||
urgency = "normal"
|
|
||||||
|
|
||||||
# Note: Notifications respect Telegram's mute settings
|
|
||||||
# Muted chats won't trigger notifications
|
|
||||||
@@ -6,15 +6,6 @@ max_width = 100
|
|||||||
tab_spaces = 4
|
tab_spaces = 4
|
||||||
newline_style = "Unix"
|
newline_style = "Unix"
|
||||||
|
|
||||||
# Imports
|
|
||||||
imports_granularity = "Crate"
|
|
||||||
group_imports = "StdExternalCrate"
|
|
||||||
|
|
||||||
# Comments
|
|
||||||
wrap_comments = true
|
|
||||||
comment_width = 80
|
|
||||||
normalize_comments = true
|
|
||||||
|
|
||||||
# Formatting
|
# Formatting
|
||||||
use_small_heuristics = "Default"
|
use_small_heuristics = "Default"
|
||||||
fn_call_width = 80
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,10 +6,10 @@
|
|||||||
/// - По статусу (archived, muted, и т.д.)
|
/// - По статусу (archived, muted, и т.д.)
|
||||||
///
|
///
|
||||||
/// Используется как в App, так и в UI слое для консистентной фильтрации.
|
/// Используется как в App, так и в UI слое для консистентной фильтрации.
|
||||||
|
|
||||||
use crate::tdlib::ChatInfo;
|
use crate::tdlib::ChatInfo;
|
||||||
|
|
||||||
/// Критерии фильтрации чатов
|
/// Критерии фильтрации чатов
|
||||||
|
#[allow(dead_code)]
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct ChatFilterCriteria {
|
pub struct ChatFilterCriteria {
|
||||||
/// Фильтр по папке (folder_id)
|
/// Фильтр по папке (folder_id)
|
||||||
@@ -34,6 +34,7 @@ pub struct ChatFilterCriteria {
|
|||||||
pub hide_archived: bool,
|
pub hide_archived: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
impl ChatFilterCriteria {
|
impl ChatFilterCriteria {
|
||||||
/// Создаёт критерии с дефолтными значениями
|
/// Создаёт критерии с дефолтными значениями
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
@@ -42,18 +43,12 @@ impl ChatFilterCriteria {
|
|||||||
|
|
||||||
/// Фильтр только по папке
|
/// Фильтр только по папке
|
||||||
pub fn by_folder(folder_id: Option<i32>) -> Self {
|
pub fn by_folder(folder_id: Option<i32>) -> Self {
|
||||||
Self {
|
Self { folder_id, ..Default::default() }
|
||||||
folder_id,
|
|
||||||
..Default::default()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Фильтр только по поисковому запросу
|
/// Фильтр только по поисковому запросу
|
||||||
pub fn by_search(query: String) -> Self {
|
pub fn by_search(query: String) -> Self {
|
||||||
Self {
|
Self { search_query: Some(query), ..Default::default() }
|
||||||
search_query: Some(query),
|
|
||||||
..Default::default()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Builder: установить папку
|
/// Builder: установить папку
|
||||||
@@ -154,8 +149,10 @@ impl ChatFilterCriteria {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Централизованный фильтр чатов
|
/// Централизованный фильтр чатов
|
||||||
|
#[allow(dead_code)]
|
||||||
pub struct ChatFilter;
|
pub struct ChatFilter;
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
impl ChatFilter {
|
impl ChatFilter {
|
||||||
/// Фильтрует список чатов по критериям
|
/// Фильтрует список чатов по критериям
|
||||||
///
|
///
|
||||||
@@ -176,10 +173,7 @@ impl ChatFilter {
|
|||||||
///
|
///
|
||||||
/// let filtered = ChatFilter::filter(&all_chats, &criteria);
|
/// let filtered = ChatFilter::filter(&all_chats, &criteria);
|
||||||
/// ```
|
/// ```
|
||||||
pub fn filter<'a>(
|
pub fn filter<'a>(chats: &'a [ChatInfo], criteria: &ChatFilterCriteria) -> Vec<&'a ChatInfo> {
|
||||||
chats: &'a [ChatInfo],
|
|
||||||
criteria: &ChatFilterCriteria,
|
|
||||||
) -> Vec<&'a ChatInfo> {
|
|
||||||
chats.iter().filter(|chat| criteria.matches(chat)).collect()
|
chats.iter().filter(|chat| criteria.matches(chat)).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,8 +303,7 @@ mod tests {
|
|||||||
let filtered = ChatFilter::filter(&chats, &criteria);
|
let filtered = ChatFilter::filter(&chats, &criteria);
|
||||||
assert_eq!(filtered.len(), 2); // Chat 1 and Chat 3 have unread
|
assert_eq!(filtered.len(), 2); // Chat 1 and Chat 3 have unread
|
||||||
|
|
||||||
let criteria = ChatFilterCriteria::new()
|
let criteria = ChatFilterCriteria::new().pinned_only(true);
|
||||||
.pinned_only(true);
|
|
||||||
|
|
||||||
let filtered = ChatFilter::filter(&chats, &criteria);
|
let filtered = ChatFilter::filter(&chats, &criteria);
|
||||||
assert_eq!(filtered.len(), 1); // Only Chat 1 is pinned
|
assert_eq!(filtered.len(), 1); // Only Chat 1 is pinned
|
||||||
@@ -330,5 +323,4 @@ mod tests {
|
|||||||
assert_eq!(ChatFilter::count_unread(&chats, &criteria), 15); // 5 + 10
|
assert_eq!(ChatFilter::count_unread(&chats, &criteria), 15); // 5 + 10
|
||||||
assert_eq!(ChatFilter::count_unread_mentions(&chats, &criteria), 3); // 1 + 2
|
assert_eq!(ChatFilter::count_unread_mentions(&chats, &criteria), 3); // 1 + 2
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,21 @@
|
|||||||
use crate::tdlib::{MessageInfo, ProfileInfo};
|
use crate::tdlib::{MessageInfo, ProfileInfo};
|
||||||
use crate::types::MessageId;
|
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 {
|
pub enum ChatState {
|
||||||
/// Обычный режим - просмотр сообщений, набор текста
|
/// Обычный режим - просмотр сообщений, набор текста
|
||||||
|
#[default]
|
||||||
Normal,
|
Normal,
|
||||||
|
|
||||||
/// Выбор сообщения для действия (edit/delete/reply/forward/reaction)
|
/// Выбор сообщения для действия (edit/delete/reply/forward/reaction)
|
||||||
@@ -80,12 +91,6 @@ pub enum ChatState {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ChatState {
|
|
||||||
fn default() -> Self {
|
|
||||||
ChatState::Normal
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ChatState {
|
impl ChatState {
|
||||||
/// Проверка: находимся в режиме выбора сообщения
|
/// Проверка: находимся в режиме выбора сообщения
|
||||||
pub fn is_message_selection(&self) -> bool {
|
pub fn is_message_selection(&self) -> bool {
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
925
src/app/mod.rs
925
src/app/mod.rs
File diff suppressed because it is too large
Load Diff
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,6 @@
|
|||||||
/// - Загрузку из конфигурационного файла
|
/// - Загрузку из конфигурационного файла
|
||||||
/// - Множественные binding для одной команды (EN/RU раскладки)
|
/// - Множественные binding для одной команды (EN/RU раскладки)
|
||||||
/// - Type-safe команды через enum
|
/// - Type-safe команды через enum
|
||||||
|
|
||||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
@@ -48,6 +47,14 @@ pub enum Command {
|
|||||||
ReactMessage,
|
ReactMessage,
|
||||||
SelectMessage,
|
SelectMessage,
|
||||||
|
|
||||||
|
// Media
|
||||||
|
ViewImage, // v - просмотр фото
|
||||||
|
|
||||||
|
// Voice playback
|
||||||
|
TogglePlayback, // Space - play/pause
|
||||||
|
SeekForward, // → - seek +5s
|
||||||
|
SeekBackward, // ← - seek -5s
|
||||||
|
|
||||||
// Input
|
// Input
|
||||||
SubmitMessage,
|
SubmitMessage,
|
||||||
Cancel,
|
Cancel,
|
||||||
@@ -57,6 +64,9 @@ pub enum Command {
|
|||||||
MoveToStart,
|
MoveToStart,
|
||||||
MoveToEnd,
|
MoveToEnd,
|
||||||
|
|
||||||
|
// Vim mode
|
||||||
|
EnterInsertMode,
|
||||||
|
|
||||||
// Profile
|
// Profile
|
||||||
OpenProfile,
|
OpenProfile,
|
||||||
}
|
}
|
||||||
@@ -72,24 +82,21 @@ pub struct KeyBinding {
|
|||||||
|
|
||||||
impl KeyBinding {
|
impl KeyBinding {
|
||||||
pub fn new(key: KeyCode) -> Self {
|
pub fn new(key: KeyCode) -> Self {
|
||||||
Self {
|
Self { key, modifiers: KeyModifiers::NONE }
|
||||||
key,
|
|
||||||
modifiers: KeyModifiers::NONE,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn with_ctrl(key: KeyCode) -> Self {
|
pub fn with_ctrl(key: KeyCode) -> Self {
|
||||||
Self {
|
Self { key, modifiers: KeyModifiers::CONTROL }
|
||||||
key,
|
|
||||||
modifiers: KeyModifiers::CONTROL,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn with_shift(key: KeyCode) -> Self {
|
pub fn with_shift(key: KeyCode) -> Self {
|
||||||
Self {
|
Self { key, modifiers: KeyModifiers::SHIFT }
|
||||||
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 {
|
pub fn matches(&self, event: &KeyEvent) -> bool {
|
||||||
@@ -105,55 +112,81 @@ pub struct Keybindings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Keybindings {
|
impl Keybindings {
|
||||||
/// Создаёт дефолтную конфигурацию
|
/// Ищет команду по клавише
|
||||||
pub fn default() -> Self {
|
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();
|
let mut bindings = HashMap::new();
|
||||||
|
|
||||||
// Navigation
|
// Navigation
|
||||||
bindings.insert(Command::MoveUp, vec![
|
bindings.insert(
|
||||||
KeyBinding::new(KeyCode::Up),
|
Command::MoveUp,
|
||||||
KeyBinding::new(KeyCode::Char('k')),
|
vec![
|
||||||
KeyBinding::new(KeyCode::Char('р')), // RU (custom mapping, not standard ЙЦУКЕН)
|
KeyBinding::new(KeyCode::Up),
|
||||||
]);
|
KeyBinding::new(KeyCode::Char('k')),
|
||||||
bindings.insert(Command::MoveDown, vec![
|
KeyBinding::new(KeyCode::Char('р')), // RU (custom mapping, not standard ЙЦУКЕН)
|
||||||
KeyBinding::new(KeyCode::Down),
|
],
|
||||||
KeyBinding::new(KeyCode::Char('j')),
|
);
|
||||||
KeyBinding::new(KeyCode::Char('о')), // RU
|
bindings.insert(
|
||||||
]);
|
Command::MoveDown,
|
||||||
bindings.insert(Command::MoveLeft, vec![
|
vec![
|
||||||
KeyBinding::new(KeyCode::Left),
|
KeyBinding::new(KeyCode::Down),
|
||||||
KeyBinding::new(KeyCode::Char('h')),
|
KeyBinding::new(KeyCode::Char('j')),
|
||||||
KeyBinding::new(KeyCode::Char('л')), // RU (custom mapping, not standard ЙЦУКЕН)
|
KeyBinding::new(KeyCode::Char('о')), // RU
|
||||||
]);
|
],
|
||||||
bindings.insert(Command::MoveRight, vec![
|
);
|
||||||
KeyBinding::new(KeyCode::Right),
|
bindings.insert(
|
||||||
KeyBinding::new(KeyCode::Char('l')),
|
Command::MoveLeft,
|
||||||
KeyBinding::new(KeyCode::Char('д')), // RU
|
vec![
|
||||||
]);
|
KeyBinding::new(KeyCode::Left),
|
||||||
bindings.insert(Command::PageUp, vec![
|
KeyBinding::new(KeyCode::Char('h')),
|
||||||
KeyBinding::new(KeyCode::PageUp),
|
KeyBinding::new(KeyCode::Char('л')), // RU (custom mapping, not standard ЙЦУКЕН)
|
||||||
KeyBinding::with_ctrl(KeyCode::Char('u')),
|
],
|
||||||
]);
|
);
|
||||||
bindings.insert(Command::PageDown, vec![
|
bindings.insert(
|
||||||
KeyBinding::new(KeyCode::PageDown),
|
Command::MoveRight,
|
||||||
KeyBinding::with_ctrl(KeyCode::Char('d')),
|
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
|
// Global
|
||||||
bindings.insert(Command::Quit, vec![
|
bindings.insert(
|
||||||
KeyBinding::new(KeyCode::Char('q')),
|
Command::Quit,
|
||||||
KeyBinding::new(KeyCode::Char('й')), // RU
|
vec![
|
||||||
KeyBinding::with_ctrl(KeyCode::Char('c')),
|
KeyBinding::new(KeyCode::Char('q')),
|
||||||
]);
|
KeyBinding::new(KeyCode::Char('й')), // RU
|
||||||
bindings.insert(Command::OpenSearch, vec![
|
KeyBinding::with_ctrl(KeyCode::Char('c')),
|
||||||
KeyBinding::with_ctrl(KeyCode::Char('s')),
|
],
|
||||||
]);
|
);
|
||||||
bindings.insert(Command::OpenSearchInChat, vec![
|
bindings.insert(Command::OpenSearch, vec![KeyBinding::with_ctrl(KeyCode::Char('s'))]);
|
||||||
KeyBinding::with_ctrl(KeyCode::Char('f')),
|
bindings.insert(Command::OpenSearchInChat, vec![KeyBinding::with_ctrl(KeyCode::Char('f'))]);
|
||||||
]);
|
bindings.insert(Command::Help, vec![KeyBinding::new(KeyCode::Char('?'))]);
|
||||||
bindings.insert(Command::Help, vec![
|
|
||||||
KeyBinding::new(KeyCode::Char('?')),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Chat list
|
// Chat list
|
||||||
// Note: Enter обрабатывается через Command::SubmitMessage в handle_enter_key()
|
// Note: Enter обрабатывается через Command::SubmitMessage в handle_enter_key()
|
||||||
@@ -170,88 +203,117 @@ impl Keybindings {
|
|||||||
9 => Command::SelectFolder9,
|
9 => Command::SelectFolder9,
|
||||||
_ => unreachable!(),
|
_ => unreachable!(),
|
||||||
};
|
};
|
||||||
bindings.insert(cmd, vec![
|
bindings.insert(
|
||||||
KeyBinding::new(KeyCode::Char(char::from_digit(i, 10).unwrap())),
|
cmd,
|
||||||
]);
|
vec![KeyBinding::new(KeyCode::Char(
|
||||||
|
char::from_digit(i, 10).unwrap(),
|
||||||
|
))],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Message actions
|
// Message actions
|
||||||
// Note: EditMessage (Up) обрабатывается напрямую в handle_open_chat_keyboard_input
|
// Note: EditMessage (Up) обрабатывается напрямую в handle_open_chat_keyboard_input
|
||||||
// в зависимости от контекста (пустой инпут). Не привязываем здесь, чтобы не
|
// в зависимости от контекста (пустой инпут). Не привязываем здесь, чтобы не
|
||||||
// конфликтовать с Command::MoveUp в списке чатов.
|
// конфликтовать с Command::MoveUp в списке чатов.
|
||||||
bindings.insert(Command::DeleteMessage, vec![
|
bindings.insert(
|
||||||
KeyBinding::new(KeyCode::Delete),
|
Command::DeleteMessage,
|
||||||
KeyBinding::new(KeyCode::Char('d')),
|
vec![
|
||||||
KeyBinding::new(KeyCode::Char('в')), // RU
|
KeyBinding::new(KeyCode::Delete),
|
||||||
]);
|
KeyBinding::new(KeyCode::Char('d')),
|
||||||
bindings.insert(Command::ReplyMessage, vec![
|
KeyBinding::new(KeyCode::Char('в')), // RU
|
||||||
KeyBinding::new(KeyCode::Char('r')),
|
],
|
||||||
KeyBinding::new(KeyCode::Char('к')), // RU
|
);
|
||||||
]);
|
bindings.insert(
|
||||||
bindings.insert(Command::ForwardMessage, vec![
|
Command::ReplyMessage,
|
||||||
KeyBinding::new(KeyCode::Char('f')),
|
vec![
|
||||||
KeyBinding::new(KeyCode::Char('а')), // RU
|
KeyBinding::new(KeyCode::Char('r')),
|
||||||
]);
|
KeyBinding::new(KeyCode::Char('к')), // RU
|
||||||
bindings.insert(Command::CopyMessage, vec![
|
],
|
||||||
KeyBinding::new(KeyCode::Char('y')),
|
);
|
||||||
KeyBinding::new(KeyCode::Char('н')), // RU
|
bindings.insert(
|
||||||
]);
|
Command::ForwardMessage,
|
||||||
bindings.insert(Command::ReactMessage, vec![
|
vec![
|
||||||
KeyBinding::new(KeyCode::Char('e')),
|
KeyBinding::new(KeyCode::Char('f')),
|
||||||
KeyBinding::new(KeyCode::Char('у')), // RU
|
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()
|
// 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
|
// Input
|
||||||
bindings.insert(Command::SubmitMessage, vec![
|
bindings.insert(Command::SubmitMessage, vec![KeyBinding::new(KeyCode::Enter)]);
|
||||||
KeyBinding::new(KeyCode::Enter),
|
bindings.insert(Command::Cancel, vec![KeyBinding::new(KeyCode::Esc)]);
|
||||||
]);
|
bindings.insert(Command::NewLine, vec![]);
|
||||||
bindings.insert(Command::Cancel, vec![
|
bindings.insert(Command::DeleteChar, vec![KeyBinding::new(KeyCode::Backspace)]);
|
||||||
KeyBinding::new(KeyCode::Esc),
|
bindings.insert(
|
||||||
]);
|
Command::DeleteWord,
|
||||||
bindings.insert(Command::NewLine, vec![
|
vec![
|
||||||
KeyBinding::with_shift(KeyCode::Enter),
|
KeyBinding::with_ctrl(KeyCode::Backspace),
|
||||||
]);
|
KeyBinding::with_ctrl(KeyCode::Char('w')),
|
||||||
bindings.insert(Command::DeleteChar, vec![
|
],
|
||||||
KeyBinding::new(KeyCode::Backspace),
|
);
|
||||||
]);
|
bindings.insert(
|
||||||
bindings.insert(Command::DeleteWord, vec![
|
Command::MoveToStart,
|
||||||
KeyBinding::with_ctrl(KeyCode::Backspace),
|
vec![
|
||||||
KeyBinding::with_ctrl(KeyCode::Char('w')),
|
KeyBinding::new(KeyCode::Home),
|
||||||
]);
|
KeyBinding::with_ctrl(KeyCode::Char('a')),
|
||||||
bindings.insert(Command::MoveToStart, vec![
|
],
|
||||||
KeyBinding::new(KeyCode::Home),
|
);
|
||||||
KeyBinding::with_ctrl(KeyCode::Char('a')),
|
bindings.insert(
|
||||||
]);
|
Command::MoveToEnd,
|
||||||
bindings.insert(Command::MoveToEnd, vec![
|
vec![
|
||||||
KeyBinding::new(KeyCode::End),
|
KeyBinding::new(KeyCode::End),
|
||||||
KeyBinding::with_ctrl(KeyCode::Char('e')),
|
KeyBinding::with_ctrl(KeyCode::Char('e')),
|
||||||
]);
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Vim mode
|
||||||
|
bindings.insert(
|
||||||
|
Command::EnterInsertMode,
|
||||||
|
vec![
|
||||||
|
KeyBinding::new(KeyCode::Char('i')),
|
||||||
|
KeyBinding::new(KeyCode::Char('ш')), // RU
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
// Profile
|
// Profile
|
||||||
bindings.insert(Command::OpenProfile, vec![
|
bindings.insert(
|
||||||
KeyBinding::with_ctrl(KeyCode::Char('u')), // Ctrl+U instead of Ctrl+I
|
Command::OpenProfile,
|
||||||
KeyBinding::with_ctrl(KeyCode::Char('г')), // RU
|
vec![
|
||||||
]);
|
KeyBinding::with_ctrl(KeyCode::Char('u')), // Ctrl+U instead of Ctrl+I
|
||||||
|
KeyBinding::with_ctrl(KeyCode::Char('г')), // RU
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
Self { bindings }
|
Self { bindings }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ищет команду по клавише
|
|
||||||
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 {
|
|
||||||
Self::default()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Сериализация KeyModifiers
|
/// Сериализация KeyModifiers
|
||||||
@@ -356,14 +418,15 @@ mod key_code_serde {
|
|||||||
let s = String::deserialize(deserializer)?;
|
let s = String::deserialize(deserializer)?;
|
||||||
|
|
||||||
if s.starts_with("Char('") && s.ends_with("')") {
|
if s.starts_with("Char('") && s.ends_with("')") {
|
||||||
let c = s.chars().nth(6).ok_or_else(|| {
|
let c = s
|
||||||
serde::de::Error::custom("Invalid Char format")
|
.chars()
|
||||||
})?;
|
.nth(6)
|
||||||
|
.ok_or_else(|| serde::de::Error::custom("Invalid Char format"))?;
|
||||||
return Ok(KeyCode::Char(c));
|
return Ok(KeyCode::Char(c));
|
||||||
}
|
}
|
||||||
|
|
||||||
if s.starts_with("F") {
|
if let Some(suffix) = s.strip_prefix("F") {
|
||||||
let n = s[1..].parse().map_err(serde::de::Error::custom)?;
|
let n = suffix.parse().map_err(serde::de::Error::custom)?;
|
||||||
return Ok(KeyCode::F(n));
|
return Ok(KeyCode::F(n));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
|
//! Configuration module.
|
||||||
|
//!
|
||||||
|
//! Loads settings from `~/.config/tele-tui/config.toml`.
|
||||||
|
//! Structs: Config, GeneralConfig, ColorsConfig, NotificationsConfig, Keybindings.
|
||||||
|
|
||||||
pub mod keybindings;
|
pub mod keybindings;
|
||||||
|
mod loader;
|
||||||
|
mod validation;
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::fs;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
pub use keybindings::{Command, Keybindings};
|
pub use keybindings::{Command, Keybindings};
|
||||||
|
|
||||||
@@ -21,7 +26,7 @@ pub use keybindings::{Command, Keybindings};
|
|||||||
/// println!("Timezone: {}", config.general.timezone);
|
/// println!("Timezone: {}", config.general.timezone);
|
||||||
/// println!("Incoming color: {}", config.colors.incoming_message);
|
/// println!("Incoming color: {}", config.colors.incoming_message);
|
||||||
/// ```
|
/// ```
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
/// Общие настройки (timezone и т.д.).
|
/// Общие настройки (timezone и т.д.).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -38,6 +43,14 @@ pub struct Config {
|
|||||||
/// Настройки desktop notifications.
|
/// Настройки desktop notifications.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub notifications: NotificationsConfig,
|
pub notifications: NotificationsConfig,
|
||||||
|
|
||||||
|
/// Настройки отображения изображений.
|
||||||
|
#[serde(default)]
|
||||||
|
pub images: ImagesConfig,
|
||||||
|
|
||||||
|
/// Настройки аудио (голосовые сообщения).
|
||||||
|
#[serde(default)]
|
||||||
|
pub audio: AudioConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Общие настройки приложения.
|
/// Общие настройки приложения.
|
||||||
@@ -100,7 +113,59 @@ pub struct NotificationsConfig {
|
|||||||
pub urgency: String,
|
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 {
|
fn default_timezone() -> String {
|
||||||
"+03:00".to_string()
|
"+03:00".to_string()
|
||||||
}
|
}
|
||||||
@@ -141,6 +206,30 @@ fn default_notification_urgency() -> String {
|
|||||||
"normal".to_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 {
|
impl Default for GeneralConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self { timezone: default_timezone() }
|
Self { timezone: default_timezone() }
|
||||||
@@ -171,309 +260,6 @@ impl Default for NotificationsConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Config {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
general: GeneralConfig::default(),
|
|
||||||
colors: ColorsConfig::default(),
|
|
||||||
keybindings: Keybindings::default(),
|
|
||||||
notifications: NotificationsConfig::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> {
|
|
||||||
// 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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -485,10 +271,22 @@ mod tests {
|
|||||||
let keybindings = &config.keybindings;
|
let keybindings = &config.keybindings;
|
||||||
|
|
||||||
// Test that keybindings exist for common commands
|
// Test that keybindings exist for common commands
|
||||||
assert!(keybindings.get_command(&KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)) == Some(Command::ReplyMessage));
|
assert!(
|
||||||
assert!(keybindings.get_command(&KeyEvent::new(KeyCode::Char('к'), KeyModifiers::NONE)) == Some(Command::ReplyMessage));
|
keybindings.get_command(&KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE))
|
||||||
assert!(keybindings.get_command(&KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE)) == Some(Command::ForwardMessage));
|
== Some(Command::ReplyMessage)
|
||||||
assert!(keybindings.get_command(&KeyEvent::new(KeyCode::Char('а'), KeyModifiers::NONE)) == Some(Command::ForwardMessage));
|
);
|
||||||
|
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]
|
#[test]
|
||||||
@@ -556,10 +354,24 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_config_validate_valid_all_standard_colors() {
|
fn test_config_validate_valid_all_standard_colors() {
|
||||||
let colors = [
|
let colors = [
|
||||||
"black", "red", "green", "yellow", "blue", "magenta",
|
"black",
|
||||||
"cyan", "gray", "grey", "white", "darkgray", "darkgrey",
|
"red",
|
||||||
"lightred", "lightgreen", "lightyellow", "lightblue",
|
"green",
|
||||||
"lightmagenta", "lightcyan"
|
"yellow",
|
||||||
|
"blue",
|
||||||
|
"magenta",
|
||||||
|
"cyan",
|
||||||
|
"gray",
|
||||||
|
"grey",
|
||||||
|
"white",
|
||||||
|
"darkgray",
|
||||||
|
"darkgrey",
|
||||||
|
"lightred",
|
||||||
|
"lightgreen",
|
||||||
|
"lightyellow",
|
||||||
|
"lightblue",
|
||||||
|
"lightmagenta",
|
||||||
|
"lightcyan",
|
||||||
];
|
];
|
||||||
|
|
||||||
for color in colors {
|
for color in colors {
|
||||||
@@ -570,11 +382,7 @@ mod tests {
|
|||||||
config.colors.reaction_chosen = color.to_string();
|
config.colors.reaction_chosen = color.to_string();
|
||||||
config.colors.reaction_other = color.to_string();
|
config.colors.reaction_other = color.to_string();
|
||||||
|
|
||||||
assert!(
|
assert!(config.validate().is_ok(), "Color '{}' should be valid", color);
|
||||||
config.validate().is_ok(),
|
|
||||||
"Color '{}' should be valid",
|
|
||||||
color
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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
|
// Memory Limits
|
||||||
@@ -35,3 +35,50 @@ pub const LAZY_LOAD_USERS_PER_TICK: usize = 5;
|
|||||||
|
|
||||||
/// Лимит количества сообщений для загрузки через TDLib за раз
|
/// Лимит количества сообщений для загрузки через TDLib за раз
|
||||||
pub const TDLIB_MESSAGE_LIMIT: i32 = 50;
|
pub const TDLIB_MESSAGE_LIMIT: i32 = 50;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Images
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Максимальная ширина превью изображения (в символах)
|
||||||
|
pub const MAX_IMAGE_WIDTH: u16 = 30;
|
||||||
|
|
||||||
|
/// Максимальная высота превью изображения (в строках)
|
||||||
|
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;
|
||||||
|
|||||||
@@ -126,23 +126,25 @@ pub fn format_text_with_entities(
|
|||||||
let start = entity.offset as usize;
|
let start = entity.offset as usize;
|
||||||
let end = (entity.offset + entity.length) 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 {
|
match &entity.r#type {
|
||||||
TextEntityType::Bold => char_styles[i].bold = true,
|
TextEntityType::Bold => item.bold = true,
|
||||||
TextEntityType::Italic => char_styles[i].italic = true,
|
TextEntityType::Italic => item.italic = true,
|
||||||
TextEntityType::Underline => char_styles[i].underline = true,
|
TextEntityType::Underline => item.underline = true,
|
||||||
TextEntityType::Strikethrough => char_styles[i].strikethrough = true,
|
TextEntityType::Strikethrough => item.strikethrough = true,
|
||||||
TextEntityType::Code | TextEntityType::Pre | TextEntityType::PreCode(_) => {
|
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::Url
|
||||||
| TextEntityType::TextUrl(_)
|
| TextEntityType::TextUrl(_)
|
||||||
| TextEntityType::EmailAddress
|
| TextEntityType::EmailAddress
|
||||||
| TextEntityType::PhoneNumber => char_styles[i].url = true,
|
| TextEntityType::PhoneNumber => item.url = true,
|
||||||
TextEntityType::Mention | TextEntityType::MentionName(_) => {
|
TextEntityType::Mention | TextEntityType::MentionName(_) => item.mention = true,
|
||||||
char_styles[i].mention = true
|
|
||||||
}
|
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -277,11 +279,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_format_text_with_bold() {
|
fn test_format_text_with_bold() {
|
||||||
let text = "Hello";
|
let text = "Hello";
|
||||||
let entities = vec![TextEntity {
|
let entities = vec![TextEntity { offset: 0, length: 5, r#type: TextEntityType::Bold }];
|
||||||
offset: 0,
|
|
||||||
length: 5,
|
|
||||||
r#type: TextEntityType::Bold,
|
|
||||||
}];
|
|
||||||
let spans = format_text_with_entities(text, &entities, Color::White);
|
let spans = format_text_with_entities(text, &entities, Color::White);
|
||||||
|
|
||||||
assert_eq!(spans.len(), 1);
|
assert_eq!(spans.len(), 1);
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ pub async fn handle<T: TdClientTrait>(app: &mut App<T>, key_code: KeyCode) {
|
|||||||
app.status_message = Some("Отправка номера...".to_string());
|
app.status_message = Some("Отправка номера...".to_string());
|
||||||
match with_timeout_msg(
|
match with_timeout_msg(
|
||||||
Duration::from_secs(10),
|
Duration::from_secs(10),
|
||||||
app.td_client.send_phone_number(app.phone_input().to_string()),
|
app.td_client
|
||||||
|
.send_phone_number(app.phone_input().to_string()),
|
||||||
"Таймаут отправки номера",
|
"Таймаут отправки номера",
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -84,7 +85,8 @@ pub async fn handle<T: TdClientTrait>(app: &mut App<T>, key_code: KeyCode) {
|
|||||||
app.status_message = Some("Проверка пароля...".to_string());
|
app.status_message = Some("Проверка пароля...".to_string());
|
||||||
match with_timeout_msg(
|
match with_timeout_msg(
|
||||||
Duration::from_secs(10),
|
Duration::from_secs(10),
|
||||||
app.td_client.send_password(app.password_input().to_string()),
|
app.td_client
|
||||||
|
.send_password(app.password_input().to_string()),
|
||||||
"Таймаут проверки пароля",
|
"Таймаут проверки пароля",
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -6,17 +6,22 @@
|
|||||||
//! - Editing and sending messages
|
//! - Editing and sending messages
|
||||||
//! - Loading older 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::App;
|
||||||
use crate::tdlib::{TdClientTrait, ChatAction, ReplyInfo};
|
use crate::app::InputMode;
|
||||||
use crate::types::{ChatId, MessageId};
|
|
||||||
use crate::utils::{is_non_empty, with_timeout, with_timeout_msg};
|
|
||||||
use crate::input::handlers::{copy_to_clipboard, format_message_for_clipboard};
|
use crate::input::handlers::{copy_to_clipboard, format_message_for_clipboard};
|
||||||
use super::chat_list::open_chat_and_load_data;
|
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 crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
/// Обработка режима выбора сообщения для действий
|
/// Обработка режима выбора сообщения для действий
|
||||||
///
|
///
|
||||||
/// Обрабатывает:
|
/// Обрабатывает:
|
||||||
/// - Навигацию по сообщениям (Up/Down)
|
/// - Навигацию по сообщениям (Up/Down)
|
||||||
/// - Удаление сообщения (d/в/Delete)
|
/// - Удаление сообщения (d/в/Delete)
|
||||||
@@ -24,7 +29,11 @@ use std::time::{Duration, Instant};
|
|||||||
/// - Пересылку сообщения (f/а)
|
/// - Пересылку сообщения (f/а)
|
||||||
/// - Копирование сообщения (y/н)
|
/// - Копирование сообщения (y/н)
|
||||||
/// - Добавление реакции (e/у)
|
/// - Добавление реакции (e/у)
|
||||||
pub async fn handle_message_selection<T: TdClientTrait>(app: &mut App<T>, _key: KeyEvent, command: Option<crate::config::Command>) {
|
pub async fn handle_message_selection<T: TdClientTrait>(
|
||||||
|
app: &mut App<T>,
|
||||||
|
_key: KeyEvent,
|
||||||
|
command: Option<crate::config::Command>,
|
||||||
|
) {
|
||||||
match command {
|
match command {
|
||||||
Some(crate::config::Command::MoveUp) => {
|
Some(crate::config::Command::MoveUp) => {
|
||||||
app.select_previous_message();
|
app.select_previous_message();
|
||||||
@@ -39,13 +48,16 @@ pub async fn handle_message_selection<T: TdClientTrait>(app: &mut App<T>, _key:
|
|||||||
let can_delete =
|
let can_delete =
|
||||||
msg.can_be_deleted_only_for_self() || msg.can_be_deleted_for_all_users();
|
msg.can_be_deleted_only_for_self() || msg.can_be_deleted_for_all_users();
|
||||||
if can_delete {
|
if can_delete {
|
||||||
app.chat_state = crate::app::ChatState::DeleteConfirmation {
|
app.chat_state = crate::app::ChatState::DeleteConfirmation { message_id: msg.id() };
|
||||||
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) => {
|
Some(crate::config::Command::ReplyMessage) => {
|
||||||
app.start_reply_to_selected();
|
app.start_reply_to_selected();
|
||||||
|
app.input_mode = InputMode::Insert;
|
||||||
}
|
}
|
||||||
Some(crate::config::Command::ForwardMessage) => {
|
Some(crate::config::Command::ForwardMessage) => {
|
||||||
app.start_forward_selected();
|
app.start_forward_selected();
|
||||||
@@ -64,6 +76,18 @@ pub async fn handle_message_selection<T: TdClientTrait>(app: &mut App<T>, _key:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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) => {
|
Some(crate::config::Command::ReactMessage) => {
|
||||||
let Some(msg) = app.get_selected_message() else {
|
let Some(msg) = app.get_selected_message() else {
|
||||||
return;
|
return;
|
||||||
@@ -107,17 +131,22 @@ pub async fn handle_message_selection<T: TdClientTrait>(app: &mut App<T>, _key:
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Редактирование существующего сообщения
|
/// Редактирование существующего сообщения
|
||||||
pub async fn edit_message<T: TdClientTrait>(app: &mut App<T>, chat_id: i64, msg_id: MessageId, text: String) {
|
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()
|
let msg_exists = app
|
||||||
|
.td_client
|
||||||
|
.current_chat_messages()
|
||||||
.iter()
|
.iter()
|
||||||
.any(|m| m.id() == msg_id);
|
.any(|m| m.id() == msg_id);
|
||||||
|
|
||||||
if !msg_exists {
|
if !msg_exists {
|
||||||
app.error_message = Some(format!(
|
app.error_message =
|
||||||
"Сообщение {} не найдено в кэше чата {}",
|
Some(format!("Сообщение {} не найдено в кэше чата {}", msg_id.as_i64(), chat_id));
|
||||||
msg_id.as_i64(), chat_id
|
|
||||||
));
|
|
||||||
app.chat_state = crate::app::ChatState::Normal;
|
app.chat_state = crate::app::ChatState::Normal;
|
||||||
app.message_input.clear();
|
app.message_input.clear();
|
||||||
app.cursor_position = 0;
|
app.cursor_position = 0;
|
||||||
@@ -126,7 +155,8 @@ pub async fn edit_message<T: TdClientTrait>(app: &mut App<T>, chat_id: i64, msg_
|
|||||||
|
|
||||||
match with_timeout_msg(
|
match with_timeout_msg(
|
||||||
Duration::from_secs(5),
|
Duration::from_secs(5),
|
||||||
app.td_client.edit_message(ChatId::new(chat_id), msg_id, text),
|
app.td_client
|
||||||
|
.edit_message(ChatId::new(chat_id), msg_id, text),
|
||||||
"Таймаут редактирования",
|
"Таймаут редактирования",
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -138,8 +168,12 @@ pub async fn edit_message<T: TdClientTrait>(app: &mut App<T>, chat_id: i64, msg_
|
|||||||
let old_reply_to = messages[pos].interactions.reply_to.clone();
|
let old_reply_to = messages[pos].interactions.reply_to.clone();
|
||||||
// Если в старом сообщении был reply и в новом он "Unknown" - сохраняем старый
|
// Если в старом сообщении был reply и в новом он "Unknown" - сохраняем старый
|
||||||
if let Some(old_reply) = old_reply_to {
|
if let Some(old_reply) = old_reply_to {
|
||||||
if edited_msg.interactions.reply_to.as_ref()
|
if edited_msg
|
||||||
.map_or(true, |r| r.sender_name == "Unknown") {
|
.interactions
|
||||||
|
.reply_to
|
||||||
|
.as_ref()
|
||||||
|
.is_none_or(|r| r.sender_name == "Unknown")
|
||||||
|
{
|
||||||
edited_msg.interactions.reply_to = Some(old_reply);
|
edited_msg.interactions.reply_to = Some(old_reply);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -167,13 +201,13 @@ pub async fn send_new_message<T: TdClientTrait>(app: &mut App<T>, chat_id: i64,
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Создаём ReplyInfo ДО отправки, пока сообщение точно доступно
|
// Создаём ReplyInfo ДО отправки, пока сообщение точно доступно
|
||||||
let reply_info = app.get_replying_to_message().map(|m| {
|
let reply_info = app
|
||||||
crate::tdlib::ReplyInfo {
|
.get_replying_to_message()
|
||||||
|
.map(|m| crate::tdlib::ReplyInfo {
|
||||||
message_id: m.id(),
|
message_id: m.id(),
|
||||||
sender_name: m.sender_name().to_string(),
|
sender_name: m.sender_name().to_string(),
|
||||||
text: m.text().to_string(),
|
text: m.text().to_string(),
|
||||||
}
|
});
|
||||||
});
|
|
||||||
|
|
||||||
app.message_input.clear();
|
app.message_input.clear();
|
||||||
app.cursor_position = 0;
|
app.cursor_position = 0;
|
||||||
@@ -184,11 +218,14 @@ pub async fn send_new_message<T: TdClientTrait>(app: &mut App<T>, chat_id: i64,
|
|||||||
app.last_typing_sent = None;
|
app.last_typing_sent = None;
|
||||||
|
|
||||||
// Отменяем typing status
|
// Отменяем typing status
|
||||||
app.td_client.send_chat_action(ChatId::new(chat_id), ChatAction::Cancel).await;
|
app.td_client
|
||||||
|
.send_chat_action(ChatId::new(chat_id), ChatAction::Cancel)
|
||||||
|
.await;
|
||||||
|
|
||||||
match with_timeout_msg(
|
match with_timeout_msg(
|
||||||
Duration::from_secs(5),
|
Duration::from_secs(5),
|
||||||
app.td_client.send_message(ChatId::new(chat_id), text, reply_to_id, reply_info),
|
app.td_client
|
||||||
|
.send_message(ChatId::new(chat_id), text, reply_to_id, reply_info),
|
||||||
"Таймаут отправки",
|
"Таймаут отправки",
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -206,7 +243,7 @@ pub async fn send_new_message<T: TdClientTrait>(app: &mut App<T>, chat_id: i64,
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Обработка клавиши Enter
|
/// Обработка клавиши Enter
|
||||||
///
|
///
|
||||||
/// Обрабатывает три сценария:
|
/// Обрабатывает три сценария:
|
||||||
/// 1. В режиме выбора сообщения: начать редактирование
|
/// 1. В режиме выбора сообщения: начать редактирование
|
||||||
/// 2. В открытом чате: отправить новое или редактировать существующее сообщение
|
/// 2. В открытом чате: отправить новое или редактировать существующее сообщение
|
||||||
@@ -227,7 +264,9 @@ pub async fn handle_enter_key<T: TdClientTrait>(app: &mut App<T>) {
|
|||||||
|
|
||||||
// Сценарий 2: Режим выбора сообщения - начать редактирование
|
// Сценарий 2: Режим выбора сообщения - начать редактирование
|
||||||
if app.is_selecting_message() {
|
if app.is_selecting_message() {
|
||||||
if !app.start_editing_selected() {
|
if app.start_editing_selected() {
|
||||||
|
app.input_mode = InputMode::Insert;
|
||||||
|
} else {
|
||||||
// Нельзя редактировать это сообщение
|
// Нельзя редактировать это сообщение
|
||||||
app.chat_state = crate::app::ChatState::Normal;
|
app.chat_state = crate::app::ChatState::Normal;
|
||||||
}
|
}
|
||||||
@@ -280,7 +319,8 @@ pub async fn send_reaction<T: TdClientTrait>(app: &mut App<T>) {
|
|||||||
// Send reaction with timeout
|
// Send reaction with timeout
|
||||||
let result = with_timeout_msg(
|
let result = with_timeout_msg(
|
||||||
Duration::from_secs(5),
|
Duration::from_secs(5),
|
||||||
app.td_client.toggle_reaction(chat_id, message_id, emoji.clone()),
|
app.td_client
|
||||||
|
.toggle_reaction(chat_id, message_id, emoji.clone()),
|
||||||
"Таймаут отправки реакции",
|
"Таймаут отправки реакции",
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -300,51 +340,8 @@ pub async fn send_reaction<T: TdClientTrait>(app: &mut App<T>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Подгружает старые сообщения если скролл близко к верху
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Обработка ввода клавиатуры в открытом чате
|
/// Обработка ввода клавиатуры в открытом чате
|
||||||
///
|
///
|
||||||
/// Обрабатывает:
|
/// Обрабатывает:
|
||||||
/// - Backspace/Delete: удаление символов относительно курсора
|
/// - Backspace/Delete: удаление символов относительно курсора
|
||||||
/// - Char: вставка символов в позицию курсора + typing status
|
/// - Char: вставка символов в позицию курсора + typing status
|
||||||
@@ -384,7 +381,8 @@ pub async fn handle_open_chat_keyboard_input<T: TdClientTrait>(app: &mut App<T>,
|
|||||||
// Игнорируем символы с Ctrl/Alt модификаторами (кроме Shift)
|
// Игнорируем символы с Ctrl/Alt модификаторами (кроме Shift)
|
||||||
// Это позволяет обрабатывать хоткеи типа Ctrl+U для профиля
|
// Это позволяет обрабатывать хоткеи типа Ctrl+U для профиля
|
||||||
if key.modifiers.contains(KeyModifiers::CONTROL)
|
if key.modifiers.contains(KeyModifiers::CONTROL)
|
||||||
|| key.modifiers.contains(KeyModifiers::ALT) {
|
|| key.modifiers.contains(KeyModifiers::ALT)
|
||||||
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -410,7 +408,9 @@ pub async fn handle_open_chat_keyboard_input<T: TdClientTrait>(app: &mut App<T>,
|
|||||||
.unwrap_or(true);
|
.unwrap_or(true);
|
||||||
if should_send_typing {
|
if should_send_typing {
|
||||||
if let Some(chat_id) = app.get_selected_chat_id() {
|
if let Some(chat_id) = app.get_selected_chat_id() {
|
||||||
app.td_client.send_chat_action(ChatId::new(chat_id), ChatAction::Typing).await;
|
app.td_client
|
||||||
|
.send_chat_action(ChatId::new(chat_id), ChatAction::Typing)
|
||||||
|
.await;
|
||||||
app.last_typing_sent = Some(Instant::now());
|
app.last_typing_sent = Some(Instant::now());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -436,25 +436,377 @@ pub async fn handle_open_chat_keyboard_input<T: TdClientTrait>(app: &mut App<T>,
|
|||||||
// Курсор в конец
|
// Курсор в конец
|
||||||
app.cursor_position = app.message_input.chars().count();
|
app.cursor_position = app.message_input.chars().count();
|
||||||
}
|
}
|
||||||
// Стрелки вверх/вниз - скролл сообщений или начало выбора
|
// Стрелки вверх/вниз - скролл сообщений (в Insert mode)
|
||||||
KeyCode::Down => {
|
KeyCode::Down => {
|
||||||
// Скролл вниз (к новым сообщениям)
|
|
||||||
if app.message_scroll_offset > 0 {
|
if app.message_scroll_offset > 0 {
|
||||||
app.message_scroll_offset = app.message_scroll_offset.saturating_sub(3);
|
app.message_scroll_offset = app.message_scroll_offset.saturating_sub(3);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Up => {
|
KeyCode::Up => {
|
||||||
// Если инпут пустой и не в режиме редактирования — начать выбор сообщения
|
// В Insert mode — только скролл
|
||||||
if app.message_input.is_empty() && !app.is_editing() {
|
app.message_scroll_offset += 3;
|
||||||
app.start_message_selection();
|
load_older_messages_if_needed(app).await;
|
||||||
} else {
|
|
||||||
// Скролл вверх (к старым сообщениям)
|
|
||||||
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
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|||||||
@@ -5,19 +5,23 @@
|
|||||||
//! - Folder selection
|
//! - Folder selection
|
||||||
//! - Opening chats
|
//! - Opening chats
|
||||||
|
|
||||||
|
use crate::app::methods::navigation::NavigationMethods;
|
||||||
use crate::app::App;
|
use crate::app::App;
|
||||||
use crate::tdlib::TdClientTrait;
|
use crate::tdlib::TdClientTrait;
|
||||||
use crate::types::{ChatId, MessageId};
|
use crate::utils::with_timeout;
|
||||||
use crate::utils::{with_timeout, with_timeout_msg, with_timeout_ignore};
|
use crossterm::event::KeyEvent;
|
||||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
/// Обработка навигации в списке чатов
|
/// Обработка навигации в списке чатов
|
||||||
///
|
///
|
||||||
/// Обрабатывает:
|
/// Обрабатывает:
|
||||||
/// - Up/Down/j/k: навигация между чатами
|
/// - Up/Down/j/k: навигация между чатами
|
||||||
/// - Цифры 1-9: переключение папок (1=All, 2-9=папки из TDLib)
|
/// - Цифры 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>) {
|
pub async fn handle_chat_list_navigation<T: TdClientTrait>(
|
||||||
|
app: &mut App<T>,
|
||||||
|
_key: KeyEvent,
|
||||||
|
command: Option<crate::config::Command>,
|
||||||
|
) {
|
||||||
match command {
|
match command {
|
||||||
Some(crate::config::Command::MoveDown) => {
|
Some(crate::config::Command::MoveDown) => {
|
||||||
app.next_chat();
|
app.next_chat();
|
||||||
@@ -63,81 +67,11 @@ pub async fn select_folder<T: TdClientTrait>(app: &mut App<T>, folder_idx: usize
|
|||||||
let folder_id = folder.id;
|
let folder_id = folder.id;
|
||||||
app.selected_folder_id = Some(folder_id);
|
app.selected_folder_id = Some(folder_id);
|
||||||
app.status_message = Some("Загрузка чатов папки...".to_string());
|
app.status_message = Some("Загрузка чатов папки...".to_string());
|
||||||
let _ = with_timeout(
|
let _ =
|
||||||
Duration::from_secs(5),
|
with_timeout(Duration::from_secs(5), app.td_client.load_folder_chats(folder_id, 50))
|
||||||
app.td_client.load_folder_chats(folder_id, 50),
|
.await;
|
||||||
)
|
|
||||||
.await;
|
|
||||||
app.status_message = None;
|
app.status_message = None;
|
||||||
app.chat_list_state.select(Some(0));
|
app.chat_list_state.select(Some(0));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Открывает чат и загружает все необходимые данные.
|
|
||||||
///
|
|
||||||
/// Выполняет:
|
|
||||||
/// - Загрузку истории сообщений (с timeout)
|
|
||||||
/// - Установку current_chat_id (после загрузки, чтобы избежать race condition)
|
|
||||||
/// - Загрузку reply info (с timeout)
|
|
||||||
/// - Загрузку закреплённого сообщения (с timeout)
|
|
||||||
/// - Загрузку черновика
|
|
||||||
///
|
|
||||||
/// При ошибке устанавливает 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;
|
|
||||||
|
|
||||||
// Загружаем все доступные сообщения (без лимита)
|
|
||||||
match with_timeout_msg(
|
|
||||||
Duration::from_secs(30),
|
|
||||||
app.td_client.get_chat_history(ChatId::new(chat_id), i32::MAX),
|
|
||||||
"Таймаут загрузки сообщений",
|
|
||||||
)
|
|
||||||
.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)));
|
|
||||||
|
|
||||||
// Загружаем недостающие 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(ChatId::new(chat_id)),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
// Загружаем черновик
|
|
||||||
app.load_draft();
|
|
||||||
app.status_message = None;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
app.error_message = Some(e);
|
|
||||||
app.status_message = None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,20 +6,27 @@
|
|||||||
//! - Edit mode
|
//! - Edit mode
|
||||||
//! - Cursor movement and text editing
|
//! - Cursor movement and text editing
|
||||||
|
|
||||||
|
use crate::app::methods::{
|
||||||
|
compose::ComposeMethods, navigation::NavigationMethods, search::SearchMethods,
|
||||||
|
};
|
||||||
use crate::app::App;
|
use crate::app::App;
|
||||||
use crate::tdlib::TdClientTrait;
|
use crate::tdlib::TdClientTrait;
|
||||||
use crate::types::ChatId;
|
use crate::types::ChatId;
|
||||||
use crate::utils::with_timeout_msg;
|
use crate::utils::with_timeout_msg;
|
||||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
use crossterm::event::KeyEvent;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
/// Обработка режима выбора чата для пересылки сообщения
|
/// Обработка режима выбора чата для пересылки сообщения
|
||||||
///
|
///
|
||||||
/// Обрабатывает:
|
/// Обрабатывает:
|
||||||
/// - Навигацию по списку чатов (Up/Down)
|
/// - Навигацию по списку чатов (Up/Down)
|
||||||
/// - Пересылку сообщения в выбранный чат (Enter)
|
/// - Пересылку сообщения в выбранный чат (Enter)
|
||||||
/// - Отмену пересылки (Esc)
|
/// - Отмену пересылки (Esc)
|
||||||
pub async fn handle_forward_mode<T: TdClientTrait>(app: &mut App<T>, _key: KeyEvent, command: Option<crate::config::Command>) {
|
pub async fn handle_forward_mode<T: TdClientTrait>(
|
||||||
|
app: &mut App<T>,
|
||||||
|
_key: KeyEvent,
|
||||||
|
command: Option<crate::config::Command>,
|
||||||
|
) {
|
||||||
match command {
|
match command {
|
||||||
Some(crate::config::Command::Cancel) => {
|
Some(crate::config::Command::Cancel) => {
|
||||||
app.cancel_forward();
|
app.cancel_forward();
|
||||||
@@ -60,11 +67,8 @@ pub async fn forward_selected_message<T: TdClientTrait>(app: &mut App<T>) {
|
|||||||
// Forward the message with timeout
|
// Forward the message with timeout
|
||||||
let result = with_timeout_msg(
|
let result = with_timeout_msg(
|
||||||
Duration::from_secs(5),
|
Duration::from_secs(5),
|
||||||
app.td_client.forward_messages(
|
app.td_client
|
||||||
to_chat_id,
|
.forward_messages(to_chat_id, ChatId::new(from_chat_id), vec![msg_id]),
|
||||||
ChatId::new(from_chat_id),
|
|
||||||
vec![msg_id],
|
|
||||||
),
|
|
||||||
"Таймаут пересылки",
|
"Таймаут пересылки",
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -78,4 +82,4 @@ pub async fn forward_selected_message<T: TdClientTrait>(app: &mut App<T>) {
|
|||||||
app.error_message = Some(e);
|
app.error_message = Some(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
//! - Ctrl+P: View pinned messages
|
//! - Ctrl+P: View pinned messages
|
||||||
//! - Ctrl+F: Search messages in chat
|
//! - Ctrl+F: Search messages in chat
|
||||||
|
|
||||||
|
use crate::app::methods::{modal::ModalMethods, search::SearchMethods};
|
||||||
use crate::app::App;
|
use crate::app::App;
|
||||||
use crate::tdlib::TdClientTrait;
|
use crate::tdlib::TdClientTrait;
|
||||||
use crate::types::ChatId;
|
use crate::types::ChatId;
|
||||||
@@ -46,7 +47,8 @@ pub async fn handle_global_commands<T: TdClientTrait>(app: &mut App<T>, key: Key
|
|||||||
KeyCode::Char('r') if has_ctrl => {
|
KeyCode::Char('r') if has_ctrl => {
|
||||||
// Ctrl+R - обновить список чатов
|
// Ctrl+R - обновить список чатов
|
||||||
app.status_message = Some("Обновление чатов...".to_string());
|
app.status_message = Some("Обновление чатов...".to_string());
|
||||||
let _ = with_timeout(Duration::from_secs(5), app.td_client.load_chats(50)).await;
|
let _ =
|
||||||
|
with_timeout(Duration::from_secs(5), app.td_client.load_chats(50)).await;
|
||||||
// Синхронизируем muted чаты после обновления
|
// Синхронизируем muted чаты после обновления
|
||||||
app.td_client.sync_notification_muted_chats();
|
app.td_client.sync_notification_muted_chats();
|
||||||
app.status_message = None;
|
app.status_message = None;
|
||||||
@@ -57,6 +59,11 @@ pub async fn handle_global_commands<T: TdClientTrait>(app: &mut App<T>, key: Key
|
|||||||
handle_pinned_messages(app).await;
|
handle_pinned_messages(app).await;
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
KeyCode::Char('a') if has_ctrl => {
|
||||||
|
// Ctrl+A - переключение аккаунтов
|
||||||
|
app.open_account_switcher();
|
||||||
|
true
|
||||||
|
}
|
||||||
_ => false,
|
_ => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,19 +6,40 @@
|
|||||||
//! - profile: Profile helper functions
|
//! - profile: Profile helper functions
|
||||||
//! - chat: Keyboard input handling for open chat view
|
//! - chat: Keyboard input handling for open chat view
|
||||||
//! - chat_list: Navigation and interaction in the chat list
|
//! - chat_list: Navigation and interaction in the chat list
|
||||||
|
//! - chat_loader: All phases of chat message loading
|
||||||
//! - compose: Text input, editing, and message composition
|
//! - compose: Text input, editing, and message composition
|
||||||
//! - modal: Modal dialogs (delete confirmation, emoji picker, etc.)
|
//! - modal: Modal dialogs (delete confirmation, emoji picker, etc.)
|
||||||
//! - search: Search functionality (chat search, message search)
|
//! - search: Search functionality (chat search, message search)
|
||||||
|
|
||||||
pub mod clipboard;
|
|
||||||
pub mod global;
|
|
||||||
pub mod profile;
|
|
||||||
pub mod chat;
|
pub mod chat;
|
||||||
pub mod chat_list;
|
pub mod chat_list;
|
||||||
|
pub mod chat_loader;
|
||||||
|
pub mod clipboard;
|
||||||
pub mod compose;
|
pub mod compose;
|
||||||
|
pub mod global;
|
||||||
pub mod modal;
|
pub mod modal;
|
||||||
|
pub mod profile;
|
||||||
pub mod search;
|
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 clipboard::*;
|
||||||
pub use global::*;
|
pub use global::*;
|
||||||
pub use profile::get_available_actions_count;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,27 +1,124 @@
|
|||||||
//! Modal dialog handlers
|
//! Modal dialog handlers
|
||||||
//!
|
//!
|
||||||
//! Handles keyboard input for modal dialogs, including:
|
//! Handles keyboard input for modal dialogs, including:
|
||||||
|
//! - Account switcher (global overlay)
|
||||||
//! - Delete confirmation
|
//! - Delete confirmation
|
||||||
//! - Reaction picker (emoji selector)
|
//! - Reaction picker (emoji selector)
|
||||||
//! - Pinned messages view
|
//! - Pinned messages view
|
||||||
//! - Profile information modal
|
//! - Profile information modal
|
||||||
|
|
||||||
use crate::app::App;
|
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::tdlib::TdClientTrait;
|
||||||
use crate::types::{ChatId, MessageId};
|
use crate::types::{ChatId, MessageId};
|
||||||
use crate::utils::{with_timeout_msg, modal_handler::handle_yes_no};
|
use crate::utils::{modal_handler::handle_yes_no, with_timeout_msg};
|
||||||
use crate::input::handlers::get_available_actions_count;
|
use crossterm::event::{KeyCode, KeyEvent};
|
||||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
|
||||||
use std::time::Duration;
|
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)
|
/// - Навигацию по действиям профиля (Up/Down)
|
||||||
/// - Выполнение выбранного действия (Enter): открыть в браузере, скопировать ID, покинуть группу
|
/// - Выполнение выбранного действия (Enter): открыть в браузере, скопировать ID, покинуть группу
|
||||||
/// - Выход из режима профиля (Esc)
|
/// - Выход из режима профиля (Esc)
|
||||||
pub async fn handle_profile_mode<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent, command: Option<crate::config::Command>) {
|
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();
|
let confirmation_step = app.get_leave_group_confirmation_step();
|
||||||
if confirmation_step > 0 {
|
if confirmation_step > 0 {
|
||||||
@@ -94,10 +191,7 @@ pub async fn handle_profile_mode<T: TdClientTrait>(app: &mut App<T>, key: KeyEve
|
|||||||
// Действие: Открыть в браузере
|
// Действие: Открыть в браузере
|
||||||
if let Some(username) = &profile.username {
|
if let Some(username) = &profile.username {
|
||||||
if action_index == current_idx {
|
if action_index == current_idx {
|
||||||
let url = format!(
|
let url = format!("https://t.me/{}", username.trim_start_matches('@'));
|
||||||
"https://t.me/{}",
|
|
||||||
username.trim_start_matches('@')
|
|
||||||
);
|
|
||||||
#[cfg(feature = "url-open")]
|
#[cfg(feature = "url-open")]
|
||||||
{
|
{
|
||||||
match open::that(&url) {
|
match open::that(&url) {
|
||||||
@@ -113,7 +207,7 @@ pub async fn handle_profile_mode<T: TdClientTrait>(app: &mut App<T>, key: KeyEve
|
|||||||
#[cfg(not(feature = "url-open"))]
|
#[cfg(not(feature = "url-open"))]
|
||||||
{
|
{
|
||||||
app.error_message = Some(
|
app.error_message = Some(
|
||||||
"Открытие URL недоступно (требуется feature 'url-open')".to_string()
|
"Открытие URL недоступно (требуется feature 'url-open')".to_string(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -138,7 +232,7 @@ pub async fn handle_profile_mode<T: TdClientTrait>(app: &mut App<T>, key: KeyEve
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Обработка Ctrl+U для открытия профиля чата/пользователя
|
/// Обработка Ctrl+U для открытия профиля чата/пользователя
|
||||||
///
|
///
|
||||||
/// Загружает информацию о профиле и переключает в режим просмотра профиля
|
/// Загружает информацию о профиле и переключает в режим просмотра профиля
|
||||||
pub async fn handle_profile_open<T: TdClientTrait>(app: &mut App<T>) {
|
pub async fn handle_profile_open<T: TdClientTrait>(app: &mut App<T>) {
|
||||||
let Some(chat_id) = app.selected_chat_id else {
|
let Some(chat_id) = app.selected_chat_id else {
|
||||||
@@ -224,12 +318,16 @@ pub async fn handle_delete_confirmation<T: TdClientTrait>(app: &mut App<T>, key:
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Обработка режима выбора реакции (emoji picker)
|
/// Обработка режима выбора реакции (emoji picker)
|
||||||
///
|
///
|
||||||
/// Обрабатывает:
|
/// Обрабатывает:
|
||||||
/// - Навигацию по сетке реакций: Left/Right, Up/Down (сетка 8x6)
|
/// - Навигацию по сетке реакций: Left/Right, Up/Down (сетка 8x6)
|
||||||
/// - Добавление/удаление реакции (Enter)
|
/// - Добавление/удаление реакции (Enter)
|
||||||
/// - Выход из режима (Esc)
|
/// - Выход из режима (Esc)
|
||||||
pub async fn handle_reaction_picker_mode<T: TdClientTrait>(app: &mut App<T>, _key: KeyEvent, command: Option<crate::config::Command>) {
|
pub async fn handle_reaction_picker_mode<T: TdClientTrait>(
|
||||||
|
app: &mut App<T>,
|
||||||
|
_key: KeyEvent,
|
||||||
|
command: Option<crate::config::Command>,
|
||||||
|
) {
|
||||||
match command {
|
match command {
|
||||||
Some(crate::config::Command::MoveLeft) => {
|
Some(crate::config::Command::MoveLeft) => {
|
||||||
app.select_previous_reaction();
|
app.select_previous_reaction();
|
||||||
@@ -240,10 +338,8 @@ pub async fn handle_reaction_picker_mode<T: TdClientTrait>(app: &mut App<T>, _ke
|
|||||||
app.needs_redraw = true;
|
app.needs_redraw = true;
|
||||||
}
|
}
|
||||||
Some(crate::config::Command::MoveUp) => {
|
Some(crate::config::Command::MoveUp) => {
|
||||||
if let crate::app::ChatState::ReactionPicker {
|
if let crate::app::ChatState::ReactionPicker { selected_index, .. } =
|
||||||
selected_index,
|
&mut app.chat_state
|
||||||
..
|
|
||||||
} = &mut app.chat_state
|
|
||||||
{
|
{
|
||||||
if *selected_index >= 8 {
|
if *selected_index >= 8 {
|
||||||
*selected_index = selected_index.saturating_sub(8);
|
*selected_index = selected_index.saturating_sub(8);
|
||||||
@@ -277,12 +373,16 @@ pub async fn handle_reaction_picker_mode<T: TdClientTrait>(app: &mut App<T>, _ke
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Обработка режима просмотра закреплённых сообщений
|
/// Обработка режима просмотра закреплённых сообщений
|
||||||
///
|
///
|
||||||
/// Обрабатывает:
|
/// Обрабатывает:
|
||||||
/// - Навигацию по закреплённым сообщениям (Up/Down)
|
/// - Навигацию по закреплённым сообщениям (Up/Down)
|
||||||
/// - Переход к сообщению в истории (Enter)
|
/// - Переход к сообщению в истории (Enter)
|
||||||
/// - Выход из режима (Esc)
|
/// - Выход из режима (Esc)
|
||||||
pub async fn handle_pinned_mode<T: TdClientTrait>(app: &mut App<T>, _key: KeyEvent, command: Option<crate::config::Command>) {
|
pub async fn handle_pinned_mode<T: TdClientTrait>(
|
||||||
|
app: &mut App<T>,
|
||||||
|
_key: KeyEvent,
|
||||||
|
command: Option<crate::config::Command>,
|
||||||
|
) {
|
||||||
match command {
|
match command {
|
||||||
Some(crate::config::Command::Cancel) => {
|
Some(crate::config::Command::Cancel) => {
|
||||||
app.exit_pinned_mode();
|
app.exit_pinned_mode();
|
||||||
@@ -295,20 +395,10 @@ pub async fn handle_pinned_mode<T: TdClientTrait>(app: &mut App<T>, _key: KeyEve
|
|||||||
}
|
}
|
||||||
Some(crate::config::Command::SubmitMessage) => {
|
Some(crate::config::Command::SubmitMessage) => {
|
||||||
if let Some(msg_id) = app.get_selected_pinned_id() {
|
if let Some(msg_id) = app.get_selected_pinned_id() {
|
||||||
let msg_id = MessageId::new(msg_id);
|
scroll_to_message(app, MessageId::new(msg_id));
|
||||||
let msg_index = app
|
|
||||||
.td_client
|
|
||||||
.current_chat_messages()
|
|
||||||
.iter()
|
|
||||||
.position(|m| m.id() == msg_id);
|
|
||||||
|
|
||||||
if let Some(idx) = msg_index {
|
|
||||||
let total = app.td_client.current_chat_messages().len();
|
|
||||||
app.message_scroll_offset = total.saturating_sub(idx + 5);
|
|
||||||
}
|
|
||||||
app.exit_pinned_mode();
|
app.exit_pinned_mode();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,24 +5,29 @@
|
|||||||
//! - Message search mode
|
//! - Message search mode
|
||||||
//! - Search query input
|
//! - Search query input
|
||||||
|
|
||||||
|
use crate::app::methods::{navigation::NavigationMethods, search::SearchMethods};
|
||||||
use crate::app::App;
|
use crate::app::App;
|
||||||
use crate::tdlib::TdClientTrait;
|
use crate::tdlib::TdClientTrait;
|
||||||
use crate::types::{ChatId, MessageId};
|
use crate::types::{ChatId, MessageId};
|
||||||
use crate::utils::with_timeout;
|
use crate::utils::with_timeout;
|
||||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
use crossterm::event::{KeyCode, KeyEvent};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
// Import from chat_list module
|
use super::chat_loader::open_chat_and_load_data;
|
||||||
use super::chat_list::open_chat_and_load_data;
|
use super::scroll_to_message;
|
||||||
|
|
||||||
/// Обработка режима поиска по чатам
|
/// Обработка режима поиска по чатам
|
||||||
///
|
///
|
||||||
/// Обрабатывает:
|
/// Обрабатывает:
|
||||||
/// - Редактирование поискового запроса (Backspace, Char)
|
/// - Редактирование поискового запроса (Backspace, Char)
|
||||||
/// - Навигацию по отфильтрованному списку (Up/Down)
|
/// - Навигацию по отфильтрованному списку (Up/Down)
|
||||||
/// - Открытие выбранного чата (Enter)
|
/// - Открытие выбранного чата (Enter)
|
||||||
/// - Отмену поиска (Esc)
|
/// - Отмену поиска (Esc)
|
||||||
pub async fn handle_chat_search_mode<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent, command: Option<crate::config::Command>) {
|
pub async fn handle_chat_search_mode<T: TdClientTrait>(
|
||||||
|
app: &mut App<T>,
|
||||||
|
key: KeyEvent,
|
||||||
|
command: Option<crate::config::Command>,
|
||||||
|
) {
|
||||||
match command {
|
match command {
|
||||||
Some(crate::config::Command::Cancel) => {
|
Some(crate::config::Command::Cancel) => {
|
||||||
app.cancel_search();
|
app.cancel_search();
|
||||||
@@ -39,30 +44,32 @@ pub async fn handle_chat_search_mode<T: TdClientTrait>(app: &mut App<T>, key: Ke
|
|||||||
Some(crate::config::Command::MoveUp) => {
|
Some(crate::config::Command::MoveUp) => {
|
||||||
app.previous_filtered_chat();
|
app.previous_filtered_chat();
|
||||||
}
|
}
|
||||||
_ => {
|
_ => match key.code {
|
||||||
match key.code {
|
KeyCode::Backspace => {
|
||||||
KeyCode::Backspace => {
|
app.search_query.pop();
|
||||||
app.search_query.pop();
|
app.chat_list_state.select(Some(0));
|
||||||
app.chat_list_state.select(Some(0));
|
|
||||||
}
|
|
||||||
KeyCode::Char(c) => {
|
|
||||||
app.search_query.push(c);
|
|
||||||
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)
|
/// - Навигацию по результатам поиска (Up/Down/N/n)
|
||||||
/// - Переход к выбранному сообщению (Enter)
|
/// - Переход к выбранному сообщению (Enter)
|
||||||
/// - Редактирование поискового запроса (Backspace, Char)
|
/// - Редактирование поискового запроса (Backspace, Char)
|
||||||
/// - Выход из режима поиска (Esc)
|
/// - Выход из режима поиска (Esc)
|
||||||
pub async fn handle_message_search_mode<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent, command: Option<crate::config::Command>) {
|
pub async fn handle_message_search_mode<T: TdClientTrait>(
|
||||||
|
app: &mut App<T>,
|
||||||
|
key: KeyEvent,
|
||||||
|
command: Option<crate::config::Command>,
|
||||||
|
) {
|
||||||
match command {
|
match command {
|
||||||
Some(crate::config::Command::Cancel) => {
|
Some(crate::config::Command::Cancel) => {
|
||||||
app.exit_message_search_mode();
|
app.exit_message_search_mode();
|
||||||
@@ -75,47 +82,35 @@ pub async fn handle_message_search_mode<T: TdClientTrait>(app: &mut App<T>, key:
|
|||||||
}
|
}
|
||||||
Some(crate::config::Command::SubmitMessage) => {
|
Some(crate::config::Command::SubmitMessage) => {
|
||||||
if let Some(msg_id) = app.get_selected_search_result_id() {
|
if let Some(msg_id) = app.get_selected_search_result_id() {
|
||||||
let msg_id = MessageId::new(msg_id);
|
scroll_to_message(app, MessageId::new(msg_id));
|
||||||
let msg_index = app
|
|
||||||
.td_client
|
|
||||||
.current_chat_messages()
|
|
||||||
.iter()
|
|
||||||
.position(|m| m.id() == msg_id);
|
|
||||||
|
|
||||||
if let Some(idx) = msg_index {
|
|
||||||
let total = app.td_client.current_chat_messages().len();
|
|
||||||
app.message_scroll_offset = total.saturating_sub(idx + 5);
|
|
||||||
}
|
|
||||||
app.exit_message_search_mode();
|
app.exit_message_search_mode();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {
|
_ => match key.code {
|
||||||
match key.code {
|
KeyCode::Char('N') => {
|
||||||
KeyCode::Char('N') => {
|
app.select_previous_search_result();
|
||||||
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;
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
}
|
||||||
}
|
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;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,4 +133,4 @@ pub async fn perform_message_search<T: TdClientTrait>(app: &mut App<T>, query: &
|
|||||||
{
|
{
|
||||||
app.set_search_results(results);
|
app.set_search_results(results);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,58 +1,37 @@
|
|||||||
|
//! Main screen input router.
|
||||||
|
//!
|
||||||
|
//! Dispatches keyboard events to specialized handlers based on current app mode.
|
||||||
|
//! Priority order: modals → search → compose → chat → chat list.
|
||||||
|
|
||||||
|
use crate::app::methods::{
|
||||||
|
compose::ComposeMethods, messages::MessageMethods, modal::ModalMethods,
|
||||||
|
navigation::NavigationMethods, search::SearchMethods,
|
||||||
|
};
|
||||||
use crate::app::App;
|
use crate::app::App;
|
||||||
use crate::tdlib::TdClientTrait;
|
use crate::app::InputMode;
|
||||||
use crate::input::handlers::{
|
use crate::input::handlers::{
|
||||||
copy_to_clipboard, format_message_for_clipboard, get_available_actions_count,
|
chat::{handle_enter_key, handle_message_selection, handle_open_chat_keyboard_input},
|
||||||
|
chat_list::handle_chat_list_navigation,
|
||||||
|
compose::handle_forward_mode,
|
||||||
handle_global_commands,
|
handle_global_commands,
|
||||||
modal::{
|
modal::{
|
||||||
handle_profile_mode, handle_profile_open, handle_delete_confirmation,
|
handle_account_switcher, handle_delete_confirmation, handle_pinned_mode,
|
||||||
handle_reaction_picker_mode, handle_pinned_mode,
|
handle_profile_mode, handle_profile_open, handle_reaction_picker_mode,
|
||||||
},
|
|
||||||
search::{
|
|
||||||
handle_chat_search_mode, handle_message_search_mode, perform_message_search,
|
|
||||||
},
|
|
||||||
compose::{
|
|
||||||
handle_forward_mode, forward_selected_message,
|
|
||||||
},
|
|
||||||
chat_list::{
|
|
||||||
handle_chat_list_navigation, select_folder, open_chat_and_load_data,
|
|
||||||
},
|
|
||||||
chat::{
|
|
||||||
handle_message_selection, handle_enter_key, send_reaction,
|
|
||||||
load_older_messages_if_needed, handle_open_chat_keyboard_input,
|
|
||||||
},
|
},
|
||||||
|
search::{handle_chat_search_mode, handle_message_search_mode},
|
||||||
};
|
};
|
||||||
use crate::tdlib::ChatAction;
|
use crate::tdlib::TdClientTrait;
|
||||||
use crate::types::{ChatId, MessageId};
|
use crossterm::event::KeyEvent;
|
||||||
use crate::utils::{is_non_empty, with_timeout, with_timeout_msg, with_timeout_ignore};
|
|
||||||
use crate::utils::modal_handler::handle_yes_no;
|
|
||||||
use crossterm::event::{KeyCode, KeyEvent};
|
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
|
|
||||||
|
/// Обработка клавиши Esc в Normal mode
|
||||||
|
///
|
||||||
/// Обработка клавиши Esc
|
/// Закрывает чат с сохранением черновика
|
||||||
///
|
async fn handle_escape_normal<T: TdClientTrait>(app: &mut App<T>) {
|
||||||
/// Обрабатывает отмену текущего действия или закрытие чата:
|
// Закрываем модальное окно изображения если открыто
|
||||||
/// - В режиме выбора сообщения: отменить выбор
|
#[cfg(feature = "images")]
|
||||||
/// - В режиме редактирования: отменить редактирование
|
if app.image_modal.is_some() {
|
||||||
/// - В режиме ответа: отменить ответ
|
app.image_modal = None;
|
||||||
/// - В открытом чате: сохранить черновик и закрыть чат
|
app.needs_redraw = true;
|
||||||
async fn handle_escape_key<T: TdClientTrait>(app: &mut App<T>) {
|
|
||||||
// Early return для режима выбора сообщения
|
|
||||||
if app.is_selecting_message() {
|
|
||||||
app.chat_state = crate::app::ChatState::Normal;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Early return для режима редактирования
|
|
||||||
if app.is_editing() {
|
|
||||||
app.cancel_editing();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Early return для режима ответа
|
|
||||||
if app.is_replying() {
|
|
||||||
app.cancel_reply();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,83 +41,189 @@ async fn handle_escape_key<T: TdClientTrait>(app: &mut App<T>) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Сохраняем черновик если есть текст в инпуте
|
// Сохраняем черновик если есть текст в инпуте
|
||||||
if !app.message_input.is_empty() && !app.is_editing() && !app.is_replying() {
|
if !app.message_input.is_empty() {
|
||||||
let draft_text = app.message_input.clone();
|
let draft_text = app.message_input.clone();
|
||||||
let _ = app.td_client.set_draft_message(chat_id, draft_text).await;
|
let _ = app.td_client.set_draft_message(chat_id, draft_text).await;
|
||||||
} else if app.message_input.is_empty() {
|
} else {
|
||||||
// Очищаем черновик если инпут пустой
|
// Очищаем черновик если инпут пустой
|
||||||
let _ = app.td_client.set_draft_message(chat_id, String::new()).await;
|
let _ = app
|
||||||
|
.td_client
|
||||||
|
.set_draft_message(chat_id, String::new())
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
app.close_chat();
|
app.close_chat();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Обработка клавиши Esc в Insert mode
|
||||||
|
///
|
||||||
|
/// Отменяет Reply/Editing и возвращает в Normal + MessageSelection
|
||||||
|
fn handle_escape_insert<T: TdClientTrait>(app: &mut App<T>) {
|
||||||
|
if app.is_editing() {
|
||||||
|
app.cancel_editing();
|
||||||
|
}
|
||||||
|
if app.is_replying() {
|
||||||
|
app.cancel_reply();
|
||||||
|
}
|
||||||
|
app.input_mode = InputMode::Normal;
|
||||||
|
app.start_message_selection();
|
||||||
|
}
|
||||||
|
|
||||||
/// Главный обработчик ввода - роутер для всех режимов приложения
|
/// Главный обработчик ввода - роутер для всех режимов приложения
|
||||||
pub async fn handle<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent) {
|
pub async fn handle<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent) {
|
||||||
// Глобальные команды (работают всегда)
|
let command = app.get_command(key);
|
||||||
|
|
||||||
|
// 0. Account switcher (глобальный оверлей — highest priority)
|
||||||
|
if app.account_switcher.is_some() {
|
||||||
|
handle_account_switcher(app, key, command).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Insert mode + чат открыт → только текст, Enter, Esc
|
||||||
|
// (Ctrl+C обрабатывается в main.rs до вызова router)
|
||||||
|
if app.selected_chat_id.is_some() && app.input_mode == InputMode::Insert {
|
||||||
|
// Модальные окна всё равно обрабатываем (image modal, delete confirmation etc.)
|
||||||
|
#[cfg(feature = "images")]
|
||||||
|
if app.image_modal.is_some() {
|
||||||
|
handle_image_modal_mode(app, key).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if app.is_confirm_delete_shown() {
|
||||||
|
handle_delete_confirmation(app, key).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if app.is_reaction_picker_mode() {
|
||||||
|
handle_reaction_picker_mode(app, key, command).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if app.is_profile_mode() {
|
||||||
|
handle_profile_mode(app, key, command).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if app.is_message_search_mode() {
|
||||||
|
handle_message_search_mode(app, key, command).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if app.is_pinned_mode() {
|
||||||
|
handle_pinned_mode(app, key, command).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if app.is_forwarding() {
|
||||||
|
handle_forward_mode(app, key, command).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
match command {
|
||||||
|
Some(crate::config::Command::Cancel) => {
|
||||||
|
handle_escape_insert(app);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Some(crate::config::Command::SubmitMessage) => {
|
||||||
|
handle_enter_key(app).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Some(crate::config::Command::DeleteWord) => {
|
||||||
|
// Ctrl+W → удалить слово
|
||||||
|
if app.cursor_position > 0 {
|
||||||
|
let chars: Vec<char> = app.message_input.chars().collect();
|
||||||
|
let mut new_pos = app.cursor_position;
|
||||||
|
// Пропускаем пробелы
|
||||||
|
while new_pos > 0 && chars[new_pos - 1] == ' ' {
|
||||||
|
new_pos -= 1;
|
||||||
|
}
|
||||||
|
// Пропускаем слово
|
||||||
|
while new_pos > 0 && chars[new_pos - 1] != ' ' {
|
||||||
|
new_pos -= 1;
|
||||||
|
}
|
||||||
|
let new_input: String = chars[..new_pos]
|
||||||
|
.iter()
|
||||||
|
.chain(chars[app.cursor_position..].iter())
|
||||||
|
.collect();
|
||||||
|
app.message_input = new_input;
|
||||||
|
app.cursor_position = new_pos;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Some(crate::config::Command::MoveToStart) => {
|
||||||
|
app.cursor_position = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Some(crate::config::Command::MoveToEnd) => {
|
||||||
|
app.cursor_position = app.message_input.chars().count();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
// Весь остальной ввод → текст
|
||||||
|
handle_open_chat_keyboard_input(app, key).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Глобальные команды (Ctrl+R, Ctrl+S, Ctrl+P, Ctrl+F)
|
||||||
if handle_global_commands(app, key).await {
|
if handle_global_commands(app, key).await {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Получаем команду из keybindings
|
// 4. Модальное окно просмотра изображения
|
||||||
let command = app.get_command(key);
|
#[cfg(feature = "images")]
|
||||||
|
if app.image_modal.is_some() {
|
||||||
|
handle_image_modal_mode(app, key).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Режим профиля
|
// 5. Режим профиля
|
||||||
if app.is_profile_mode() {
|
if app.is_profile_mode() {
|
||||||
handle_profile_mode(app, key, command).await;
|
handle_profile_mode(app, key, command).await;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Режим поиска по сообщениям
|
// 6. Режим поиска по сообщениям
|
||||||
if app.is_message_search_mode() {
|
if app.is_message_search_mode() {
|
||||||
handle_message_search_mode(app, key, command).await;
|
handle_message_search_mode(app, key, command).await;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Режим просмотра закреплённых сообщений
|
// 7. Режим просмотра закреплённых сообщений
|
||||||
if app.is_pinned_mode() {
|
if app.is_pinned_mode() {
|
||||||
handle_pinned_mode(app, key, command).await;
|
handle_pinned_mode(app, key, command).await;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Обработка ввода в режиме выбора реакции
|
// 8. Обработка ввода в режиме выбора реакции
|
||||||
if app.is_reaction_picker_mode() {
|
if app.is_reaction_picker_mode() {
|
||||||
handle_reaction_picker_mode(app, key, command).await;
|
handle_reaction_picker_mode(app, key, command).await;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Модалка подтверждения удаления
|
// 9. Модалка подтверждения удаления
|
||||||
if app.is_confirm_delete_shown() {
|
if app.is_confirm_delete_shown() {
|
||||||
handle_delete_confirmation(app, key).await;
|
handle_delete_confirmation(app, key).await;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Режим выбора чата для пересылки
|
// 10. Режим выбора чата для пересылки
|
||||||
if app.is_forwarding() {
|
if app.is_forwarding() {
|
||||||
handle_forward_mode(app, key, command).await;
|
handle_forward_mode(app, key, command).await;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Режим поиска
|
// 11. Режим поиска чатов
|
||||||
if app.is_searching {
|
if app.is_searching {
|
||||||
handle_chat_search_mode(app, key, command).await;
|
handle_chat_search_mode(app, key, command).await;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Обработка команд через keybindings
|
// 12. Normal mode commands (Enter, Esc, Profile)
|
||||||
match command {
|
match command {
|
||||||
Some(crate::config::Command::SubmitMessage) => {
|
Some(crate::config::Command::SubmitMessage) => {
|
||||||
// Enter - открыть чат, отправить сообщение или редактировать
|
|
||||||
handle_enter_key(app).await;
|
handle_enter_key(app).await;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Some(crate::config::Command::Cancel) => {
|
Some(crate::config::Command::Cancel) => {
|
||||||
// Esc - отменить выбор/редактирование/reply или закрыть чат
|
handle_escape_normal(app).await;
|
||||||
handle_escape_key(app).await;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Some(crate::config::Command::OpenProfile) => {
|
Some(crate::config::Command::OpenProfile) => {
|
||||||
// Открыть профиль (обычно 'i')
|
|
||||||
if app.selected_chat_id.is_some() {
|
if app.selected_chat_id.is_some() {
|
||||||
handle_profile_open(app).await;
|
handle_profile_open(app).await;
|
||||||
return;
|
return;
|
||||||
@@ -147,18 +232,96 @@ pub async fn handle<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent) {
|
|||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Режим открытого чата
|
// 13. Normal mode в чате → MessageSelection
|
||||||
if app.selected_chat_id.is_some() {
|
if app.selected_chat_id.is_some() {
|
||||||
// Режим выбора сообщения для редактирования/удаления
|
// Auto-enter MessageSelection if not already in it
|
||||||
if app.is_selecting_message() {
|
if !app.is_selecting_message() {
|
||||||
handle_message_selection(app, key, command).await;
|
app.start_message_selection();
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
handle_message_selection(app, key, command).await;
|
||||||
handle_open_chat_keyboard_input(app, key).await;
|
|
||||||
} else {
|
} else {
|
||||||
// В режиме списка чатов - навигация стрелками и переключение папок
|
// 14. Список чатов
|
||||||
handle_chat_list_navigation(app, key, command).await;
|
handle_chat_list_navigation(app, key, command).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Обработка модального окна просмотра изображения
|
||||||
|
///
|
||||||
|
/// Hotkeys:
|
||||||
|
/// - Esc/q: закрыть модальное окно
|
||||||
|
/// - ←: предыдущее фото в чате
|
||||||
|
/// - →: следующее фото в чате
|
||||||
|
#[cfg(feature = "images")]
|
||||||
|
async fn handle_image_modal_mode<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent) {
|
||||||
|
use crossterm::event::KeyCode;
|
||||||
|
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('й') => {
|
||||||
|
// Закрываем модальное окно
|
||||||
|
app.image_modal = None;
|
||||||
|
app.needs_redraw = true;
|
||||||
|
}
|
||||||
|
KeyCode::Left | KeyCode::Char('h') | KeyCode::Char('р') => {
|
||||||
|
// Предыдущее фото в чате
|
||||||
|
navigate_to_adjacent_photo(app, Direction::Previous).await;
|
||||||
|
}
|
||||||
|
KeyCode::Right | KeyCode::Char('l') | KeyCode::Char('д') => {
|
||||||
|
// Следующее фото в чате
|
||||||
|
navigate_to_adjacent_photo(app, Direction::Next).await;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "images")]
|
||||||
|
enum Direction {
|
||||||
|
Previous,
|
||||||
|
Next,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Переключение на соседнее фото в чате
|
||||||
|
#[cfg(feature = "images")]
|
||||||
|
async fn navigate_to_adjacent_photo<T: TdClientTrait>(app: &mut App<T>, direction: Direction) {
|
||||||
|
use crate::tdlib::PhotoDownloadState;
|
||||||
|
|
||||||
|
let Some(current_modal) = &app.image_modal else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let current_msg_id = current_modal.message_id;
|
||||||
|
let messages = app.td_client.current_chat_messages();
|
||||||
|
|
||||||
|
// Находим текущее сообщение
|
||||||
|
let Some(current_idx) = messages.iter().position(|m| m.id() == current_msg_id) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ищем следующее/предыдущее сообщение с фото
|
||||||
|
let search_range: Box<dyn Iterator<Item = usize>> = match direction {
|
||||||
|
Direction::Previous => Box::new((0..current_idx).rev()),
|
||||||
|
Direction::Next => Box::new((current_idx + 1)..messages.len()),
|
||||||
|
};
|
||||||
|
|
||||||
|
for idx in search_range {
|
||||||
|
if let Some(photo) = messages[idx].photo_info() {
|
||||||
|
if let PhotoDownloadState::Downloaded(path) = &photo.download_state {
|
||||||
|
// Нашли фото - открываем его
|
||||||
|
app.image_modal = Some(crate::tdlib::ImageModalState {
|
||||||
|
message_id: messages[idx].id(),
|
||||||
|
photo_path: path.clone(),
|
||||||
|
photo_width: photo.width,
|
||||||
|
photo_height: photo.height,
|
||||||
|
});
|
||||||
|
app.needs_redraw = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если не нашли фото - показываем сообщение
|
||||||
|
let msg = match direction {
|
||||||
|
Direction::Previous => "Нет предыдущих фото",
|
||||||
|
Direction::Next => "Нет следующих фото",
|
||||||
|
};
|
||||||
|
app.status_message = Some(msg.to_string());
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,7 @@
|
|||||||
|
//! Input handling module.
|
||||||
|
//!
|
||||||
|
//! Routes keyboard events by screen (Auth vs Main) to specialized handlers.
|
||||||
|
|
||||||
mod auth;
|
mod auth;
|
||||||
pub mod handlers;
|
pub mod handlers;
|
||||||
mod main_input;
|
mod main_input;
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
// Library interface for tele-tui
|
//! tele-tui — TUI client for Telegram
|
||||||
// This allows tests to import modules
|
//!
|
||||||
|
//! Library interface exposing modules for integration testing.
|
||||||
|
|
||||||
|
pub mod accounts;
|
||||||
pub mod app;
|
pub mod app;
|
||||||
|
pub mod audio;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod constants;
|
pub mod constants;
|
||||||
pub mod formatting;
|
pub mod formatting;
|
||||||
pub mod input;
|
pub mod input;
|
||||||
|
#[cfg(feature = "images")]
|
||||||
|
pub mod media;
|
||||||
pub mod message_grouping;
|
pub mod message_grouping;
|
||||||
pub mod notifications;
|
pub mod notifications;
|
||||||
pub mod tdlib;
|
pub mod tdlib;
|
||||||
|
|||||||
273
src/main.rs
273
src/main.rs
@@ -1,8 +1,12 @@
|
|||||||
|
mod accounts;
|
||||||
mod app;
|
mod app;
|
||||||
|
mod audio;
|
||||||
mod config;
|
mod config;
|
||||||
mod constants;
|
mod constants;
|
||||||
mod formatting;
|
mod formatting;
|
||||||
mod input;
|
mod input;
|
||||||
|
#[cfg(feature = "images")]
|
||||||
|
mod media;
|
||||||
mod message_grouping;
|
mod message_grouping;
|
||||||
mod notifications;
|
mod notifications;
|
||||||
mod tdlib;
|
mod tdlib;
|
||||||
@@ -25,9 +29,23 @@ use tdlib_rs::enums::Update;
|
|||||||
use app::{App, AppScreen};
|
use app::{App, AppScreen};
|
||||||
use constants::{POLL_TIMEOUT_MS, SHUTDOWN_TIMEOUT_SECS};
|
use constants::{POLL_TIMEOUT_MS, SHUTDOWN_TIMEOUT_SECS};
|
||||||
use input::{handle_auth_input, handle_main_input};
|
use input::{handle_auth_input, handle_main_input};
|
||||||
|
use input::handlers::process_pending_chat_init;
|
||||||
use tdlib::AuthState;
|
use tdlib::AuthState;
|
||||||
use utils::{disable_tdlib_logs, with_timeout_ignore};
|
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]
|
#[tokio::main]
|
||||||
async fn main() -> Result<(), io::Error> {
|
async fn main() -> Result<(), io::Error> {
|
||||||
// Загружаем переменные окружения из .env
|
// Загружаем переменные окружения из .env
|
||||||
@@ -38,13 +56,41 @@ async fn main() -> Result<(), io::Error> {
|
|||||||
tracing_subscriber::fmt()
|
tracing_subscriber::fmt()
|
||||||
.with_env_filter(
|
.with_env_filter(
|
||||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn"))
|
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn")),
|
||||||
)
|
)
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
// Загружаем конфигурацию (создаёт дефолтный если отсутствует)
|
// Загружаем конфигурацию (создаёт дефолтный если отсутствует)
|
||||||
let config = config::Config::load();
|
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 ДО создания клиента
|
// Отключаем логи TDLib ДО создания клиента
|
||||||
disable_tdlib_logs();
|
disable_tdlib_logs();
|
||||||
|
|
||||||
@@ -63,24 +109,27 @@ async fn main() -> Result<(), io::Error> {
|
|||||||
panic_hook(info);
|
panic_hook(info);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Create app state
|
// Create app state with account-specific db_path
|
||||||
let mut app = App::new(config);
|
let mut app = App::new(config, db_path);
|
||||||
|
app.current_account_name = account_name;
|
||||||
|
app.account_lock = Some(account_lock);
|
||||||
|
|
||||||
// Запускаем инициализацию TDLib в фоне (только для реального клиента)
|
// Запускаем инициализацию TDLib в фоне (только для реального клиента)
|
||||||
let client_id = app.td_client.client_id();
|
let client_id = app.td_client.client_id();
|
||||||
let api_id = app.td_client.api_id;
|
let api_id = app.td_client.api_id;
|
||||||
let api_hash = app.td_client.api_hash.clone();
|
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 {
|
tokio::spawn(async move {
|
||||||
let _ = tdlib_rs::functions::set_tdlib_parameters(
|
if let Err(e) = tdlib_rs::functions::set_tdlib_parameters(
|
||||||
false, // use_test_dc
|
false, // use_test_dc
|
||||||
"tdlib_data".to_string(), // database_directory
|
db_path_str, // database_directory
|
||||||
"".to_string(), // files_directory
|
"".to_string(), // files_directory
|
||||||
"".to_string(), // database_encryption_key
|
"".to_string(), // database_encryption_key
|
||||||
true, // use_file_database
|
true, // use_file_database
|
||||||
true, // use_chat_info_database
|
true, // use_chat_info_database
|
||||||
true, // use_message_database
|
true, // use_message_database
|
||||||
false, // use_secret_chats
|
false, // use_secret_chats
|
||||||
api_id,
|
api_id,
|
||||||
api_hash,
|
api_hash,
|
||||||
"en".to_string(), // system_language_code
|
"en".to_string(), // system_language_code
|
||||||
@@ -89,7 +138,10 @@ async fn main() -> Result<(), io::Error> {
|
|||||||
env!("CARGO_PKG_VERSION").to_string(), // application_version
|
env!("CARGO_PKG_VERSION").to_string(), // application_version
|
||||||
client_id,
|
client_id,
|
||||||
)
|
)
|
||||||
.await;
|
.await
|
||||||
|
{
|
||||||
|
tracing::error!("set_tdlib_parameters failed: {:?}", e);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let res = run_app(&mut terminal, &mut app).await;
|
let res = run_app(&mut terminal, &mut app).await;
|
||||||
@@ -121,7 +173,7 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
|
|||||||
let polling_handle = tokio::spawn(async move {
|
let polling_handle = tokio::spawn(async move {
|
||||||
while !should_stop_clone.load(Ordering::Relaxed) {
|
while !should_stop_clone.load(Ordering::Relaxed) {
|
||||||
// receive() с таймаутом 0.1 сек чтобы периодически проверять флаг
|
// 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 let Ok(Some((update, _client_id))) = result {
|
||||||
if update_tx.send(update).is_err() {
|
if update_tx.send(update).is_err() {
|
||||||
break; // Канал закрыт, выходим
|
break; // Канал закрыт, выходим
|
||||||
@@ -143,6 +195,75 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
|
|||||||
app.needs_redraw = true;
|
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
|
// Очищаем устаревший typing status
|
||||||
if app.td_client.clear_stale_typing_status() {
|
if app.td_client.clear_stale_typing_status() {
|
||||||
app.needs_redraw = true;
|
app.needs_redraw = true;
|
||||||
@@ -164,6 +285,42 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
|
|||||||
app.needs_redraw = true;
|
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 {
|
if app.needs_redraw {
|
||||||
terminal.draw(|f| ui::render(f, app))?;
|
terminal.draw(|f| ui::render(f, app))?;
|
||||||
@@ -182,21 +339,39 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
|
|||||||
// Graceful shutdown
|
// Graceful shutdown
|
||||||
should_stop.store(true, Ordering::Relaxed);
|
should_stop.store(true, Ordering::Relaxed);
|
||||||
|
|
||||||
|
// Останавливаем воспроизведение голосового (убиваем ffplay)
|
||||||
|
app.stop_playback();
|
||||||
|
|
||||||
// Закрываем TDLib клиент
|
// Закрываем TDLib клиент
|
||||||
let _ = tdlib_rs::functions::close(app.td_client.client_id()).await;
|
let _ = tdlib_rs::functions::close(app.td_client.client_id()).await;
|
||||||
|
|
||||||
// Ждём завершения polling задачи (с таймаутом)
|
// Ждём завершения polling задачи (с таймаутом)
|
||||||
with_timeout_ignore(Duration::from_secs(SHUTDOWN_TIMEOUT_SECS), polling_handle).await;
|
with_timeout_ignore(
|
||||||
|
Duration::from_secs(SHUTDOWN_TIMEOUT_SECS),
|
||||||
|
polling_handle,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
match app.screen {
|
// Ctrl+A opens account switcher from any screen
|
||||||
AppScreen::Loading => {
|
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,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Любой ввод требует перерисовки
|
// Любой ввод требует перерисовки
|
||||||
@@ -209,6 +384,64 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
|
|||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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)
|
/// Разделитель даты (день в формате timestamp)
|
||||||
DateSeparator(i32),
|
DateSeparator(i32),
|
||||||
/// Заголовок отправителя (is_outgoing, sender_name)
|
/// Заголовок отправителя (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());
|
/// 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 result = Vec::new();
|
||||||
let mut last_day: Option<i64> = None;
|
let mut last_day: Option<i64> = None;
|
||||||
let mut last_sender: Option<(bool, String)> = None; // (is_outgoing, sender_name)
|
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 {
|
for msg in messages {
|
||||||
// Проверяем, нужно ли добавить разделитель даты
|
// Проверяем, нужно ли добавить разделитель даты
|
||||||
let msg_day = get_day(msg.date());
|
let msg_day = get_day(msg.date());
|
||||||
|
|
||||||
if last_day != Some(msg_day) {
|
if last_day != Some(msg_day) {
|
||||||
|
// Flush аккумулятор перед разделителем даты
|
||||||
|
flush_album(&mut album_acc, &mut result);
|
||||||
// Добавляем разделитель даты
|
// Добавляем разделитель даты
|
||||||
result.push(MessageGroup::DateSeparator(msg.date()));
|
result.push(MessageGroup::DateSeparator(msg.date()));
|
||||||
last_day = Some(msg_day);
|
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);
|
let show_sender_header = last_sender.as_ref() != Some(¤t_sender);
|
||||||
|
|
||||||
if show_sender_header {
|
if show_sender_header {
|
||||||
result.push(MessageGroup::SenderHeader {
|
// Flush аккумулятор перед сменой отправителя
|
||||||
is_outgoing: msg.is_outgoing(),
|
flush_album(&mut album_acc, &mut result);
|
||||||
sender_name,
|
result.push(MessageGroup::SenderHeader { is_outgoing: msg.is_outgoing(), sender_name });
|
||||||
});
|
|
||||||
last_sender = Some(current_sender);
|
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
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,4 +296,152 @@ mod tests {
|
|||||||
assert!(matches!(grouped[1], MessageGroup::SenderHeader { .. }));
|
assert!(matches!(grouped[1], MessageGroup::SenderHeader { .. }));
|
||||||
assert!(matches!(grouped[2], MessageGroup::Message(_)));
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use std::collections::HashSet;
|
|||||||
use notify_rust::{Notification, Timeout};
|
use notify_rust::{Notification, Timeout};
|
||||||
|
|
||||||
/// Manages desktop notifications
|
/// Manages desktop notifications
|
||||||
|
#[allow(dead_code)]
|
||||||
pub struct NotificationManager {
|
pub struct NotificationManager {
|
||||||
/// Whether notifications are enabled
|
/// Whether notifications are enabled
|
||||||
enabled: bool,
|
enabled: bool,
|
||||||
@@ -25,6 +26,7 @@ pub struct NotificationManager {
|
|||||||
urgency: String,
|
urgency: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
impl NotificationManager {
|
impl NotificationManager {
|
||||||
/// Creates a new notification manager with default settings
|
/// Creates a new notification manager with default settings
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
@@ -39,11 +41,7 @@ impl NotificationManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Creates a notification manager with custom settings
|
/// Creates a notification manager with custom settings
|
||||||
pub fn with_config(
|
pub fn with_config(enabled: bool, only_mentions: bool, show_preview: bool) -> Self {
|
||||||
enabled: bool,
|
|
||||||
only_mentions: bool,
|
|
||||||
show_preview: bool,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
Self {
|
||||||
enabled,
|
enabled,
|
||||||
muted_chats: HashSet::new(),
|
muted_chats: HashSet::new(),
|
||||||
@@ -269,7 +267,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_notification_manager_creation() {
|
fn test_notification_manager_creation() {
|
||||||
let manager = NotificationManager::new();
|
let manager = NotificationManager::new();
|
||||||
assert!(manager.enabled);
|
assert!(!manager.enabled); // disabled by default
|
||||||
assert!(!manager.only_mentions);
|
assert!(!manager.only_mentions);
|
||||||
assert!(manager.show_preview);
|
assert!(manager.show_preview);
|
||||||
}
|
}
|
||||||
@@ -311,22 +309,13 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_beautify_media_labels() {
|
fn test_beautify_media_labels() {
|
||||||
// Test photo
|
// Test photo
|
||||||
assert_eq!(
|
assert_eq!(NotificationManager::beautify_media_labels("[Фото]"), "📷 Фото");
|
||||||
NotificationManager::beautify_media_labels("[Фото]"),
|
|
||||||
"📷 Фото"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Test video
|
// Test video
|
||||||
assert_eq!(
|
assert_eq!(NotificationManager::beautify_media_labels("[Видео]"), "🎥 Видео");
|
||||||
NotificationManager::beautify_media_labels("[Видео]"),
|
|
||||||
"🎥 Видео"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Test sticker with emoji
|
// Test sticker with emoji
|
||||||
assert_eq!(
|
assert_eq!(NotificationManager::beautify_media_labels("[Стикер: 😊]"), "🎨 Стикер: 😊]");
|
||||||
NotificationManager::beautify_media_labels("[Стикер: 😊]"),
|
|
||||||
"🎨 Стикер: 😊]"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Test audio with title
|
// Test audio with title
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -341,10 +330,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Test regular text (no changes)
|
// Test regular text (no changes)
|
||||||
assert_eq!(
|
assert_eq!(NotificationManager::beautify_media_labels("Hello, world!"), "Hello, world!");
|
||||||
NotificationManager::beautify_media_labels("Hello, world!"),
|
|
||||||
"Hello, world!"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Test mixed content
|
// Test mixed content
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ use tdlib_rs::functions;
|
|||||||
///
|
///
|
||||||
/// Отслеживает текущий этап аутентификации пользователя,
|
/// Отслеживает текущий этап аутентификации пользователя,
|
||||||
/// от инициализации TDLib до полной авторизации.
|
/// от инициализации TDLib до полной авторизации.
|
||||||
|
#[allow(dead_code)]
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub enum AuthState {
|
pub enum AuthState {
|
||||||
/// Ожидание параметров TDLib (начальное состояние).
|
/// Ожидание параметров TDLib (начальное состояние).
|
||||||
@@ -72,6 +73,7 @@ pub struct AuthManager {
|
|||||||
client_id: i32,
|
client_id: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
impl AuthManager {
|
impl AuthManager {
|
||||||
/// Создает новый менеджер авторизации.
|
/// Создает новый менеджер авторизации.
|
||||||
///
|
///
|
||||||
@@ -83,10 +85,7 @@ impl AuthManager {
|
|||||||
///
|
///
|
||||||
/// Новый экземпляр `AuthManager` в состоянии `WaitTdlibParameters`.
|
/// Новый экземпляр `AuthManager` в состоянии `WaitTdlibParameters`.
|
||||||
pub fn new(client_id: i32) -> Self {
|
pub fn new(client_id: i32) -> Self {
|
||||||
Self {
|
Self { state: AuthState::WaitTdlibParameters, client_id }
|
||||||
state: AuthState::WaitTdlibParameters,
|
|
||||||
client_id,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Проверяет, завершена ли авторизация.
|
/// Проверяет, завершена ли авторизация.
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
//! This module contains utility functions for managing chats,
|
//! This module contains utility functions for managing chats,
|
||||||
//! including finding, updating, and adding/removing chats.
|
//! including finding, updating, and adding/removing chats.
|
||||||
|
|
||||||
use crate::constants::{MAX_CHAT_USER_IDS, MAX_CHATS};
|
use crate::constants::{MAX_CHATS, MAX_CHAT_USER_IDS};
|
||||||
use crate::types::{ChatId, MessageId, UserId};
|
use crate::types::{ChatId, MessageId, UserId};
|
||||||
use tdlib_rs::enums::{Chat as TdChat, ChatList, ChatType};
|
use tdlib_rs::enums::{Chat as TdChat, ChatList, ChatType};
|
||||||
|
|
||||||
@@ -33,7 +33,9 @@ pub fn add_or_update_chat(client: &mut TdClient, td_chat_enum: &TdChat) {
|
|||||||
// Пропускаем удалённые аккаунты
|
// Пропускаем удалённые аккаунты
|
||||||
if td_chat.title == "Deleted Account" || td_chat.title.is_empty() {
|
if td_chat.title == "Deleted Account" || td_chat.title.is_empty() {
|
||||||
// Удаляем из списка если уже был добавлен
|
// Удаляем из списка если уже был добавлен
|
||||||
client.chats_mut().retain(|c| c.id != ChatId::new(td_chat.id));
|
client
|
||||||
|
.chats_mut()
|
||||||
|
.retain(|c| c.id != ChatId::new(td_chat.id));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +72,9 @@ pub fn add_or_update_chat(client: &mut TdClient, td_chat_enum: &TdChat) {
|
|||||||
let user_id = UserId::new(private.user_id);
|
let user_id = UserId::new(private.user_id);
|
||||||
client.user_cache.chat_user_ids.insert(chat_id, user_id);
|
client.user_cache.chat_user_ids.insert(chat_id, user_id);
|
||||||
// Проверяем, есть ли уже username в кэше (peek не обновляет LRU)
|
// Проверяем, есть ли уже username в кэше (peek не обновляет LRU)
|
||||||
client.user_cache.user_usernames
|
client
|
||||||
|
.user_cache
|
||||||
|
.user_usernames
|
||||||
.peek(&user_id)
|
.peek(&user_id)
|
||||||
.map(|u| format!("@{}", u))
|
.map(|u| format!("@{}", u))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -197,10 +197,7 @@ impl ChatManager {
|
|||||||
ChatType::Secret(_) => "Секретный чат",
|
ChatType::Secret(_) => "Секретный чат",
|
||||||
};
|
};
|
||||||
|
|
||||||
let is_group = matches!(
|
let is_group = matches!(&chat.r#type, ChatType::Supergroup(_) | ChatType::BasicGroup(_));
|
||||||
&chat.r#type,
|
|
||||||
ChatType::Supergroup(_) | ChatType::BasicGroup(_)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Для личных чатов получаем информацию о пользователе
|
// Для личных чатов получаем информацию о пользователе
|
||||||
let (bio, phone_number, username, online_status) = if let ChatType::Private(private_chat) =
|
let (bio, phone_number, username, online_status) = if let ChatType::Private(private_chat) =
|
||||||
@@ -208,13 +205,15 @@ impl ChatManager {
|
|||||||
{
|
{
|
||||||
match functions::get_user(private_chat.user_id, self.client_id).await {
|
match functions::get_user(private_chat.user_id, self.client_id).await {
|
||||||
Ok(tdlib_rs::enums::User::User(user)) => {
|
Ok(tdlib_rs::enums::User::User(user)) => {
|
||||||
let bio_opt = if let Ok(tdlib_rs::enums::UserFullInfo::UserFullInfo(full_info)) =
|
let bio_opt =
|
||||||
functions::get_user_full_info(private_chat.user_id, self.client_id).await
|
if let Ok(tdlib_rs::enums::UserFullInfo::UserFullInfo(full_info)) =
|
||||||
{
|
functions::get_user_full_info(private_chat.user_id, self.client_id)
|
||||||
full_info.bio.map(|b| b.text)
|
.await
|
||||||
} else {
|
{
|
||||||
None
|
full_info.bio.map(|b| b.text)
|
||||||
};
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
let online_status_str = match user.status {
|
let online_status_str = match user.status {
|
||||||
tdlib_rs::enums::UserStatus::Online(_) => Some("В сети".to_string()),
|
tdlib_rs::enums::UserStatus::Online(_) => Some("В сети".to_string()),
|
||||||
@@ -234,10 +233,7 @@ impl ChatManager {
|
|||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let username_opt = user
|
let username_opt = user.usernames.as_ref().map(|u| u.editable_username.clone());
|
||||||
.usernames
|
|
||||||
.as_ref()
|
|
||||||
.map(|u| u.editable_username.clone());
|
|
||||||
|
|
||||||
(bio_opt, Some(user.phone_number.clone()), username_opt, online_status_str)
|
(bio_opt, Some(user.phone_number.clone()), username_opt, online_status_str)
|
||||||
}
|
}
|
||||||
@@ -257,7 +253,10 @@ impl ChatManager {
|
|||||||
} else {
|
} else {
|
||||||
None
|
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)
|
(Some(full_info.member_count), desc, link)
|
||||||
}
|
}
|
||||||
_ => (None, None, None),
|
_ => (None, None, None),
|
||||||
@@ -324,7 +323,8 @@ impl ChatManager {
|
|||||||
/// ).await;
|
/// ).await;
|
||||||
/// ```
|
/// ```
|
||||||
pub async fn send_chat_action(&self, chat_id: ChatId, action: ChatAction) {
|
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-статус.
|
/// Очищает устаревший typing-статус.
|
||||||
@@ -371,6 +371,7 @@ impl ChatManager {
|
|||||||
/// println!("Status: {}", typing_text);
|
/// println!("Status: {}", typing_text);
|
||||||
/// }
|
/// }
|
||||||
/// ```
|
/// ```
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn get_typing_text(&self) -> Option<String> {
|
pub fn get_typing_text(&self) -> Option<String> {
|
||||||
self.typing_status
|
self.typing_status
|
||||||
.as_ref()
|
.as_ref()
|
||||||
|
|||||||
@@ -1,19 +1,17 @@
|
|||||||
use crate::types::{ChatId, MessageId, UserId};
|
use crate::types::{ChatId, MessageId, UserId};
|
||||||
use std::env;
|
use std::env;
|
||||||
use tdlib_rs::enums::{
|
use std::path::PathBuf;
|
||||||
ChatList, ConnectionState, Update, UserStatus,
|
use tdlib_rs::enums::{Chat as TdChat, ChatList, ConnectionState, Update, UserStatus};
|
||||||
Chat as TdChat
|
|
||||||
};
|
|
||||||
use tdlib_rs::types::Message as TdMessage;
|
|
||||||
use tdlib_rs::functions;
|
use tdlib_rs::functions;
|
||||||
|
use tdlib_rs::types::Message as TdMessage;
|
||||||
|
|
||||||
|
|
||||||
use super::auth::{AuthManager, AuthState};
|
use super::auth::{AuthManager, AuthState};
|
||||||
use super::chats::ChatManager;
|
use super::chats::ChatManager;
|
||||||
use super::messages::MessageManager;
|
use super::messages::MessageManager;
|
||||||
use super::reactions::ReactionManager;
|
use super::reactions::ReactionManager;
|
||||||
use super::types::{ChatInfo, FolderInfo, MessageInfo, NetworkState, ProfileInfo, UserOnlineStatus};
|
use super::types::{
|
||||||
|
ChatInfo, FolderInfo, MessageInfo, NetworkState, ProfileInfo, UserOnlineStatus,
|
||||||
|
};
|
||||||
use super::users::UserCache;
|
use super::users::UserCache;
|
||||||
use crate::notifications::NotificationManager;
|
use crate::notifications::NotificationManager;
|
||||||
|
|
||||||
@@ -32,7 +30,7 @@ use crate::notifications::NotificationManager;
|
|||||||
/// ```ignore
|
/// ```ignore
|
||||||
/// use tele_tui::tdlib::TdClient;
|
/// use tele_tui::tdlib::TdClient;
|
||||||
///
|
///
|
||||||
/// let mut client = TdClient::new();
|
/// let mut client = TdClient::new(std::path::PathBuf::from("tdlib_data"));
|
||||||
///
|
///
|
||||||
/// // Start authorization
|
/// // Start authorization
|
||||||
/// client.send_phone_number("+1234567890".to_string()).await?;
|
/// client.send_phone_number("+1234567890".to_string()).await?;
|
||||||
@@ -45,6 +43,7 @@ use crate::notifications::NotificationManager;
|
|||||||
pub struct TdClient {
|
pub struct TdClient {
|
||||||
pub api_id: i32,
|
pub api_id: i32,
|
||||||
pub api_hash: String,
|
pub api_hash: String,
|
||||||
|
pub db_path: PathBuf,
|
||||||
client_id: i32,
|
client_id: i32,
|
||||||
|
|
||||||
// Менеджеры (делегируем им функциональность)
|
// Менеджеры (делегируем им функциональность)
|
||||||
@@ -59,6 +58,7 @@ pub struct TdClient {
|
|||||||
pub network_state: NetworkState,
|
pub network_state: NetworkState,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
impl TdClient {
|
impl TdClient {
|
||||||
/// Creates a new TDLib client instance.
|
/// Creates a new TDLib client instance.
|
||||||
///
|
///
|
||||||
@@ -71,24 +71,24 @@ impl TdClient {
|
|||||||
/// # Returns
|
/// # Returns
|
||||||
///
|
///
|
||||||
/// A new `TdClient` instance ready for authentication.
|
/// A new `TdClient` instance ready for authentication.
|
||||||
pub fn new() -> Self {
|
pub fn new(db_path: PathBuf) -> Self {
|
||||||
// Пробуем загрузить credentials из Config (файл или env)
|
// Пробуем загрузить credentials из Config (файл или env)
|
||||||
let (api_id, api_hash) = crate::config::Config::load_credentials()
|
let (api_id, api_hash) = crate::config::Config::load_credentials().unwrap_or_else(|_| {
|
||||||
.unwrap_or_else(|_| {
|
// Fallback на прямое чтение из env (старое поведение)
|
||||||
// Fallback на прямое чтение из env (старое поведение)
|
let api_id = env::var("API_ID")
|
||||||
let api_id = env::var("API_ID")
|
.unwrap_or_else(|_| "0".to_string())
|
||||||
.unwrap_or_else(|_| "0".to_string())
|
.parse()
|
||||||
.parse()
|
.unwrap_or(0);
|
||||||
.unwrap_or(0);
|
let api_hash = env::var("API_HASH").unwrap_or_default();
|
||||||
let api_hash = env::var("API_HASH").unwrap_or_default();
|
(api_id, api_hash)
|
||||||
(api_id, api_hash)
|
});
|
||||||
});
|
|
||||||
|
|
||||||
let client_id = tdlib_rs::create_client();
|
let client_id = tdlib_rs::create_client();
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
api_id,
|
api_id,
|
||||||
api_hash,
|
api_hash,
|
||||||
|
db_path,
|
||||||
client_id,
|
client_id,
|
||||||
auth: AuthManager::new(client_id),
|
auth: AuthManager::new(client_id),
|
||||||
chat_manager: ChatManager::new(client_id),
|
chat_manager: ChatManager::new(client_id),
|
||||||
@@ -103,9 +103,11 @@ impl TdClient {
|
|||||||
/// Configures notification manager from app config
|
/// Configures notification manager from app config
|
||||||
pub fn configure_notifications(&mut self, config: &crate::config::NotificationsConfig) {
|
pub fn configure_notifications(&mut self, config: &crate::config::NotificationsConfig) {
|
||||||
self.notification_manager.set_enabled(config.enabled);
|
self.notification_manager.set_enabled(config.enabled);
|
||||||
self.notification_manager.set_only_mentions(config.only_mentions);
|
self.notification_manager
|
||||||
|
.set_only_mentions(config.only_mentions);
|
||||||
self.notification_manager.set_timeout(config.timeout_ms);
|
self.notification_manager.set_timeout(config.timeout_ms);
|
||||||
self.notification_manager.set_urgency(config.urgency.clone());
|
self.notification_manager
|
||||||
|
.set_urgency(config.urgency.clone());
|
||||||
// Note: show_preview is used when formatting notification body
|
// Note: show_preview is used when formatting notification body
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,7 +115,8 @@ impl TdClient {
|
|||||||
///
|
///
|
||||||
/// Should be called after chats are loaded to ensure muted chats don't trigger notifications.
|
/// Should be called after chats are loaded to ensure muted chats don't trigger notifications.
|
||||||
pub fn sync_notification_muted_chats(&mut self) {
|
pub fn sync_notification_muted_chats(&mut self) {
|
||||||
self.notification_manager.sync_muted_chats(&self.chat_manager.chats);
|
self.notification_manager
|
||||||
|
.sync_muted_chats(&self.chat_manager.chats);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Делегирование к auth
|
// Делегирование к auth
|
||||||
@@ -254,12 +257,17 @@ impl TdClient {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_pinned_messages(&mut self, chat_id: ChatId) -> Result<Vec<MessageInfo>, String> {
|
pub async fn get_pinned_messages(
|
||||||
|
&mut self,
|
||||||
|
chat_id: ChatId,
|
||||||
|
) -> Result<Vec<MessageInfo>, String> {
|
||||||
self.message_manager.get_pinned_messages(chat_id).await
|
self.message_manager.get_pinned_messages(chat_id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn load_current_pinned_message(&mut self, chat_id: ChatId) {
|
pub async fn load_current_pinned_message(&mut self, chat_id: ChatId) {
|
||||||
self.message_manager.load_current_pinned_message(chat_id).await
|
self.message_manager
|
||||||
|
.load_current_pinned_message(chat_id)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn search_messages(
|
pub async fn search_messages(
|
||||||
@@ -362,6 +370,22 @@ impl TdClient {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Делегирование файловых операций
|
||||||
|
|
||||||
|
/// Скачивает файл по file_id и возвращает локальный путь.
|
||||||
|
pub async fn download_file(&self, file_id: i32) -> Result<String, String> {
|
||||||
|
match functions::download_file(file_id, 1, 0, 0, true, self.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)
|
||||||
|
} else {
|
||||||
|
Err("Файл не скачан".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => Err(format!("Ошибка скачивания файла: {:?}", e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Вспомогательные методы
|
// Вспомогательные методы
|
||||||
pub fn client_id(&self) -> i32 {
|
pub fn client_id(&self) -> i32 {
|
||||||
self.client_id
|
self.client_id
|
||||||
@@ -423,7 +447,10 @@ impl TdClient {
|
|||||||
self.chat_manager.typing_status.as_ref()
|
self.chat_manager.typing_status.as_ref()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_typing_status(&mut self, status: Option<(crate::types::UserId, String, std::time::Instant)>) {
|
pub fn set_typing_status(
|
||||||
|
&mut self,
|
||||||
|
status: Option<(crate::types::UserId, String, std::time::Instant)>,
|
||||||
|
) {
|
||||||
self.chat_manager.typing_status = status;
|
self.chat_manager.typing_status = status;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -431,7 +458,9 @@ impl TdClient {
|
|||||||
&self.message_manager.pending_view_messages
|
&self.message_manager.pending_view_messages
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn pending_view_messages_mut(&mut self) -> &mut Vec<(crate::types::ChatId, Vec<crate::types::MessageId>)> {
|
pub fn pending_view_messages_mut(
|
||||||
|
&mut self,
|
||||||
|
) -> &mut Vec<(crate::types::ChatId, Vec<crate::types::MessageId>)> {
|
||||||
&mut self.message_manager.pending_view_messages
|
&mut self.message_manager.pending_view_messages
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -462,19 +491,6 @@ impl TdClient {
|
|||||||
|
|
||||||
// ==================== Helper методы для упрощения обработки updates ====================
|
// ==================== Helper методы для упрощения обработки updates ====================
|
||||||
|
|
||||||
/// Находит мутабельную ссылку на чат по ID.
|
|
||||||
///
|
|
||||||
/// Упрощает повторяющийся паттерн `self.chats_mut().iter_mut().find(...)`.
|
|
||||||
///
|
|
||||||
/// # Arguments
|
|
||||||
///
|
|
||||||
/// * `chat_id` - ID чата для поиска
|
|
||||||
///
|
|
||||||
/// # Returns
|
|
||||||
///
|
|
||||||
/// * `Some(&mut ChatInfo)` - если чат найден
|
|
||||||
/// * `None` - если чат не найден
|
|
||||||
|
|
||||||
/// Обрабатываем одно обновление от TDLib
|
/// Обрабатываем одно обновление от TDLib
|
||||||
pub fn handle_update(&mut self, update: Update) {
|
pub fn handle_update(&mut self, update: Update) {
|
||||||
match update {
|
match update {
|
||||||
@@ -500,7 +516,11 @@ impl TdClient {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Обновляем позиции если они пришли
|
// Обновляем позиции если они пришли
|
||||||
for pos in update.positions.iter().filter(|p| matches!(p.list, ChatList::Main)) {
|
for pos in update
|
||||||
|
.positions
|
||||||
|
.iter()
|
||||||
|
.filter(|p| matches!(p.list, ChatList::Main))
|
||||||
|
{
|
||||||
crate::tdlib::chat_helpers::update_chat(self, chat_id, |chat| {
|
crate::tdlib::chat_helpers::update_chat(self, chat_id, |chat| {
|
||||||
chat.order = pos.order;
|
chat.order = pos.order;
|
||||||
chat.is_pinned = pos.is_pinned;
|
chat.is_pinned = pos.is_pinned;
|
||||||
@@ -511,27 +531,43 @@ impl TdClient {
|
|||||||
self.chats_mut().sort_by(|a, b| b.order.cmp(&a.order));
|
self.chats_mut().sort_by(|a, b| b.order.cmp(&a.order));
|
||||||
}
|
}
|
||||||
Update::ChatReadInbox(update) => {
|
Update::ChatReadInbox(update) => {
|
||||||
crate::tdlib::chat_helpers::update_chat(self, ChatId::new(update.chat_id), |chat| {
|
crate::tdlib::chat_helpers::update_chat(
|
||||||
chat.unread_count = update.unread_count;
|
self,
|
||||||
});
|
ChatId::new(update.chat_id),
|
||||||
|
|chat| {
|
||||||
|
chat.unread_count = update.unread_count;
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Update::ChatUnreadMentionCount(update) => {
|
Update::ChatUnreadMentionCount(update) => {
|
||||||
crate::tdlib::chat_helpers::update_chat(self, ChatId::new(update.chat_id), |chat| {
|
crate::tdlib::chat_helpers::update_chat(
|
||||||
chat.unread_mention_count = update.unread_mention_count;
|
self,
|
||||||
});
|
ChatId::new(update.chat_id),
|
||||||
|
|chat| {
|
||||||
|
chat.unread_mention_count = update.unread_mention_count;
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Update::ChatNotificationSettings(update) => {
|
Update::ChatNotificationSettings(update) => {
|
||||||
crate::tdlib::chat_helpers::update_chat(self, ChatId::new(update.chat_id), |chat| {
|
crate::tdlib::chat_helpers::update_chat(
|
||||||
// mute_for > 0 означает что чат замьючен
|
self,
|
||||||
chat.is_muted = update.notification_settings.mute_for > 0;
|
ChatId::new(update.chat_id),
|
||||||
});
|
|chat| {
|
||||||
|
// mute_for > 0 означает что чат замьючен
|
||||||
|
chat.is_muted = update.notification_settings.mute_for > 0;
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Update::ChatReadOutbox(update) => {
|
Update::ChatReadOutbox(update) => {
|
||||||
// Обновляем last_read_outbox_message_id когда собеседник прочитал сообщения
|
// Обновляем last_read_outbox_message_id когда собеседник прочитал сообщения
|
||||||
let last_read_msg_id = MessageId::new(update.last_read_outbox_message_id);
|
let last_read_msg_id = MessageId::new(update.last_read_outbox_message_id);
|
||||||
crate::tdlib::chat_helpers::update_chat(self, ChatId::new(update.chat_id), |chat| {
|
crate::tdlib::chat_helpers::update_chat(
|
||||||
chat.last_read_outbox_message_id = last_read_msg_id;
|
self,
|
||||||
});
|
ChatId::new(update.chat_id),
|
||||||
|
|chat| {
|
||||||
|
chat.last_read_outbox_message_id = last_read_msg_id;
|
||||||
|
},
|
||||||
|
);
|
||||||
// Если это текущий открытый чат — обновляем is_read у сообщений
|
// Если это текущий открытый чат — обновляем is_read у сообщений
|
||||||
if Some(ChatId::new(update.chat_id)) == self.current_chat_id() {
|
if Some(ChatId::new(update.chat_id)) == self.current_chat_id() {
|
||||||
for msg in self.current_chat_messages_mut().iter_mut() {
|
for msg in self.current_chat_messages_mut().iter_mut() {
|
||||||
@@ -569,7 +605,9 @@ impl TdClient {
|
|||||||
UserStatus::LastMonth(_) => UserOnlineStatus::LastMonth,
|
UserStatus::LastMonth(_) => UserOnlineStatus::LastMonth,
|
||||||
UserStatus::Empty => UserOnlineStatus::LongTimeAgo,
|
UserStatus::Empty => UserOnlineStatus::LongTimeAgo,
|
||||||
};
|
};
|
||||||
self.user_cache.user_statuses.insert(UserId::new(update.user_id), status);
|
self.user_cache
|
||||||
|
.user_statuses
|
||||||
|
.insert(UserId::new(update.user_id), status);
|
||||||
}
|
}
|
||||||
Update::ConnectionState(update) => {
|
Update::ConnectionState(update) => {
|
||||||
// Обновляем состояние сетевого соединения
|
// Обновляем состояние сетевого соединения
|
||||||
@@ -597,17 +635,65 @@ impl TdClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Helper functions
|
// Helper functions
|
||||||
pub fn extract_message_text_static(message: &TdMessage) -> (String, Vec<tdlib_rs::types::TextEntity>) {
|
pub fn extract_message_text_static(
|
||||||
|
message: &TdMessage,
|
||||||
|
) -> (String, Vec<tdlib_rs::types::TextEntity>) {
|
||||||
use tdlib_rs::enums::MessageContent;
|
use tdlib_rs::enums::MessageContent;
|
||||||
match &message.content {
|
match &message.content {
|
||||||
MessageContent::MessageText(text) => (text.text.text.clone(), text.text.entities.clone()),
|
MessageContent::MessageText(text) => {
|
||||||
|
(text.text.text.clone(), text.text.entities.clone())
|
||||||
|
}
|
||||||
_ => (String::new(), Vec::new()),
|
_ => (String::new(), Vec::new()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Recreates the TDLib client with a new database path.
|
||||||
|
///
|
||||||
|
/// Closes the old client, creates a new one, and spawns TDLib parameter initialization.
|
||||||
|
pub async fn recreate_client(&mut self, db_path: PathBuf) -> Result<(), String> {
|
||||||
|
// 1. Close old client
|
||||||
|
let _ = functions::close(self.client_id).await;
|
||||||
|
|
||||||
|
// 2. Create new client
|
||||||
|
let new_client = TdClient::new(db_path);
|
||||||
|
|
||||||
|
// 3. Spawn set_tdlib_parameters for new client
|
||||||
|
let new_client_id = new_client.client_id;
|
||||||
|
let api_id = new_client.api_id;
|
||||||
|
let api_hash = new_client.api_hash.clone();
|
||||||
|
let db_path_str = new_client.db_path.to_string_lossy().to_string();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) = functions::set_tdlib_parameters(
|
||||||
|
false,
|
||||||
|
db_path_str,
|
||||||
|
"".to_string(),
|
||||||
|
"".to_string(),
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
api_id,
|
||||||
|
api_hash,
|
||||||
|
"en".to_string(),
|
||||||
|
"Desktop".to_string(),
|
||||||
|
"".to_string(),
|
||||||
|
env!("CARGO_PKG_VERSION").to_string(),
|
||||||
|
new_client_id,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::error!("set_tdlib_parameters failed on recreate: {:?}", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Replace self
|
||||||
|
*self = new_client;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn extract_content_text(content: &tdlib_rs::enums::MessageContent) -> String {
|
pub fn extract_content_text(content: &tdlib_rs::enums::MessageContent) -> String {
|
||||||
use tdlib_rs::enums::MessageContent;
|
use tdlib_rs::enums::MessageContent;
|
||||||
match content {
|
match content {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -4,9 +4,13 @@
|
|||||||
|
|
||||||
use super::client::TdClient;
|
use super::client::TdClient;
|
||||||
use super::r#trait::TdClientTrait;
|
use super::r#trait::TdClientTrait;
|
||||||
use super::{AuthState, ChatInfo, FolderInfo, MessageInfo, ProfileInfo, ReplyInfo, UserCache, UserOnlineStatus};
|
use super::{
|
||||||
|
AuthState, ChatInfo, FolderInfo, MessageInfo, ProfileInfo, ReplyInfo, UserCache,
|
||||||
|
UserOnlineStatus,
|
||||||
|
};
|
||||||
use crate::types::{ChatId, MessageId, UserId};
|
use crate::types::{ChatId, MessageId, UserId};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
use std::path::PathBuf;
|
||||||
use tdlib_rs::enums::{ChatAction, Update};
|
use tdlib_rs::enums::{ChatAction, Update};
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -51,11 +55,19 @@ impl TdClientTrait for TdClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ============ Message methods ============
|
// ============ Message methods ============
|
||||||
async fn get_chat_history(&mut self, chat_id: ChatId, limit: i32) -> Result<Vec<MessageInfo>, String> {
|
async fn get_chat_history(
|
||||||
|
&mut self,
|
||||||
|
chat_id: ChatId,
|
||||||
|
limit: i32,
|
||||||
|
) -> Result<Vec<MessageInfo>, String> {
|
||||||
self.get_chat_history(chat_id, limit).await
|
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> {
|
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
|
self.load_older_messages(chat_id, from_message_id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,7 +79,11 @@ impl TdClientTrait for TdClient {
|
|||||||
self.load_current_pinned_message(chat_id).await
|
self.load_current_pinned_message(chat_id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn search_messages(&self, chat_id: ChatId, query: &str) -> Result<Vec<MessageInfo>, String> {
|
async fn search_messages(
|
||||||
|
&self,
|
||||||
|
chat_id: ChatId,
|
||||||
|
query: &str,
|
||||||
|
) -> Result<Vec<MessageInfo>, String> {
|
||||||
self.search_messages(chat_id, query).await
|
self.search_messages(chat_id, query).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,7 +163,8 @@ impl TdClientTrait for TdClient {
|
|||||||
chat_id: ChatId,
|
chat_id: ChatId,
|
||||||
message_id: MessageId,
|
message_id: MessageId,
|
||||||
) -> Result<Vec<String>, String> {
|
) -> Result<Vec<String>, String> {
|
||||||
self.get_message_available_reactions(chat_id, message_id).await
|
self.get_message_available_reactions(chat_id, message_id)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn toggle_reaction(
|
async fn toggle_reaction(
|
||||||
@@ -159,6 +176,16 @@ impl TdClientTrait for TdClient {
|
|||||||
self.toggle_reaction(chat_id, message_id, reaction).await
|
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 {
|
fn client_id(&self) -> i32 {
|
||||||
self.client_id()
|
self.client_id()
|
||||||
}
|
}
|
||||||
@@ -265,7 +292,13 @@ impl TdClientTrait for TdClient {
|
|||||||
|
|
||||||
// ============ Notification methods ============
|
// ============ Notification methods ============
|
||||||
fn sync_notification_muted_chats(&mut self) {
|
fn sync_notification_muted_chats(&mut self) {
|
||||||
self.notification_manager.sync_muted_chats(&self.chat_manager.chats);
|
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 ============
|
// ============ Update handling ============
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ use crate::types::MessageId;
|
|||||||
use tdlib_rs::enums::{MessageContent, MessageSender};
|
use tdlib_rs::enums::{MessageContent, MessageSender};
|
||||||
use tdlib_rs::types::Message as TdMessage;
|
use tdlib_rs::types::Message as TdMessage;
|
||||||
|
|
||||||
use super::types::{ForwardInfo, ReactionInfo, ReplyInfo};
|
use super::types::{
|
||||||
|
ForwardInfo, MediaInfo, PhotoDownloadState, PhotoInfo, ReactionInfo, ReplyInfo,
|
||||||
|
VoiceDownloadState, VoiceInfo,
|
||||||
|
};
|
||||||
|
|
||||||
/// Извлекает текст контента из TDLib Message
|
/// Извлекает текст контента из TDLib Message
|
||||||
///
|
///
|
||||||
@@ -19,9 +22,9 @@ pub fn extract_content_text(msg: &TdMessage) -> String {
|
|||||||
MessageContent::MessagePhoto(p) => {
|
MessageContent::MessagePhoto(p) => {
|
||||||
let caption_text = p.caption.text.clone();
|
let caption_text = p.caption.text.clone();
|
||||||
if caption_text.is_empty() {
|
if caption_text.is_empty() {
|
||||||
"[Фото]".to_string()
|
"📷 [Фото]".to_string()
|
||||||
} else {
|
} else {
|
||||||
caption_text
|
format!("📷 {}", caption_text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
MessageContent::MessageVideo(v) => {
|
MessageContent::MessageVideo(v) => {
|
||||||
@@ -52,11 +55,12 @@ pub fn extract_content_text(msg: &TdMessage) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
MessageContent::MessageVoiceNote(v) => {
|
MessageContent::MessageVoiceNote(v) => {
|
||||||
|
let duration = v.voice_note.duration;
|
||||||
let caption_text = v.caption.text.clone();
|
let caption_text = v.caption.text.clone();
|
||||||
if caption_text.is_empty() {
|
if caption_text.is_empty() {
|
||||||
"[Голосовое]".to_string()
|
format!("🎤 [Голосовое {:.0}s]", duration)
|
||||||
} else {
|
} else {
|
||||||
caption_text
|
format!("🎤 {} ({:.0}s)", caption_text, duration)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
MessageContent::MessageAudio(a) => {
|
MessageContent::MessageAudio(a) => {
|
||||||
@@ -94,9 +98,9 @@ pub async fn extract_sender_name(msg: &TdMessage, client_id: i32) -> String {
|
|||||||
match &msg.sender_id {
|
match &msg.sender_id {
|
||||||
MessageSender::User(user) => {
|
MessageSender::User(user) => {
|
||||||
match tdlib_rs::functions::get_user(user.user_id, client_id).await {
|
match tdlib_rs::functions::get_user(user.user_id, client_id).await {
|
||||||
Ok(tdlib_rs::enums::User::User(u)) => {
|
Ok(tdlib_rs::enums::User::User(u)) => format!("{} {}", u.first_name, u.last_name)
|
||||||
format!("{} {}", u.first_name, u.last_name).trim().to_string()
|
.trim()
|
||||||
}
|
.to_string(),
|
||||||
_ => format!("User {}", user.user_id),
|
_ => format!("User {}", user.user_id),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -132,6 +136,57 @@ pub fn extract_reply_info(msg: &TdMessage) -> Option<ReplyInfo> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Извлекает информацию о медиа-контенте из 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
|
/// Извлекает реакции из TDLib Message
|
||||||
pub fn extract_reactions(msg: &TdMessage) -> Vec<ReactionInfo> {
|
pub fn extract_reactions(msg: &TdMessage) -> Vec<ReactionInfo> {
|
||||||
msg.interaction_info
|
msg.interaction_info
|
||||||
|
|||||||
@@ -11,11 +11,7 @@ use super::client::TdClient;
|
|||||||
use super::types::{ForwardInfo, MessageInfo, ReactionInfo, ReplyInfo};
|
use super::types::{ForwardInfo, MessageInfo, ReactionInfo, ReplyInfo};
|
||||||
|
|
||||||
/// Конвертирует TDLib сообщение в MessageInfo
|
/// Конвертирует TDLib сообщение в MessageInfo
|
||||||
pub fn convert_message(
|
pub fn convert_message(client: &mut TdClient, message: &TdMessage, chat_id: ChatId) -> MessageInfo {
|
||||||
client: &mut TdClient,
|
|
||||||
message: &TdMessage,
|
|
||||||
chat_id: ChatId,
|
|
||||||
) -> MessageInfo {
|
|
||||||
let sender_name = match &message.sender_id {
|
let sender_name = match &message.sender_id {
|
||||||
tdlib_rs::enums::MessageSender::User(user) => {
|
tdlib_rs::enums::MessageSender::User(user) => {
|
||||||
// Пробуем получить имя из кеша (get обновляет LRU порядок)
|
// Пробуем получить имя из кеша (get обновляет LRU порядок)
|
||||||
@@ -76,7 +72,8 @@ pub fn convert_message(
|
|||||||
.text(content)
|
.text(content)
|
||||||
.entities(entities)
|
.entities(entities)
|
||||||
.date(message.date)
|
.date(message.date)
|
||||||
.edit_date(message.edit_date);
|
.edit_date(message.edit_date)
|
||||||
|
.media_album_id(message.media_album_id);
|
||||||
|
|
||||||
// Применяем флаги
|
// Применяем флаги
|
||||||
if message.is_outgoing {
|
if message.is_outgoing {
|
||||||
@@ -119,7 +116,7 @@ pub fn extract_reply_info(client: &TdClient, message: &TdMessage) -> Option<Repl
|
|||||||
let sender_name = reply
|
let sender_name = reply
|
||||||
.origin
|
.origin
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|origin| get_origin_sender_name(origin))
|
.map(get_origin_sender_name)
|
||||||
.unwrap_or_else(|| {
|
.unwrap_or_else(|| {
|
||||||
// Пробуем найти оригинальное сообщение в текущем списке
|
// Пробуем найти оригинальное сообщение в текущем списке
|
||||||
let reply_msg_id = MessageId::new(reply.message_id);
|
let reply_msg_id = MessageId::new(reply.message_id);
|
||||||
@@ -137,12 +134,7 @@ pub fn extract_reply_info(client: &TdClient, message: &TdMessage) -> Option<Repl
|
|||||||
.quote
|
.quote
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|q| q.text.text.clone())
|
.map(|q| q.text.text.clone())
|
||||||
.or_else(|| {
|
.or_else(|| reply.content.as_ref().map(TdClient::extract_content_text))
|
||||||
reply
|
|
||||||
.content
|
|
||||||
.as_ref()
|
|
||||||
.map(TdClient::extract_content_text)
|
|
||||||
})
|
|
||||||
.unwrap_or_else(|| {
|
.unwrap_or_else(|| {
|
||||||
// Пробуем найти в текущих сообщениях
|
// Пробуем найти в текущих сообщениях
|
||||||
client
|
client
|
||||||
@@ -153,11 +145,7 @@ pub fn extract_reply_info(client: &TdClient, message: &TdMessage) -> Option<Repl
|
|||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
});
|
});
|
||||||
|
|
||||||
Some(ReplyInfo {
|
Some(ReplyInfo { message_id: reply_msg_id, sender_name, text })
|
||||||
message_id: reply_msg_id,
|
|
||||||
sender_name,
|
|
||||||
text,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
@@ -218,12 +206,7 @@ pub fn update_reply_info_from_loaded_messages(client: &mut TdClient) {
|
|||||||
let msg_data: std::collections::HashMap<i64, (String, String)> = client
|
let msg_data: std::collections::HashMap<i64, (String, String)> = client
|
||||||
.current_chat_messages()
|
.current_chat_messages()
|
||||||
.iter()
|
.iter()
|
||||||
.map(|m| {
|
.map(|m| (m.id().as_i64(), (m.sender_name().to_string(), m.text().to_string())))
|
||||||
(
|
|
||||||
m.id().as_i64(),
|
|
||||||
(m.sender_name().to_string(), m.text().to_string()),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Обновляем reply_to для сообщений с неполными данными
|
// Обновляем reply_to для сообщений с неполными данными
|
||||||
|
|||||||
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,103 +1,21 @@
|
|||||||
use crate::constants::{MAX_MESSAGES_IN_CHAT, TDLIB_MESSAGE_LIMIT};
|
//! TDLib message API operations: history, send, edit, delete, forward, search.
|
||||||
|
|
||||||
|
use crate::constants::TDLIB_MESSAGE_LIMIT;
|
||||||
use crate::types::{ChatId, MessageId};
|
use crate::types::{ChatId, MessageId};
|
||||||
use tdlib_rs::enums::{InputMessageContent, InputMessageReplyTo, SearchMessagesFilter, TextParseMode};
|
use tdlib_rs::enums::{
|
||||||
|
InputMessageContent, InputMessageReplyTo, SearchMessagesFilter, TextParseMode,
|
||||||
|
};
|
||||||
use tdlib_rs::functions;
|
use tdlib_rs::functions;
|
||||||
use tdlib_rs::types::{FormattedText, InputMessageReplyToMessage, InputMessageText, Message as TdMessage, TextParseModeMarkdown};
|
use tdlib_rs::types::{
|
||||||
|
FormattedText, InputMessageReplyToMessage, InputMessageText, TextParseModeMarkdown,
|
||||||
|
};
|
||||||
use tokio::time::{sleep, Duration};
|
use tokio::time::{sleep, Duration};
|
||||||
|
|
||||||
use super::types::{MessageBuilder, MessageInfo, ReplyInfo};
|
use crate::tdlib::types::{MessageInfo, ReplyInfo};
|
||||||
|
|
||||||
/// Менеджер сообщений TDLib.
|
use super::MessageManager;
|
||||||
///
|
|
||||||
/// Управляет загрузкой, отправкой, редактированием и удалением сообщений.
|
|
||||||
/// Кеширует сообщения текущего открытого чата и закрепленные сообщения.
|
|
||||||
///
|
|
||||||
/// # Основные возможности
|
|
||||||
///
|
|
||||||
/// - Загрузка истории сообщений чата
|
|
||||||
/// - Отправка текстовых сообщений с поддержкой 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 {
|
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Загружает историю сообщений чата с динамической подгрузкой.
|
/// Загружает историю сообщений чата с динамической подгрузкой.
|
||||||
///
|
///
|
||||||
/// Загружает сообщения чанками, ожидая пока TDLib синхронизирует их с сервера.
|
/// Загружает сообщения чанками, ожидая пока TDLib синхронизирует их с сервера.
|
||||||
@@ -172,7 +90,7 @@ impl MessageManager {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let received_count = messages_obj.messages.len();
|
let received_count = messages_obj.messages.len();
|
||||||
|
|
||||||
// Если получили пустой результат
|
// Если получили пустой результат
|
||||||
if messages_obj.messages.is_empty() {
|
if messages_obj.messages.is_empty() {
|
||||||
consecutive_empty_results += 1;
|
consecutive_empty_results += 1;
|
||||||
@@ -183,15 +101,16 @@ impl MessageManager {
|
|||||||
// Пробуем еще раз
|
// Пробуем еще раз
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Получили сообщения - сбрасываем счетчик
|
// Получили сообщения - сбрасываем счетчик
|
||||||
consecutive_empty_results = 0;
|
consecutive_empty_results = 0;
|
||||||
|
|
||||||
// Если это первая загрузка и получили мало сообщений - продолжаем попытки
|
// Если это первая загрузка и получили мало сообщений - продолжаем попытки
|
||||||
// TDLib может подгружать данные с сервера постепенно
|
// TDLib может подгружать данные с сервера постепенно
|
||||||
if all_messages.is_empty() &&
|
if all_messages.is_empty()
|
||||||
received_count < (chunk_size as usize) &&
|
&& received_count < (chunk_size as usize)
|
||||||
attempt < max_attempts_per_chunk {
|
&& attempt < max_attempts_per_chunk
|
||||||
|
{
|
||||||
// Даём TDLib время на синхронизацию с сервером
|
// Даём TDLib время на синхронизацию с сервером
|
||||||
sleep(Duration::from_millis(100)).await;
|
sleep(Duration::from_millis(100)).await;
|
||||||
continue;
|
continue;
|
||||||
@@ -212,7 +131,7 @@ impl MessageManager {
|
|||||||
if !chunk_messages.is_empty() {
|
if !chunk_messages.is_empty() {
|
||||||
// Для следующей итерации: ID самого старого сообщения из текущего чанка
|
// Для следующей итерации: ID самого старого сообщения из текущего чанка
|
||||||
from_message_id = chunk_messages[0].id().as_i64();
|
from_message_id = chunk_messages[0].id().as_i64();
|
||||||
|
|
||||||
// ВАЖНО: Вставляем чанк В НАЧАЛО списка!
|
// ВАЖНО: Вставляем чанк В НАЧАЛО списка!
|
||||||
// Первый чанк содержит НОВЫЕ сообщения (например 51-100)
|
// Первый чанк содержит НОВЫЕ сообщения (например 51-100)
|
||||||
// Второй чанк содержит СТАРЫЕ сообщения (например 1-50)
|
// Второй чанк содержит СТАРЫЕ сообщения (например 1-50)
|
||||||
@@ -224,7 +143,7 @@ impl MessageManager {
|
|||||||
// Последующие чанки - вставляем в начало
|
// Последующие чанки - вставляем в начало
|
||||||
all_messages.splice(0..0, chunk_messages);
|
all_messages.splice(0..0, chunk_messages);
|
||||||
}
|
}
|
||||||
|
|
||||||
chunk_loaded = true;
|
chunk_loaded = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,7 +160,7 @@ impl MessageManager {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(all_messages)
|
Ok(all_messages)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,11 +206,9 @@ impl MessageManager {
|
|||||||
match result {
|
match result {
|
||||||
Ok(tdlib_rs::enums::Messages::Messages(messages_obj)) => {
|
Ok(tdlib_rs::enums::Messages::Messages(messages_obj)) => {
|
||||||
let mut messages = Vec::new();
|
let mut messages = Vec::new();
|
||||||
for msg_opt in messages_obj.messages.iter().rev() {
|
for msg in messages_obj.messages.iter().rev().flatten() {
|
||||||
if let Some(msg) = msg_opt {
|
if let Some(info) = self.convert_message(msg).await {
|
||||||
if let Some(info) = self.convert_message(msg).await {
|
messages.push(info);
|
||||||
messages.push(info);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(messages)
|
Ok(messages)
|
||||||
@@ -319,17 +236,20 @@ impl MessageManager {
|
|||||||
/// let pinned = msg_manager.get_pinned_messages(chat_id).await?;
|
/// let pinned = msg_manager.get_pinned_messages(chat_id).await?;
|
||||||
/// println!("Found {} pinned messages", pinned.len());
|
/// println!("Found {} pinned messages", pinned.len());
|
||||||
/// ```
|
/// ```
|
||||||
pub async fn get_pinned_messages(&mut self, chat_id: ChatId) -> Result<Vec<MessageInfo>, String> {
|
pub async fn get_pinned_messages(
|
||||||
|
&mut self,
|
||||||
|
chat_id: ChatId,
|
||||||
|
) -> Result<Vec<MessageInfo>, String> {
|
||||||
let result = functions::search_chat_messages(
|
let result = functions::search_chat_messages(
|
||||||
chat_id.as_i64(),
|
chat_id.as_i64(),
|
||||||
String::new(),
|
String::new(),
|
||||||
None,
|
None,
|
||||||
0, // from_message_id
|
0, // from_message_id
|
||||||
0, // offset
|
0, // offset
|
||||||
100, // limit
|
100, // limit
|
||||||
Some(SearchMessagesFilter::Pinned),
|
Some(SearchMessagesFilter::Pinned),
|
||||||
0, // message_thread_id
|
0, // message_thread_id
|
||||||
0, // saved_messages_topic_id
|
0, // saved_messages_topic_id
|
||||||
self.client_id,
|
self.client_id,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -364,13 +284,6 @@ impl MessageManager {
|
|||||||
// Нужно использовать getChatPinnedMessage или альтернативный способ.
|
// Нужно использовать getChatPinnedMessage или альтернативный способ.
|
||||||
// Временно отключено.
|
// Временно отключено.
|
||||||
self.current_pinned_message = None;
|
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 больше не существует
|
|
||||||
// }
|
|
||||||
// _ => {}
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Выполняет поиск сообщений по тексту в указанном чате.
|
/// Выполняет поиск сообщений по тексту в указанном чате.
|
||||||
@@ -403,8 +316,8 @@ impl MessageManager {
|
|||||||
0, // offset
|
0, // offset
|
||||||
100, // limit
|
100, // limit
|
||||||
None,
|
None,
|
||||||
0, // message_thread_id
|
0, // message_thread_id
|
||||||
0, // saved_messages_topic_id
|
0, // saved_messages_topic_id
|
||||||
self.client_id,
|
self.client_id,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -474,15 +387,9 @@ impl MessageManager {
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(tdlib_rs::enums::FormattedText::FormattedText(ft)) => {
|
Ok(tdlib_rs::enums::FormattedText::FormattedText(ft)) => {
|
||||||
FormattedText {
|
FormattedText { text: ft.text, entities: ft.entities }
|
||||||
text: ft.text,
|
|
||||||
entities: ft.entities,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Err(_) => FormattedText {
|
Err(_) => FormattedText { text: text.clone(), entities: vec![] },
|
||||||
text: text.clone(),
|
|
||||||
entities: vec![],
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let content = InputMessageContent::InputMessageText(InputMessageText {
|
let content = InputMessageContent::InputMessageText(InputMessageText {
|
||||||
@@ -515,7 +422,7 @@ impl MessageManager {
|
|||||||
.convert_message(&msg)
|
.convert_message(&msg)
|
||||||
.await
|
.await
|
||||||
.ok_or_else(|| "Не удалось конвертировать сообщение".to_string())?;
|
.ok_or_else(|| "Не удалось конвертировать сообщение".to_string())?;
|
||||||
|
|
||||||
// Добавляем reply_info если был передан
|
// Добавляем reply_info если был передан
|
||||||
if let Some(reply) = reply_info {
|
if let Some(reply) = reply_info {
|
||||||
msg_info.interactions.reply_to = Some(reply);
|
msg_info.interactions.reply_to = Some(reply);
|
||||||
@@ -553,15 +460,9 @@ impl MessageManager {
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(tdlib_rs::enums::FormattedText::FormattedText(ft)) => {
|
Ok(tdlib_rs::enums::FormattedText::FormattedText(ft)) => {
|
||||||
FormattedText {
|
FormattedText { text: ft.text, entities: ft.entities }
|
||||||
text: ft.text,
|
|
||||||
entities: ft.entities,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Err(_) => FormattedText {
|
Err(_) => FormattedText { text: text.clone(), entities: vec![] },
|
||||||
text: text.clone(),
|
|
||||||
entities: vec![],
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let content = InputMessageContent::InputMessageText(InputMessageText {
|
let content = InputMessageContent::InputMessageText(InputMessageText {
|
||||||
@@ -570,8 +471,13 @@ impl MessageManager {
|
|||||||
clear_draft: true,
|
clear_draft: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
let result =
|
let result = functions::edit_message_text(
|
||||||
functions::edit_message_text(chat_id.as_i64(), message_id.as_i64(), content, self.client_id).await;
|
chat_id.as_i64(),
|
||||||
|
message_id.as_i64(),
|
||||||
|
content,
|
||||||
|
self.client_id,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok(tdlib_rs::enums::Message::Message(msg)) => self
|
Ok(tdlib_rs::enums::Message::Message(msg)) => self
|
||||||
@@ -602,7 +508,8 @@ impl MessageManager {
|
|||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let message_ids_i64: Vec<i64> = message_ids.into_iter().map(|id| id.as_i64()).collect();
|
let message_ids_i64: Vec<i64> = message_ids.into_iter().map(|id| id.as_i64()).collect();
|
||||||
let result =
|
let result =
|
||||||
functions::delete_messages(chat_id.as_i64(), message_ids_i64, revoke, self.client_id).await;
|
functions::delete_messages(chat_id.as_i64(), message_ids_i64, revoke, self.client_id)
|
||||||
|
.await;
|
||||||
match result {
|
match result {
|
||||||
Ok(_) => Ok(()),
|
Ok(_) => Ok(()),
|
||||||
Err(e) => Err(format!("Ошибка удаления: {:?}", e)),
|
Err(e) => Err(format!("Ошибка удаления: {:?}", e)),
|
||||||
@@ -670,17 +577,15 @@ impl MessageManager {
|
|||||||
reply_to: None,
|
reply_to: None,
|
||||||
date: 0,
|
date: 0,
|
||||||
input_message_text: InputMessageContent::InputMessageText(InputMessageText {
|
input_message_text: InputMessageContent::InputMessageText(InputMessageText {
|
||||||
text: FormattedText {
|
text: FormattedText { text: text.clone(), entities: vec![] },
|
||||||
text: text.clone(),
|
|
||||||
entities: vec![],
|
|
||||||
},
|
|
||||||
link_preview_options: None,
|
link_preview_options: None,
|
||||||
clear_draft: false,
|
clear_draft: false,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
let result = functions::set_chat_draft_message(chat_id.as_i64(), 0, draft, self.client_id).await;
|
let result =
|
||||||
|
functions::set_chat_draft_message(chat_id.as_i64(), 0, draft, self.client_id).await;
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok(_) => Ok(()),
|
Ok(_) => Ok(()),
|
||||||
@@ -705,132 +610,8 @@ impl MessageManager {
|
|||||||
|
|
||||||
for (chat_id, message_ids) in batch {
|
for (chat_id, message_ids) in batch {
|
||||||
let ids: Vec<i64> = message_ids.iter().map(|id| id.as_i64()).collect();
|
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;
|
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> {
|
|
||||||
use crate::tdlib::message_conversion::{
|
|
||||||
extract_content_text, extract_entities, extract_forward_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 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) {
|
|
||||||
// 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();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -4,8 +4,8 @@ mod chat_helpers; // Chat management helpers
|
|||||||
pub mod chats;
|
pub mod chats;
|
||||||
pub mod client;
|
pub mod client;
|
||||||
mod client_impl; // Private module for trait implementation
|
mod client_impl; // Private module for trait implementation
|
||||||
mod message_converter; // Message conversion utilities (for client.rs)
|
|
||||||
mod message_conversion; // Message conversion utilities (for messages.rs)
|
mod message_conversion; // Message conversion utilities (for messages.rs)
|
||||||
|
mod message_converter; // Message conversion utilities (for client.rs)
|
||||||
pub mod messages;
|
pub mod messages;
|
||||||
pub mod reactions;
|
pub mod reactions;
|
||||||
pub mod r#trait;
|
pub mod r#trait;
|
||||||
@@ -17,9 +17,15 @@ pub mod users;
|
|||||||
pub use auth::AuthState;
|
pub use auth::AuthState;
|
||||||
pub use client::TdClient;
|
pub use client::TdClient;
|
||||||
pub use r#trait::TdClientTrait;
|
pub use r#trait::TdClientTrait;
|
||||||
|
#[allow(unused_imports)]
|
||||||
pub use types::{
|
pub use types::{
|
||||||
ChatInfo, FolderInfo, MessageBuilder, MessageInfo, NetworkState, ProfileInfo, 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;
|
pub use users::UserCache;
|
||||||
|
|
||||||
// Re-export ChatAction для удобства
|
// Re-export ChatAction для удобства
|
||||||
|
|||||||
@@ -69,7 +69,8 @@ impl ReactionManager {
|
|||||||
message_id: MessageId,
|
message_id: MessageId,
|
||||||
) -> Result<Vec<String>, String> {
|
) -> Result<Vec<String>, String> {
|
||||||
// Получаем сообщение
|
// Получаем сообщение
|
||||||
let msg_result = functions::get_message(chat_id.as_i64(), message_id.as_i64(), self.client_id).await;
|
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 = match msg_result {
|
||||||
Ok(m) => m,
|
Ok(m) => m,
|
||||||
Err(e) => return Err(format!("Ошибка получения сообщения: {:?}", e)),
|
Err(e) => return Err(format!("Ошибка получения сообщения: {:?}", e)),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
use crate::tdlib::{AuthState, FolderInfo, MessageInfo, ProfileInfo, UserCache, UserOnlineStatus};
|
use crate::tdlib::{AuthState, FolderInfo, MessageInfo, ProfileInfo, UserCache, UserOnlineStatus};
|
||||||
use crate::types::{ChatId, MessageId, UserId};
|
use crate::types::{ChatId, MessageId, UserId};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
use std::path::PathBuf;
|
||||||
use tdlib_rs::enums::{ChatAction, Update};
|
use tdlib_rs::enums::{ChatAction, Update};
|
||||||
|
|
||||||
use super::ChatInfo;
|
use super::ChatInfo;
|
||||||
@@ -13,6 +14,7 @@ use super::ChatInfo;
|
|||||||
///
|
///
|
||||||
/// This trait defines the interface for both real and fake TDLib clients,
|
/// This trait defines the interface for both real and fake TDLib clients,
|
||||||
/// enabling dependency injection and easier testing.
|
/// enabling dependency injection and easier testing.
|
||||||
|
#[allow(dead_code)]
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait TdClientTrait: Send {
|
pub trait TdClientTrait: Send {
|
||||||
// ============ Auth methods ============
|
// ============ Auth methods ============
|
||||||
@@ -31,11 +33,23 @@ pub trait TdClientTrait: Send {
|
|||||||
fn clear_stale_typing_status(&mut self) -> bool;
|
fn clear_stale_typing_status(&mut self) -> bool;
|
||||||
|
|
||||||
// ============ Message methods ============
|
// ============ Message methods ============
|
||||||
async fn get_chat_history(&mut self, chat_id: ChatId, limit: i32) -> Result<Vec<MessageInfo>, String>;
|
async fn get_chat_history(
|
||||||
async fn load_older_messages(&mut self, chat_id: ChatId, from_message_id: MessageId) -> Result<Vec<MessageInfo>, String>;
|
&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 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 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 search_messages(
|
||||||
|
&self,
|
||||||
|
chat_id: ChatId,
|
||||||
|
query: &str,
|
||||||
|
) -> Result<Vec<MessageInfo>, String>;
|
||||||
|
|
||||||
async fn send_message(
|
async fn send_message(
|
||||||
&mut self,
|
&mut self,
|
||||||
@@ -90,6 +104,10 @@ pub trait TdClientTrait: Send {
|
|||||||
reaction: String,
|
reaction: String,
|
||||||
) -> Result<(), 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) ============
|
// ============ Getters (immutable) ============
|
||||||
fn client_id(&self) -> i32;
|
fn client_id(&self) -> i32;
|
||||||
async fn get_me(&self) -> Result<i64, String>;
|
async fn get_me(&self) -> Result<i64, String>;
|
||||||
@@ -123,6 +141,13 @@ pub trait TdClientTrait: Send {
|
|||||||
// ============ Notification methods ============
|
// ============ Notification methods ============
|
||||||
fn sync_notification_muted_chats(&mut self);
|
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 ============
|
// ============ Update handling ============
|
||||||
fn handle_update(&mut self, update: Update);
|
fn handle_update(&mut self, update: Update);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,54 @@ pub struct ReactionInfo {
|
|||||||
pub is_chosen: bool,
|
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, отправитель, время)
|
/// Метаданные сообщения (ID, отправитель, время)
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct MessageMetadata {
|
pub struct MessageMetadata {
|
||||||
@@ -62,14 +110,18 @@ pub struct MessageMetadata {
|
|||||||
pub date: i32,
|
pub date: i32,
|
||||||
/// Дата редактирования (0 если не редактировалось)
|
/// Дата редактирования (0 если не редактировалось)
|
||||||
pub edit_date: i32,
|
pub edit_date: i32,
|
||||||
|
/// ID медиа-альбома (0 если не часть альбома)
|
||||||
|
pub media_album_id: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Контент сообщения (текст и форматирование)
|
/// Контент сообщения (текст и форматирование)
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct MessageContent {
|
pub struct MessageContent {
|
||||||
pub text: String,
|
pub text: String,
|
||||||
/// Сущности форматирования (bold, italic, code и т.д.)
|
/// Сущности форматирования (bold, italic, code и т.д.)
|
||||||
pub entities: Vec<TextEntity>,
|
pub entities: Vec<TextEntity>,
|
||||||
|
/// Медиа-контент (фото, видео и т.д.)
|
||||||
|
pub media: Option<MediaInfo>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Состояние и права доступа к сообщению
|
/// Состояние и права доступа к сообщению
|
||||||
@@ -106,6 +158,7 @@ pub struct MessageInfo {
|
|||||||
|
|
||||||
impl MessageInfo {
|
impl MessageInfo {
|
||||||
/// Создать новое сообщение
|
/// Создать новое сообщение
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn new(
|
pub fn new(
|
||||||
id: MessageId,
|
id: MessageId,
|
||||||
sender_name: String,
|
sender_name: String,
|
||||||
@@ -128,11 +181,9 @@ impl MessageInfo {
|
|||||||
sender_name,
|
sender_name,
|
||||||
date,
|
date,
|
||||||
edit_date,
|
edit_date,
|
||||||
|
media_album_id: 0,
|
||||||
},
|
},
|
||||||
content: MessageContent {
|
content: MessageContent { text: content, entities, media: None },
|
||||||
text: content,
|
|
||||||
entities,
|
|
||||||
},
|
|
||||||
state: MessageState {
|
state: MessageState {
|
||||||
is_outgoing,
|
is_outgoing,
|
||||||
is_read,
|
is_read,
|
||||||
@@ -140,11 +191,7 @@ impl MessageInfo {
|
|||||||
can_be_deleted_only_for_self,
|
can_be_deleted_only_for_self,
|
||||||
can_be_deleted_for_all_users,
|
can_be_deleted_for_all_users,
|
||||||
},
|
},
|
||||||
interactions: MessageInteractions {
|
interactions: MessageInteractions { reply_to, forward_from, reactions },
|
||||||
reply_to,
|
|
||||||
forward_from,
|
|
||||||
reactions,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,6 +212,10 @@ impl MessageInfo {
|
|||||||
self.metadata.edit_date > 0
|
self.metadata.edit_date > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn media_album_id(&self) -> i64 {
|
||||||
|
self.metadata.media_album_id
|
||||||
|
}
|
||||||
|
|
||||||
pub fn text(&self) -> &str {
|
pub fn text(&self) -> &str {
|
||||||
&self.content.text
|
&self.content.text
|
||||||
}
|
}
|
||||||
@@ -196,13 +247,53 @@ impl MessageInfo {
|
|||||||
/// Checks if the message contains a mention (@username or user mention)
|
/// Checks if the message contains a mention (@username or user mention)
|
||||||
pub fn has_mention(&self) -> bool {
|
pub fn has_mention(&self) -> bool {
|
||||||
self.content.entities.iter().any(|entity| {
|
self.content.entities.iter().any(|entity| {
|
||||||
matches!(
|
matches!(entity.r#type, TextEntityType::Mention | TextEntityType::MentionName(_))
|
||||||
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> {
|
pub fn reply_to(&self) -> Option<&ReplyInfo> {
|
||||||
self.interactions.reply_to.as_ref()
|
self.interactions.reply_to.as_ref()
|
||||||
}
|
}
|
||||||
@@ -217,13 +308,13 @@ impl MessageInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Builder для удобного создания MessageInfo с fluent API
|
/// Builder для удобного создания MessageInfo с fluent API
|
||||||
///
|
///
|
||||||
/// # Примеры
|
/// # Примеры
|
||||||
///
|
///
|
||||||
/// ```
|
/// ```
|
||||||
/// use tele_tui::tdlib::MessageBuilder;
|
/// use tele_tui::tdlib::MessageBuilder;
|
||||||
/// use tele_tui::types::MessageId;
|
/// use tele_tui::types::MessageId;
|
||||||
///
|
///
|
||||||
/// let message = MessageBuilder::new(MessageId::new(123))
|
/// let message = MessageBuilder::new(MessageId::new(123))
|
||||||
/// .sender_name("Alice")
|
/// .sender_name("Alice")
|
||||||
/// .text("Hello, world!")
|
/// .text("Hello, world!")
|
||||||
@@ -246,6 +337,8 @@ pub struct MessageBuilder {
|
|||||||
reply_to: Option<ReplyInfo>,
|
reply_to: Option<ReplyInfo>,
|
||||||
forward_from: Option<ForwardInfo>,
|
forward_from: Option<ForwardInfo>,
|
||||||
reactions: Vec<ReactionInfo>,
|
reactions: Vec<ReactionInfo>,
|
||||||
|
media: Option<MediaInfo>,
|
||||||
|
media_album_id: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MessageBuilder {
|
impl MessageBuilder {
|
||||||
@@ -266,6 +359,8 @@ impl MessageBuilder {
|
|||||||
reply_to: None,
|
reply_to: None,
|
||||||
forward_from: None,
|
forward_from: None,
|
||||||
reactions: Vec::new(),
|
reactions: Vec::new(),
|
||||||
|
media: None,
|
||||||
|
media_album_id: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -363,9 +458,21 @@ impl MessageBuilder {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Установить медиа-контент
|
||||||
|
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'а
|
/// Построить MessageInfo из данных builder'а
|
||||||
pub fn build(self) -> MessageInfo {
|
pub fn build(self) -> MessageInfo {
|
||||||
MessageInfo::new(
|
let mut msg = MessageInfo::new(
|
||||||
self.id,
|
self.id,
|
||||||
self.sender_name,
|
self.sender_name,
|
||||||
self.is_outgoing,
|
self.is_outgoing,
|
||||||
@@ -380,11 +487,13 @@ impl MessageBuilder {
|
|||||||
self.reply_to,
|
self.reply_to,
|
||||||
self.forward_from,
|
self.forward_from,
|
||||||
self.reactions,
|
self.reactions,
|
||||||
)
|
);
|
||||||
|
msg.content.media = self.media;
|
||||||
|
msg.metadata.media_album_id = self.media_album_id;
|
||||||
|
msg
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -452,9 +561,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_message_builder_with_reactions() {
|
fn test_message_builder_with_reactions() {
|
||||||
let reaction = ReactionInfo {
|
let reaction = ReactionInfo {
|
||||||
emoji: "👍".to_string(),
|
emoji: "👍".to_string(), count: 5, is_chosen: true
|
||||||
count: 5,
|
|
||||||
is_chosen: true,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let message = MessageBuilder::new(MessageId::new(300))
|
let message = MessageBuilder::new(MessageId::new(300))
|
||||||
@@ -512,9 +619,9 @@ mod tests {
|
|||||||
.entities(vec![TextEntity {
|
.entities(vec![TextEntity {
|
||||||
offset: 6,
|
offset: 6,
|
||||||
length: 4,
|
length: 4,
|
||||||
r#type: TextEntityType::MentionName(
|
r#type: TextEntityType::MentionName(tdlib_rs::types::TextEntityTypeMentionName {
|
||||||
tdlib_rs::types::TextEntityTypeMentionName { user_id: 123 },
|
user_id: 123,
|
||||||
),
|
}),
|
||||||
}])
|
}])
|
||||||
.build();
|
.build();
|
||||||
assert!(message_with_mention_name.has_mention());
|
assert!(message_with_mention_name.has_mention());
|
||||||
@@ -574,3 +681,44 @@ pub enum UserOnlineStatus {
|
|||||||
/// Оффлайн с указанием времени (unix timestamp)
|
/// Оффлайн с указанием времени (unix timestamp)
|
||||||
Offline(i32),
|
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),
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,12 +5,10 @@
|
|||||||
|
|
||||||
use crate::types::{ChatId, MessageId, UserId};
|
use crate::types::{ChatId, MessageId, UserId};
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
use tdlib_rs::enums::{
|
use tdlib_rs::enums::{AuthorizationState, ChatAction, ChatList, MessageSender};
|
||||||
AuthorizationState, ChatAction, ChatList, MessageSender,
|
|
||||||
};
|
|
||||||
use tdlib_rs::types::{
|
use tdlib_rs::types::{
|
||||||
UpdateChatAction, UpdateChatDraftMessage, UpdateChatPosition,
|
UpdateChatAction, UpdateChatDraftMessage, UpdateChatPosition, UpdateMessageInteractionInfo,
|
||||||
UpdateMessageInteractionInfo, UpdateMessageSendSucceeded, UpdateNewMessage, UpdateUser,
|
UpdateMessageSendSucceeded, UpdateNewMessage, UpdateUser,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::auth::AuthState;
|
use super::auth::AuthState;
|
||||||
@@ -25,24 +23,24 @@ pub fn handle_new_message_update(client: &mut TdClient, new_msg: UpdateNewMessag
|
|||||||
if Some(chat_id) != client.current_chat_id() {
|
if Some(chat_id) != client.current_chat_id() {
|
||||||
// Find and clone chat info to avoid borrow checker issues
|
// Find and clone chat info to avoid borrow checker issues
|
||||||
if let Some(chat) = client.chats().iter().find(|c| c.id == chat_id).cloned() {
|
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);
|
let msg_info =
|
||||||
|
crate::tdlib::message_converter::convert_message(client, &new_msg.message, chat_id);
|
||||||
|
|
||||||
// Get sender name (from message or user cache)
|
// Get sender name (from message or user cache)
|
||||||
let sender_name = msg_info.sender_name();
|
let sender_name = msg_info.sender_name();
|
||||||
|
|
||||||
// Send notification
|
// Send notification
|
||||||
let _ = client.notification_manager.notify_new_message(
|
let _ = client
|
||||||
&chat,
|
.notification_manager
|
||||||
&msg_info,
|
.notify_new_message(&chat, &msg_info, sender_name);
|
||||||
sender_name,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Добавляем новое сообщение если это текущий открытый чат
|
// Добавляем новое сообщение если это текущий открытый чат
|
||||||
|
|
||||||
let msg_info = crate::tdlib::message_converter::convert_message(client, &new_msg.message, chat_id);
|
let msg_info =
|
||||||
|
crate::tdlib::message_converter::convert_message(client, &new_msg.message, chat_id);
|
||||||
let msg_id = msg_info.id();
|
let msg_id = msg_info.id();
|
||||||
let is_incoming = !msg_info.is_outgoing();
|
let is_incoming = !msg_info.is_outgoing();
|
||||||
|
|
||||||
@@ -74,7 +72,9 @@ pub fn handle_new_message_update(client: &mut TdClient, new_msg: UpdateNewMessag
|
|||||||
client.push_message(msg_info.clone());
|
client.push_message(msg_info.clone());
|
||||||
// Если это входящее сообщение — добавляем в очередь для отметки как прочитанное
|
// Если это входящее сообщение — добавляем в очередь для отметки как прочитанное
|
||||||
if is_incoming {
|
if is_incoming {
|
||||||
client.pending_view_messages_mut().push((chat_id, vec![msg_id]));
|
client
|
||||||
|
.pending_view_messages_mut()
|
||||||
|
.push((chat_id, vec![msg_id]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -105,7 +105,7 @@ pub fn handle_chat_action_update(client: &mut TdClient, update: UpdateChatAction
|
|||||||
ChatAction::ChoosingSticker => Some("выбирает стикер...".to_string()),
|
ChatAction::ChoosingSticker => Some("выбирает стикер...".to_string()),
|
||||||
ChatAction::RecordingVideoNote => Some("записывает видеосообщение...".to_string()),
|
ChatAction::RecordingVideoNote => Some("записывает видеосообщение...".to_string()),
|
||||||
ChatAction::UploadingVideoNote(_) => Some("отправляет видеосообщение...".to_string()),
|
ChatAction::UploadingVideoNote(_) => Some("отправляет видеосообщение...".to_string()),
|
||||||
ChatAction::Cancel | _ => None, // Отмена или неизвестное действие
|
_ => None, // Отмена или неизвестное действие
|
||||||
};
|
};
|
||||||
|
|
||||||
match action_text {
|
match action_text {
|
||||||
@@ -181,14 +181,21 @@ pub fn handle_user_update(client: &mut TdClient, update: UpdateUser) {
|
|||||||
} else {
|
} else {
|
||||||
format!("{} {}", user.first_name, user.last_name)
|
format!("{} {}", user.first_name, user.last_name)
|
||||||
};
|
};
|
||||||
client.user_cache.user_names.insert(UserId::new(user.id), display_name);
|
client
|
||||||
|
.user_cache
|
||||||
|
.user_names
|
||||||
|
.insert(UserId::new(user.id), display_name);
|
||||||
|
|
||||||
// Сохраняем username если есть (с упрощённым извлечением через and_then)
|
// Сохраняем username если есть (с упрощённым извлечением через and_then)
|
||||||
if let Some(username) = user.usernames
|
if let Some(username) = user
|
||||||
|
.usernames
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|u| u.active_usernames.first())
|
.and_then(|u| u.active_usernames.first())
|
||||||
{
|
{
|
||||||
client.user_cache.user_usernames.insert(UserId::new(user.id), username.to_string());
|
client
|
||||||
|
.user_cache
|
||||||
|
.user_usernames
|
||||||
|
.insert(UserId::new(user.id), username.to_string());
|
||||||
// Обновляем username в чатах, связанных с этим пользователем
|
// Обновляем username в чатах, связанных с этим пользователем
|
||||||
for (&chat_id, &user_id) in &client.user_cache.chat_user_ids.clone() {
|
for (&chat_id, &user_id) in &client.user_cache.chat_user_ids.clone() {
|
||||||
if user_id == UserId::new(user.id) {
|
if user_id == UserId::new(user.id) {
|
||||||
@@ -273,7 +280,8 @@ pub fn handle_message_send_succeeded_update(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Конвертируем новое сообщение
|
// Конвертируем новое сообщение
|
||||||
let mut new_msg = crate::tdlib::message_converter::convert_message(client, &update.message, chat_id);
|
let mut new_msg =
|
||||||
|
crate::tdlib::message_converter::convert_message(client, &update.message, chat_id);
|
||||||
|
|
||||||
// Сохраняем reply_info из старого сообщения (если было)
|
// Сохраняем reply_info из старого сообщения (если было)
|
||||||
let old_reply = client.current_chat_messages()[idx]
|
let old_reply = client.current_chat_messages()[idx]
|
||||||
|
|||||||
@@ -175,7 +175,9 @@ impl UserCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Сохраняем имя
|
// Сохраняем имя
|
||||||
let display_name = format!("{} {}", user.first_name, user.last_name).trim().to_string();
|
let display_name = format!("{} {}", user.first_name, user.last_name)
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
self.user_names.insert(UserId::new(user_id), display_name);
|
self.user_names.insert(UserId::new(user_id), display_name);
|
||||||
|
|
||||||
// Обновляем статус
|
// Обновляем статус
|
||||||
@@ -211,6 +213,7 @@ impl UserCache {
|
|||||||
/// # Returns
|
/// # Returns
|
||||||
///
|
///
|
||||||
/// Имя пользователя (first_name + last_name) или "User {id}" если не найден.
|
/// Имя пользователя (first_name + last_name) или "User {id}" если не найден.
|
||||||
|
#[allow(dead_code)]
|
||||||
pub async fn get_user_name(&self, user_id: UserId) -> String {
|
pub async fn get_user_name(&self, user_id: UserId) -> String {
|
||||||
// Сначала пытаемся получить из кэша
|
// Сначала пытаемся получить из кэша
|
||||||
if let Some(name) = self.user_names.peek(&user_id) {
|
if let Some(name) = self.user_names.peek(&user_id) {
|
||||||
@@ -220,7 +223,9 @@ impl UserCache {
|
|||||||
// Загружаем пользователя
|
// Загружаем пользователя
|
||||||
match functions::get_user(user_id.as_i64(), self.client_id).await {
|
match functions::get_user(user_id.as_i64(), self.client_id).await {
|
||||||
Ok(User::User(user)) => {
|
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
|
name
|
||||||
}
|
}
|
||||||
_ => format!("User {}", user_id.as_i64()),
|
_ => format!("User {}", user_id.as_i64()),
|
||||||
@@ -257,8 +262,7 @@ impl UserCache {
|
|||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
// Если не удалось загрузить, сохраняем placeholder
|
// Если не удалось загрузить, сохраняем placeholder
|
||||||
self.user_names
|
self.user_names.insert(user_id, format!("User {}", user_id));
|
||||||
.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 serde::{Deserialize, Serialize};
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
@@ -134,7 +136,7 @@ mod tests {
|
|||||||
// let chat_id = ChatId::new(1);
|
// let chat_id = ChatId::new(1);
|
||||||
// let message_id = MessageId::new(1);
|
// let message_id = MessageId::new(1);
|
||||||
// if chat_id == message_id { } // ERROR: mismatched types
|
// if chat_id == message_id { } // ERROR: mismatched types
|
||||||
|
|
||||||
// Runtime values can be the same, but types are different
|
// Runtime values can be the same, but types are different
|
||||||
let chat_id = ChatId::new(1);
|
let chat_id = ChatId::new(1);
|
||||||
let message_id = MessageId::new(1);
|
let message_id = MessageId::new(1);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use crate::app::App;
|
use crate::app::App;
|
||||||
use crate::tdlib::TdClientTrait;
|
|
||||||
use crate::tdlib::AuthState;
|
use crate::tdlib::AuthState;
|
||||||
|
use crate::tdlib::TdClientTrait;
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
layout::{Alignment, Constraint, Direction, Layout},
|
layout::{Alignment, Constraint, Direction, Layout},
|
||||||
style::{Color, Modifier, Style},
|
style::{Color, Modifier, Style},
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
//! 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::app::App;
|
||||||
use crate::tdlib::TdClientTrait;
|
use crate::tdlib::TdClientTrait;
|
||||||
use crate::tdlib::UserOnlineStatus;
|
use crate::tdlib::UserOnlineStatus;
|
||||||
@@ -32,7 +35,7 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &mut App<T>) {
|
|||||||
let search_style = if app.is_searching {
|
let search_style = if app.is_searching {
|
||||||
Style::default().fg(Color::Yellow)
|
Style::default().fg(Color::Yellow)
|
||||||
} else {
|
} else {
|
||||||
Style::default().fg(Color::DarkGray)
|
Style::default().fg(Color::Rgb(160, 160, 160))
|
||||||
};
|
};
|
||||||
let search = Paragraph::new(search_text)
|
let search = Paragraph::new(search_text)
|
||||||
.block(Block::default().borders(Borders::ALL))
|
.block(Block::default().borders(Borders::ALL))
|
||||||
@@ -68,55 +71,18 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &mut App<T>) {
|
|||||||
|
|
||||||
f.render_stateful_widget(chats_list, chat_chunks[1], &mut app.chat_list_state);
|
f.render_stateful_widget(chats_list, chat_chunks[1], &mut app.chat_list_state);
|
||||||
|
|
||||||
// User status - показываем статус выбранного чата
|
// User status - показываем статус выбранного или выделенного чата
|
||||||
let (status_text, status_color) = if let Some(chat_id) = app.selected_chat_id {
|
let status_chat_id = if app.selected_chat_id.is_some() {
|
||||||
match app.td_client.get_user_status_by_chat_id(chat_id) {
|
app.selected_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 {
|
} else {
|
||||||
// Показываем статус выделенного в списке чата
|
|
||||||
let filtered = app.get_filtered_chats();
|
let filtered = app.get_filtered_chats();
|
||||||
if let Some(i) = app.chat_list_state.selected() {
|
app.chat_list_state
|
||||||
if let Some(chat) = filtered.get(i) {
|
.selected()
|
||||||
match app.td_client.get_user_status_by_chat_id(chat.id) {
|
.and_then(|i| filtered.get(i).map(|c| c.id))
|
||||||
Some(UserOnlineStatus::Online) => ("● онлайн".to_string(), Color::Green),
|
};
|
||||||
Some(UserOnlineStatus::Recently) => {
|
let (status_text, status_color) = match status_chat_id {
|
||||||
("был(а) недавно".to_string(), Color::Yellow)
|
Some(chat_id) => format_user_status(app.td_client.get_user_status_by_chat_id(chat_id)),
|
||||||
}
|
None => ("".to_string(), Color::DarkGray),
|
||||||
Some(UserOnlineStatus::Offline(was_online)) => {
|
|
||||||
let formatted = format_was_online(*was_online);
|
|
||||||
(formatted, Color::Gray)
|
|
||||||
}
|
|
||||||
Some(UserOnlineStatus::LastWeek) => {
|
|
||||||
("был(а) на этой неделе".to_string(), Color::DarkGray)
|
|
||||||
}
|
|
||||||
Some(UserOnlineStatus::LastMonth) => {
|
|
||||||
("был(а) в этом месяце".to_string(), Color::DarkGray)
|
|
||||||
}
|
|
||||||
Some(UserOnlineStatus::LongTimeAgo) => {
|
|
||||||
("был(а) давно".to_string(), Color::DarkGray)
|
|
||||||
}
|
|
||||||
None => ("".to_string(), Color::DarkGray),
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
("".to_string(), Color::DarkGray)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
("".to_string(), Color::DarkGray)
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let status = Paragraph::new(status_text)
|
let status = Paragraph::new(status_text)
|
||||||
@@ -125,7 +91,17 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &mut App<T>) {
|
|||||||
f.render_widget(status, chat_chunks[2]);
|
f.render_widget(status, chat_chunks[2]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Форматирование времени "был(а) в ..."
|
/// Форматирует статус пользователя для отображения в статус-баре
|
||||||
fn format_was_online(timestamp: i32) -> String {
|
fn format_user_status(status: Option<&UserOnlineStatus>) -> (String, Color) {
|
||||||
crate::utils::format_was_online(timestamp)
|
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 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_width = 50u16;
|
||||||
let modal_height = (rows + 4) as u16; // +4 для заголовка, отступов и подсказки
|
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 x = area.x + (area.width.saturating_sub(modal_width)) / 2;
|
||||||
let y = area.y + (area.height.saturating_sub(modal_height)) / 2;
|
let y = area.y + (area.height.saturating_sub(modal_height)) / 2;
|
||||||
|
|
||||||
let modal_area = Rect::new(
|
let modal_area = Rect::new(x, y, modal_width.min(area.width), modal_height.min(area.height));
|
||||||
x,
|
|
||||||
y,
|
|
||||||
modal_width.min(area.width),
|
|
||||||
modal_height.min(area.height),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Очищаем область под модалкой
|
// Очищаем область под модалкой
|
||||||
f.render_widget(Clear, modal_area);
|
f.render_widget(Clear, modal_area);
|
||||||
@@ -87,10 +82,7 @@ pub fn render_emoji_picker(
|
|||||||
.add_modifier(Modifier::BOLD),
|
.add_modifier(Modifier::BOLD),
|
||||||
),
|
),
|
||||||
Span::raw("Добавить "),
|
Span::raw("Добавить "),
|
||||||
Span::styled(
|
Span::styled(" [Esc] ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
|
||||||
" [Esc] ",
|
|
||||||
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
|
|
||||||
),
|
|
||||||
Span::raw("Отмена"),
|
Span::raw("Отмена"),
|
||||||
]));
|
]));
|
||||||
|
|
||||||
|
|||||||
@@ -34,10 +34,7 @@ pub fn render_input_field(
|
|||||||
// Символ под курсором (или █ если курсор в конце)
|
// Символ под курсором (или █ если курсор в конце)
|
||||||
if safe_cursor_pos < chars.len() {
|
if safe_cursor_pos < chars.len() {
|
||||||
let cursor_char = chars[safe_cursor_pos].to_string();
|
let cursor_char = chars[safe_cursor_pos].to_string();
|
||||||
spans.push(Span::styled(
|
spans.push(Span::styled(cursor_char, Style::default().fg(Color::Black).bg(color)));
|
||||||
cursor_char,
|
|
||||||
Style::default().fg(Color::Black).bg(color),
|
|
||||||
));
|
|
||||||
} else {
|
} else {
|
||||||
// Курсор в конце - показываем блок
|
// Курсор в конце - показываем блок
|
||||||
spans.push(Span::styled("█", Style::default().fg(color)));
|
spans.push(Span::styled("█", Style::default().fg(color)));
|
||||||
|
|||||||
@@ -7,7 +7,9 @@
|
|||||||
|
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use crate::formatting;
|
use crate::formatting;
|
||||||
use crate::tdlib::MessageInfo;
|
#[cfg(feature = "images")]
|
||||||
|
use crate::tdlib::PhotoDownloadState;
|
||||||
|
use crate::tdlib::{MessageInfo, PlaybackState, PlaybackStatus};
|
||||||
use crate::types::MessageId;
|
use crate::types::MessageId;
|
||||||
use crate::utils::{format_date, format_timestamp_with_tz};
|
use crate::utils::{format_date, format_timestamp_with_tz};
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
@@ -22,19 +24,34 @@ struct WrappedLine {
|
|||||||
start_offset: usize,
|
start_offset: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Разбивает текст на строки с учётом максимальной ширины
|
/// Разбивает текст на строки с учётом максимальной ширины и `\n`
|
||||||
fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
|
fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
|
||||||
|
let mut all_lines = Vec::new();
|
||||||
|
let mut char_offset = 0;
|
||||||
|
|
||||||
|
for segment in text.split('\n') {
|
||||||
|
let wrapped = wrap_paragraph(segment, max_width, char_offset);
|
||||||
|
all_lines.extend(wrapped);
|
||||||
|
char_offset += segment.chars().count() + 1; // +1 за '\n'
|
||||||
|
}
|
||||||
|
|
||||||
|
if all_lines.is_empty() {
|
||||||
|
all_lines.push(WrappedLine { text: String::new(), start_offset: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
all_lines
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Разбивает один абзац (без `\n`) на строки по ширине
|
||||||
|
fn wrap_paragraph(text: &str, max_width: usize, base_offset: usize) -> Vec<WrappedLine> {
|
||||||
if max_width == 0 {
|
if max_width == 0 {
|
||||||
return vec![WrappedLine {
|
return vec![WrappedLine { text: text.to_string(), start_offset: base_offset }];
|
||||||
text: text.to_string(),
|
|
||||||
start_offset: 0,
|
|
||||||
}];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut result = Vec::new();
|
let mut result = Vec::new();
|
||||||
let mut current_line = String::new();
|
let mut current_line = String::new();
|
||||||
let mut current_width = 0;
|
let mut current_width = 0;
|
||||||
let mut line_start_offset = 0;
|
let mut line_start_offset = base_offset;
|
||||||
|
|
||||||
let chars: Vec<char> = text.chars().collect();
|
let chars: Vec<char> = text.chars().collect();
|
||||||
let mut word_start = 0;
|
let mut word_start = 0;
|
||||||
@@ -49,7 +66,7 @@ fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
|
|||||||
if current_width == 0 {
|
if current_width == 0 {
|
||||||
current_line = word;
|
current_line = word;
|
||||||
current_width = word_width;
|
current_width = word_width;
|
||||||
line_start_offset = word_start;
|
line_start_offset = base_offset + word_start;
|
||||||
} else if current_width + 1 + word_width <= max_width {
|
} else if current_width + 1 + word_width <= max_width {
|
||||||
current_line.push(' ');
|
current_line.push(' ');
|
||||||
current_line.push_str(&word);
|
current_line.push_str(&word);
|
||||||
@@ -61,7 +78,7 @@ fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
|
|||||||
});
|
});
|
||||||
current_line = word;
|
current_line = word;
|
||||||
current_width = word_width;
|
current_width = word_width;
|
||||||
line_start_offset = word_start;
|
line_start_offset = base_offset + word_start;
|
||||||
}
|
}
|
||||||
in_word = false;
|
in_word = false;
|
||||||
}
|
}
|
||||||
@@ -77,7 +94,7 @@ fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
|
|||||||
|
|
||||||
if current_width == 0 {
|
if current_width == 0 {
|
||||||
current_line = word;
|
current_line = word;
|
||||||
line_start_offset = word_start;
|
line_start_offset = base_offset + word_start;
|
||||||
} else if current_width + 1 + word_width <= max_width {
|
} else if current_width + 1 + word_width <= max_width {
|
||||||
current_line.push(' ');
|
current_line.push(' ');
|
||||||
current_line.push_str(&word);
|
current_line.push_str(&word);
|
||||||
@@ -87,7 +104,7 @@ fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
|
|||||||
start_offset: line_start_offset,
|
start_offset: line_start_offset,
|
||||||
});
|
});
|
||||||
current_line = word;
|
current_line = word;
|
||||||
line_start_offset = word_start;
|
line_start_offset = base_offset + word_start;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,10 +116,7 @@ fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if result.is_empty() {
|
if result.is_empty() {
|
||||||
result.push(WrappedLine {
|
result.push(WrappedLine { text: String::new(), start_offset: base_offset });
|
||||||
text: String::new(),
|
|
||||||
start_offset: 0,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
result
|
result
|
||||||
@@ -115,7 +129,11 @@ fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
|
|||||||
/// * `date` - timestamp сообщения
|
/// * `date` - timestamp сообщения
|
||||||
/// * `content_width` - ширина области для центрирования
|
/// * `content_width` - ширина области для центрирования
|
||||||
/// * `is_first` - первый ли это разделитель (если нет, добавляется пустая строка сверху)
|
/// * `is_first` - первый ли это разделитель (если нет, добавляется пустая строка сверху)
|
||||||
pub fn render_date_separator(date: i32, content_width: usize, is_first: bool) -> Vec<Line<'static>> {
|
pub fn render_date_separator(
|
||||||
|
date: i32,
|
||||||
|
content_width: usize,
|
||||||
|
is_first: bool,
|
||||||
|
) -> Vec<Line<'static>> {
|
||||||
let mut lines = Vec::new();
|
let mut lines = Vec::new();
|
||||||
|
|
||||||
if !is_first {
|
if !is_first {
|
||||||
@@ -198,13 +216,14 @@ pub fn render_message_bubble(
|
|||||||
config: &Config,
|
config: &Config,
|
||||||
content_width: usize,
|
content_width: usize,
|
||||||
selected_msg_id: Option<MessageId>,
|
selected_msg_id: Option<MessageId>,
|
||||||
|
playback_state: Option<&PlaybackState>,
|
||||||
) -> Vec<Line<'static>> {
|
) -> Vec<Line<'static>> {
|
||||||
let mut lines = Vec::new();
|
let mut lines = Vec::new();
|
||||||
let is_selected = selected_msg_id == Some(msg.id());
|
let is_selected = selected_msg_id == Some(msg.id());
|
||||||
|
|
||||||
// Маркер выбора
|
// Маркер выбора (всегда резервируем место для ▶, чтобы текст не сдвигался)
|
||||||
let selection_marker = if is_selected { "▶ " } else { "" };
|
let selection_marker = if is_selected { "▶ " } else { " " };
|
||||||
let marker_len = selection_marker.chars().count();
|
let marker_len = 2;
|
||||||
|
|
||||||
// Цвет сообщения
|
// Цвет сообщения
|
||||||
let msg_color = if is_selected {
|
let msg_color = if is_selected {
|
||||||
@@ -252,10 +271,8 @@ pub fn render_message_bubble(
|
|||||||
Span::styled(reply_line, Style::default().fg(Color::Cyan)),
|
Span::styled(reply_line, Style::default().fg(Color::Cyan)),
|
||||||
]));
|
]));
|
||||||
} else {
|
} else {
|
||||||
lines.push(Line::from(vec![Span::styled(
|
lines
|
||||||
reply_line,
|
.push(Line::from(vec![Span::styled(reply_line, Style::default().fg(Color::Cyan))]));
|
||||||
Style::default().fg(Color::Cyan),
|
|
||||||
)]));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,31 +294,49 @@ pub fn render_message_bubble(
|
|||||||
let is_last_line = i == total_wrapped - 1;
|
let is_last_line = i == total_wrapped - 1;
|
||||||
let line_len = wrapped.text.chars().count();
|
let line_len = wrapped.text.chars().count();
|
||||||
|
|
||||||
let line_entities =
|
let line_entities = formatting::adjust_entities_for_substring(
|
||||||
formatting::adjust_entities_for_substring(msg.entities(), wrapped.start_offset, line_len);
|
msg.entities(),
|
||||||
let formatted_spans = formatting::format_text_with_entities(&wrapped.text, &line_entities, msg_color);
|
wrapped.start_offset,
|
||||||
|
line_len,
|
||||||
|
);
|
||||||
|
let formatted_spans =
|
||||||
|
formatting::format_text_with_entities(&wrapped.text, &line_entities, msg_color);
|
||||||
|
|
||||||
if is_last_line {
|
if is_last_line {
|
||||||
let full_len = line_len + time_mark_len + marker_len;
|
let full_len = line_len + time_mark_len + marker_len;
|
||||||
let padding = content_width.saturating_sub(full_len + 1);
|
let padding = content_width.saturating_sub(full_len + 1);
|
||||||
let mut line_spans = vec![Span::raw(" ".repeat(padding))];
|
let mut line_spans = vec![Span::raw(" ".repeat(padding))];
|
||||||
if is_selected {
|
if i == 0 {
|
||||||
|
// Первая (или единственная) строка — маркер
|
||||||
line_spans.push(Span::styled(
|
line_spans.push(Span::styled(
|
||||||
selection_marker,
|
selection_marker,
|
||||||
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
|
Style::default()
|
||||||
|
.fg(Color::Yellow)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
));
|
));
|
||||||
|
} else {
|
||||||
|
// Остальные строки multi-line — пробелы вместо маркера
|
||||||
|
line_spans.push(Span::raw(" ".repeat(marker_len)));
|
||||||
}
|
}
|
||||||
line_spans.extend(formatted_spans);
|
line_spans.extend(formatted_spans);
|
||||||
line_spans.push(Span::styled(format!(" {}", time_mark), Style::default().fg(Color::Gray)));
|
line_spans.push(Span::styled(
|
||||||
|
format!(" {}", time_mark),
|
||||||
|
Style::default().fg(Color::Gray),
|
||||||
|
));
|
||||||
lines.push(Line::from(line_spans));
|
lines.push(Line::from(line_spans));
|
||||||
} else {
|
} else {
|
||||||
let padding = content_width.saturating_sub(line_len + marker_len + 1);
|
let padding = content_width.saturating_sub(line_len + marker_len + 1);
|
||||||
let mut line_spans = vec![Span::raw(" ".repeat(padding))];
|
let mut line_spans = vec![Span::raw(" ".repeat(padding))];
|
||||||
if i == 0 && is_selected {
|
if i == 0 {
|
||||||
line_spans.push(Span::styled(
|
line_spans.push(Span::styled(
|
||||||
selection_marker,
|
selection_marker,
|
||||||
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
|
Style::default()
|
||||||
|
.fg(Color::Yellow)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
));
|
));
|
||||||
|
} else {
|
||||||
|
// Средние строки multi-line — пробелы вместо маркера
|
||||||
|
line_spans.push(Span::raw(" ".repeat(marker_len)));
|
||||||
}
|
}
|
||||||
line_spans.extend(formatted_spans);
|
line_spans.extend(formatted_spans);
|
||||||
lines.push(Line::from(line_spans));
|
lines.push(Line::from(line_spans));
|
||||||
@@ -319,19 +354,24 @@ pub fn render_message_bubble(
|
|||||||
for (i, wrapped) in wrapped_lines.into_iter().enumerate() {
|
for (i, wrapped) in wrapped_lines.into_iter().enumerate() {
|
||||||
let line_len = wrapped.text.chars().count();
|
let line_len = wrapped.text.chars().count();
|
||||||
|
|
||||||
let line_entities =
|
let line_entities = formatting::adjust_entities_for_substring(
|
||||||
formatting::adjust_entities_for_substring(msg.entities(), wrapped.start_offset, line_len);
|
msg.entities(),
|
||||||
let formatted_spans = formatting::format_text_with_entities(&wrapped.text, &line_entities, msg_color);
|
wrapped.start_offset,
|
||||||
|
line_len,
|
||||||
|
);
|
||||||
|
let formatted_spans =
|
||||||
|
formatting::format_text_with_entities(&wrapped.text, &line_entities, msg_color);
|
||||||
|
|
||||||
if i == 0 {
|
if i == 0 {
|
||||||
let mut line_spans = vec![];
|
let mut line_spans = vec![];
|
||||||
if is_selected {
|
line_spans.push(Span::styled(
|
||||||
line_spans.push(Span::styled(
|
selection_marker,
|
||||||
selection_marker,
|
Style::default()
|
||||||
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
|
.fg(Color::Yellow)
|
||||||
));
|
.add_modifier(Modifier::BOLD),
|
||||||
}
|
));
|
||||||
line_spans.push(Span::styled(format!(" {}", time_str), Style::default().fg(Color::Gray)));
|
line_spans
|
||||||
|
.push(Span::styled(format!(" {}", time_str), Style::default().fg(Color::Gray)));
|
||||||
line_spans.push(Span::raw(" "));
|
line_spans.push(Span::raw(" "));
|
||||||
line_spans.extend(formatted_spans);
|
line_spans.extend(formatted_spans);
|
||||||
lines.push(Line::from(line_spans));
|
lines.push(Line::from(line_spans));
|
||||||
@@ -359,12 +399,10 @@ pub fn render_message_bubble(
|
|||||||
} else {
|
} else {
|
||||||
format!("[{}]", reaction.emoji)
|
format!("[{}]", reaction.emoji)
|
||||||
}
|
}
|
||||||
|
} else if reaction.count > 1 {
|
||||||
|
format!("{} {}", reaction.emoji, reaction.count)
|
||||||
} else {
|
} else {
|
||||||
if reaction.count > 1 {
|
reaction.emoji.clone()
|
||||||
format!("{} {}", reaction.emoji, reaction.count)
|
|
||||||
} else {
|
|
||||||
reaction.emoji.clone()
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let style = if reaction.is_chosen {
|
let style = if reaction.is_chosen {
|
||||||
@@ -392,5 +430,336 @@ pub fn render_message_bubble(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Отображаем индикатор воспроизведения голосового
|
||||||
|
if msg.has_voice() {
|
||||||
|
if let Some(voice) = msg.voice_info() {
|
||||||
|
let is_this_playing = playback_state
|
||||||
|
.map(|ps| ps.message_id == msg.id())
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
let status_line = if is_this_playing {
|
||||||
|
let ps = playback_state.unwrap();
|
||||||
|
let icon = match ps.status {
|
||||||
|
PlaybackStatus::Playing => "▶",
|
||||||
|
PlaybackStatus::Paused => "⏸",
|
||||||
|
PlaybackStatus::Loading => "⏳",
|
||||||
|
_ => "⏹",
|
||||||
|
};
|
||||||
|
let bar = render_progress_bar(ps.position, ps.duration, 20);
|
||||||
|
format!("{} {} {:.0}s/{:.0}s", icon, bar, ps.position, ps.duration)
|
||||||
|
} else {
|
||||||
|
let waveform = render_waveform(&voice.waveform, 20);
|
||||||
|
format!(" {} {:.0}s", waveform, voice.duration)
|
||||||
|
};
|
||||||
|
|
||||||
|
let status_len = status_line.chars().count();
|
||||||
|
if msg.is_outgoing() {
|
||||||
|
let padding = content_width.saturating_sub(status_len + 1);
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::raw(" ".repeat(padding)),
|
||||||
|
Span::styled(status_line, Style::default().fg(Color::Cyan)),
|
||||||
|
]));
|
||||||
|
} else {
|
||||||
|
lines.push(Line::from(Span::styled(status_line, Style::default().fg(Color::Cyan))));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отображаем статус фото (если есть)
|
||||||
|
#[cfg(feature = "images")]
|
||||||
|
if let Some(photo) = msg.photo_info() {
|
||||||
|
match &photo.download_state {
|
||||||
|
PhotoDownloadState::Downloading => {
|
||||||
|
let status = "📷 ⏳ Загрузка...";
|
||||||
|
if msg.is_outgoing() {
|
||||||
|
let padding = content_width.saturating_sub(status.chars().count() + 1);
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::raw(" ".repeat(padding)),
|
||||||
|
Span::styled(status, Style::default().fg(Color::Yellow)),
|
||||||
|
]));
|
||||||
|
} else {
|
||||||
|
lines
|
||||||
|
.push(Line::from(Span::styled(status, Style::default().fg(Color::Yellow))));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PhotoDownloadState::Error(e) => {
|
||||||
|
let status = format!("📷 [Ошибка: {}]", e);
|
||||||
|
if msg.is_outgoing() {
|
||||||
|
let padding = content_width.saturating_sub(status.chars().count() + 1);
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::raw(" ".repeat(padding)),
|
||||||
|
Span::styled(status, Style::default().fg(Color::Red)),
|
||||||
|
]));
|
||||||
|
} else {
|
||||||
|
lines.push(Line::from(Span::styled(status, Style::default().fg(Color::Red))));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PhotoDownloadState::Downloaded(_) => {
|
||||||
|
// Всегда показываем inline превью для загруженных фото
|
||||||
|
let inline_width = content_width.min(crate::constants::INLINE_IMAGE_MAX_WIDTH);
|
||||||
|
let img_height = calculate_image_height(photo.width, photo.height, inline_width);
|
||||||
|
for _ in 0..img_height {
|
||||||
|
lines.push(Line::from(""));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PhotoDownloadState::NotDownloaded => {
|
||||||
|
// Для незагруженных фото ничего не рендерим,
|
||||||
|
// текст сообщения уже содержит 📷 prefix
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
lines
|
lines
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Информация для отложенного рендеринга изображения поверх placeholder
|
||||||
|
#[cfg(feature = "images")]
|
||||||
|
pub struct DeferredImageRender {
|
||||||
|
pub message_id: MessageId,
|
||||||
|
/// Путь к файлу изображения
|
||||||
|
pub photo_path: String,
|
||||||
|
/// Смещение в строках от начала всего списка сообщений
|
||||||
|
pub line_offset: usize,
|
||||||
|
/// Горизонтальное смещение от левого края контента (для сетки альбомов)
|
||||||
|
pub x_offset: u16,
|
||||||
|
pub width: u16,
|
||||||
|
pub height: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Рендерит bubble для альбома (группы фото с общим media_album_id)
|
||||||
|
///
|
||||||
|
/// Фото отображаются в сетке (до 3 в ряд), с общей подписью и timestamp.
|
||||||
|
#[cfg(feature = "images")]
|
||||||
|
pub fn render_album_bubble(
|
||||||
|
messages: &[MessageInfo],
|
||||||
|
config: &Config,
|
||||||
|
content_width: usize,
|
||||||
|
selected_msg_id: Option<MessageId>,
|
||||||
|
) -> (Vec<Line<'static>>, Vec<DeferredImageRender>) {
|
||||||
|
use crate::constants::{
|
||||||
|
ALBUM_GRID_MAX_COLS, ALBUM_PHOTO_GAP, ALBUM_PHOTO_HEIGHT, ALBUM_PHOTO_WIDTH,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||||
|
let mut deferred: Vec<DeferredImageRender> = Vec::new();
|
||||||
|
|
||||||
|
let is_selected = messages.iter().any(|m| selected_msg_id == Some(m.id()));
|
||||||
|
let is_outgoing = messages.first().is_some_and(|m| m.is_outgoing());
|
||||||
|
|
||||||
|
// Selection marker (всегда резервируем место)
|
||||||
|
let selection_marker = if is_selected { "▶ " } else { " " };
|
||||||
|
|
||||||
|
// Фильтруем фото
|
||||||
|
let photos: Vec<&MessageInfo> = messages.iter().filter(|m| m.has_photo()).collect();
|
||||||
|
let photo_count = photos.len();
|
||||||
|
|
||||||
|
if photo_count == 0 {
|
||||||
|
// Нет фото — рендерим как обычные сообщения
|
||||||
|
for msg in messages {
|
||||||
|
lines.extend(render_message_bubble(msg, config, content_width, selected_msg_id, None));
|
||||||
|
}
|
||||||
|
return (lines, deferred);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grid layout
|
||||||
|
let cols = photo_count.min(ALBUM_GRID_MAX_COLS);
|
||||||
|
let rows = photo_count.div_ceil(cols);
|
||||||
|
|
||||||
|
// Добавляем маркер выбора на первую строку (всегда — для постоянного отступа)
|
||||||
|
lines.push(Line::from(vec![Span::styled(
|
||||||
|
selection_marker,
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Yellow)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
)]));
|
||||||
|
|
||||||
|
let grid_start_line = lines.len();
|
||||||
|
|
||||||
|
// Генерируем placeholder-строки для сетки
|
||||||
|
for row in 0..rows {
|
||||||
|
for line_in_row in 0..ALBUM_PHOTO_HEIGHT {
|
||||||
|
let mut spans = Vec::new();
|
||||||
|
|
||||||
|
// Для исходящих — добавляем отступ справа
|
||||||
|
if is_outgoing {
|
||||||
|
let grid_width = cols as u16 * ALBUM_PHOTO_WIDTH
|
||||||
|
+ (cols as u16).saturating_sub(1) * ALBUM_PHOTO_GAP;
|
||||||
|
let padding = content_width.saturating_sub(grid_width as usize + 1);
|
||||||
|
spans.push(Span::raw(" ".repeat(padding)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Для каждого столбца в этом ряду
|
||||||
|
for col in 0..cols {
|
||||||
|
let photo_idx = row * cols + col;
|
||||||
|
if photo_idx >= photo_count {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let msg = photos[photo_idx];
|
||||||
|
if let Some(photo) = msg.photo_info() {
|
||||||
|
match &photo.download_state {
|
||||||
|
PhotoDownloadState::Downloaded(path) => {
|
||||||
|
if line_in_row == 0 {
|
||||||
|
// Регистрируем deferred render для этого фото
|
||||||
|
let x_off = if is_outgoing {
|
||||||
|
let grid_width = cols as u16 * ALBUM_PHOTO_WIDTH
|
||||||
|
+ (cols as u16).saturating_sub(1) * ALBUM_PHOTO_GAP;
|
||||||
|
let padding = content_width
|
||||||
|
.saturating_sub(grid_width as usize + 1)
|
||||||
|
as u16;
|
||||||
|
padding + col as u16 * (ALBUM_PHOTO_WIDTH + ALBUM_PHOTO_GAP)
|
||||||
|
} else {
|
||||||
|
col as u16 * (ALBUM_PHOTO_WIDTH + ALBUM_PHOTO_GAP)
|
||||||
|
};
|
||||||
|
|
||||||
|
deferred.push(DeferredImageRender {
|
||||||
|
message_id: msg.id(),
|
||||||
|
photo_path: path.clone(),
|
||||||
|
line_offset: grid_start_line
|
||||||
|
+ row * ALBUM_PHOTO_HEIGHT as usize,
|
||||||
|
x_offset: x_off,
|
||||||
|
width: ALBUM_PHOTO_WIDTH,
|
||||||
|
height: ALBUM_PHOTO_HEIGHT,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Пустая строка — placeholder для изображения
|
||||||
|
}
|
||||||
|
PhotoDownloadState::Downloading => {
|
||||||
|
if line_in_row == ALBUM_PHOTO_HEIGHT / 2 {
|
||||||
|
spans.push(Span::styled(
|
||||||
|
"⏳ Загрузка...",
|
||||||
|
Style::default().fg(Color::Yellow),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PhotoDownloadState::Error(e) => {
|
||||||
|
if line_in_row == ALBUM_PHOTO_HEIGHT / 2 {
|
||||||
|
let err_text: String = e.chars().take(14).collect();
|
||||||
|
spans.push(Span::styled(
|
||||||
|
format!("❌ {}", err_text),
|
||||||
|
Style::default().fg(Color::Red),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PhotoDownloadState::NotDownloaded => {
|
||||||
|
if line_in_row == ALBUM_PHOTO_HEIGHT / 2 {
|
||||||
|
spans.push(Span::styled("📷", Style::default().fg(Color::Gray)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(Line::from(spans));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Caption: собираем непустые тексты (без "📷 [Фото]" prefix)
|
||||||
|
let captions: Vec<&str> = messages
|
||||||
|
.iter()
|
||||||
|
.map(|m| m.text())
|
||||||
|
.filter(|t| !t.is_empty() && !t.starts_with("📷"))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let msg_color = if is_selected {
|
||||||
|
config.parse_color(&config.colors.selected_message)
|
||||||
|
} else if is_outgoing {
|
||||||
|
config.parse_color(&config.colors.outgoing_message)
|
||||||
|
} else {
|
||||||
|
config.parse_color(&config.colors.incoming_message)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Timestamp из последнего сообщения
|
||||||
|
let last_msg = messages.last().unwrap();
|
||||||
|
let time = format_timestamp_with_tz(last_msg.date(), &config.general.timezone);
|
||||||
|
|
||||||
|
if !captions.is_empty() {
|
||||||
|
let caption_text = captions.join(" ");
|
||||||
|
let time_suffix = format!(" ({})", time);
|
||||||
|
|
||||||
|
if is_outgoing {
|
||||||
|
let total_len = caption_text.chars().count() + time_suffix.chars().count();
|
||||||
|
let padding = content_width.saturating_sub(total_len + 1);
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::raw(" ".repeat(padding)),
|
||||||
|
Span::styled(caption_text, Style::default().fg(msg_color)),
|
||||||
|
Span::styled(time_suffix, Style::default().fg(Color::Gray)),
|
||||||
|
]));
|
||||||
|
} else {
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::styled(format!(" ({})", time), Style::default().fg(Color::Gray)),
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled(caption_text, Style::default().fg(msg_color)),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Без подписи — только timestamp
|
||||||
|
let time_text = format!("({})", time);
|
||||||
|
if is_outgoing {
|
||||||
|
let padding = content_width.saturating_sub(time_text.chars().count() + 1);
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::raw(" ".repeat(padding)),
|
||||||
|
Span::styled(time_text, Style::default().fg(Color::Gray)),
|
||||||
|
]));
|
||||||
|
} else {
|
||||||
|
lines.push(Line::from(vec![Span::styled(
|
||||||
|
format!(" {}", time_text),
|
||||||
|
Style::default().fg(Color::Gray),
|
||||||
|
)]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(lines, deferred)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Вычисляет высоту изображения (в строках) с учётом пропорций
|
||||||
|
#[cfg(feature = "images")]
|
||||||
|
pub fn calculate_image_height(img_width: i32, img_height: i32, content_width: usize) -> u16 {
|
||||||
|
use crate::constants::{MAX_IMAGE_HEIGHT, MAX_IMAGE_WIDTH, MIN_IMAGE_HEIGHT};
|
||||||
|
|
||||||
|
let display_width = (content_width as u16).min(MAX_IMAGE_WIDTH);
|
||||||
|
let aspect = img_height as f64 / img_width as f64;
|
||||||
|
// Терминальные символы ~2:1 по высоте, компенсируем
|
||||||
|
let raw_height = (display_width as f64 * aspect * 0.5) as u16;
|
||||||
|
raw_height.clamp(MIN_IMAGE_HEIGHT, MAX_IMAGE_HEIGHT)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Рендерит progress bar для воспроизведения
|
||||||
|
fn render_progress_bar(position: f32, duration: f32, width: usize) -> String {
|
||||||
|
if duration <= 0.0 {
|
||||||
|
return "─".repeat(width);
|
||||||
|
}
|
||||||
|
let ratio = (position / duration).clamp(0.0, 1.0);
|
||||||
|
let filled = (ratio * width as f32) as usize;
|
||||||
|
let empty = width.saturating_sub(filled + 1);
|
||||||
|
format!("{}●{}", "━".repeat(filled), "─".repeat(empty))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Рендерит waveform из base64-encoded данных TDLib
|
||||||
|
fn render_waveform(waveform_b64: &str, width: usize) -> String {
|
||||||
|
const BARS: &[char] = &['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
|
||||||
|
|
||||||
|
if waveform_b64.is_empty() {
|
||||||
|
return "▁".repeat(width);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Декодируем waveform (каждый байт = амплитуда 0-255)
|
||||||
|
use base64::Engine;
|
||||||
|
let bytes = base64::engine::general_purpose::STANDARD
|
||||||
|
.decode(waveform_b64)
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
if bytes.is_empty() {
|
||||||
|
return "▁".repeat(width);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сэмплируем до нужной ширины
|
||||||
|
let mut result = String::with_capacity(width * 4);
|
||||||
|
for i in 0..width {
|
||||||
|
let byte_idx = i * bytes.len() / width;
|
||||||
|
let amplitude = bytes.get(byte_idx).copied().unwrap_or(0);
|
||||||
|
let bar_idx = (amplitude as usize * (BARS.len() - 1)) / 255;
|
||||||
|
result.push(BARS[bar_idx]);
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|||||||
117
src/ui/components/message_list.rs
Normal file
117
src/ui/components/message_list.rs
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
//! Shared message list rendering for search and pinned modals
|
||||||
|
|
||||||
|
use crate::tdlib::MessageInfo;
|
||||||
|
use ratatui::{
|
||||||
|
layout::Alignment,
|
||||||
|
style::{Color, Modifier, Style},
|
||||||
|
text::{Line, Span},
|
||||||
|
widgets::{Block, Borders, Paragraph},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Renders a single message item with marker, sender, date, and wrapped text
|
||||||
|
pub fn render_message_item(
|
||||||
|
msg: &MessageInfo,
|
||||||
|
is_selected: bool,
|
||||||
|
content_width: usize,
|
||||||
|
max_preview_lines: usize,
|
||||||
|
) -> Vec<Line<'static>> {
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
|
||||||
|
// Marker, sender name, and date
|
||||||
|
let marker = if is_selected { "▶ " } else { " " };
|
||||||
|
let sender_color = if msg.is_outgoing() {
|
||||||
|
Color::Green
|
||||||
|
} else {
|
||||||
|
Color::Cyan
|
||||||
|
};
|
||||||
|
let sender_name = if msg.is_outgoing() {
|
||||||
|
"Вы".to_string()
|
||||||
|
} else {
|
||||||
|
msg.sender_name().to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::styled(
|
||||||
|
marker.to_string(),
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Yellow)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
Span::styled(
|
||||||
|
format!("{} ", sender_name),
|
||||||
|
Style::default()
|
||||||
|
.fg(sender_color)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
Span::styled(
|
||||||
|
format!("({})", crate::utils::format_datetime(msg.date())),
|
||||||
|
Style::default().fg(Color::Gray),
|
||||||
|
),
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Wrapped message text
|
||||||
|
let msg_color = if is_selected {
|
||||||
|
Color::Yellow
|
||||||
|
} else {
|
||||||
|
Color::White
|
||||||
|
};
|
||||||
|
let max_width = content_width.saturating_sub(4);
|
||||||
|
let wrapped = crate::ui::messages::wrap_text_with_offsets(msg.text(), max_width);
|
||||||
|
let wrapped_count = wrapped.len();
|
||||||
|
|
||||||
|
for wrapped_line in wrapped.into_iter().take(max_preview_lines) {
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::raw(" ".to_string()),
|
||||||
|
Span::styled(wrapped_line.text, Style::default().fg(msg_color)),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
if wrapped_count > max_preview_lines {
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::raw(" ".to_string()),
|
||||||
|
Span::styled("...".to_string(), Style::default().fg(Color::Gray)),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
lines
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculates scroll offset to keep selected item visible
|
||||||
|
pub fn calculate_scroll_offset(
|
||||||
|
selected_index: usize,
|
||||||
|
lines_per_item: usize,
|
||||||
|
visible_height: u16,
|
||||||
|
) -> u16 {
|
||||||
|
let visible = visible_height.saturating_sub(2) as usize;
|
||||||
|
let selected_line = selected_index * lines_per_item;
|
||||||
|
if selected_line > visible / 2 {
|
||||||
|
(selected_line - visible / 2) as u16
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders a help bar with keyboard shortcuts
|
||||||
|
pub fn render_help_bar(
|
||||||
|
shortcuts: &[(&str, &str, Color)],
|
||||||
|
border_color: Color,
|
||||||
|
) -> Paragraph<'static> {
|
||||||
|
let mut spans: Vec<Span<'static>> = Vec::new();
|
||||||
|
for (i, (key, label, color)) in shortcuts.iter().enumerate() {
|
||||||
|
if i > 0 {
|
||||||
|
spans.push(Span::raw(" ".to_string()));
|
||||||
|
}
|
||||||
|
spans.push(Span::styled(
|
||||||
|
format!(" {} ", key),
|
||||||
|
Style::default().fg(*color).add_modifier(Modifier::BOLD),
|
||||||
|
));
|
||||||
|
spans.push(Span::raw(label.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Paragraph::new(Line::from(spans))
|
||||||
|
.block(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(Style::default().fg(border_color)),
|
||||||
|
)
|
||||||
|
.alignment(Alignment::Center)
|
||||||
|
}
|
||||||
@@ -1,13 +1,17 @@
|
|||||||
// UI компоненты для переиспользования
|
//! Reusable UI components: message bubbles, input fields, modals, lists.
|
||||||
|
|
||||||
pub mod modal;
|
|
||||||
pub mod input_field;
|
|
||||||
pub mod message_bubble;
|
|
||||||
pub mod chat_list_item;
|
pub mod chat_list_item;
|
||||||
pub mod emoji_picker;
|
pub mod emoji_picker;
|
||||||
|
pub mod input_field;
|
||||||
|
pub mod message_bubble;
|
||||||
|
pub mod message_list;
|
||||||
|
pub mod modal;
|
||||||
|
|
||||||
// Экспорт основных функций
|
// Экспорт основных функций
|
||||||
pub use input_field::render_input_field;
|
|
||||||
pub use chat_list_item::render_chat_list_item;
|
pub use chat_list_item::render_chat_list_item;
|
||||||
pub use emoji_picker::render_emoji_picker;
|
pub use emoji_picker::render_emoji_picker;
|
||||||
|
pub use input_field::render_input_field;
|
||||||
|
#[cfg(feature = "images")]
|
||||||
|
pub use message_bubble::{calculate_image_height, render_album_bubble, DeferredImageRender};
|
||||||
pub use message_bubble::{render_date_separator, render_message_bubble, render_sender_header};
|
pub use message_bubble::{render_date_separator, render_message_bubble, render_sender_header};
|
||||||
|
pub use message_list::{calculate_scroll_offset, render_help_bar, render_message_item};
|
||||||
|
|||||||
@@ -74,10 +74,7 @@ pub fn render_delete_confirm_modal(f: &mut Frame, area: Rect) {
|
|||||||
),
|
),
|
||||||
Span::raw("Да"),
|
Span::raw("Да"),
|
||||||
Span::raw(" "),
|
Span::raw(" "),
|
||||||
Span::styled(
|
Span::styled(" [n/Esc] ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
|
||||||
" [n/Esc] ",
|
|
||||||
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
|
|
||||||
),
|
|
||||||
Span::raw("Нет"),
|
Span::raw("Нет"),
|
||||||
]),
|
]),
|
||||||
];
|
];
|
||||||
|
|||||||
194
src/ui/compose_bar.rs
Normal file
194
src/ui/compose_bar.rs
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
//! Compose bar / input box rendering
|
||||||
|
|
||||||
|
use crate::app::methods::{compose::ComposeMethods, messages::MessageMethods};
|
||||||
|
use crate::app::App;
|
||||||
|
use crate::app::InputMode;
|
||||||
|
use crate::tdlib::TdClientTrait;
|
||||||
|
use crate::ui::components;
|
||||||
|
use ratatui::{
|
||||||
|
layout::Rect,
|
||||||
|
style::{Color, Modifier, Style},
|
||||||
|
text::{Line, Span},
|
||||||
|
widgets::{Block, Borders, Paragraph},
|
||||||
|
Frame,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Renders input field with cursor at the specified position
|
||||||
|
fn render_input_with_cursor(
|
||||||
|
prefix: &str,
|
||||||
|
text: &str,
|
||||||
|
cursor_pos: usize,
|
||||||
|
color: Color,
|
||||||
|
) -> Line<'static> {
|
||||||
|
components::render_input_field(prefix, text, cursor_pos, color)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders input box with support for different modes (forward/select/edit/reply/normal)
|
||||||
|
pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
|
||||||
|
let (input_line, input_title): (Line, &str) = if app.is_forwarding() {
|
||||||
|
// Режим пересылки - показываем превью сообщения
|
||||||
|
let forward_preview = app
|
||||||
|
.get_forwarding_message()
|
||||||
|
.map(|m| {
|
||||||
|
let text_preview: String = m.text().chars().take(40).collect();
|
||||||
|
let ellipsis = if m.text().chars().count() > 40 {
|
||||||
|
"..."
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
format!("↪ {}{}", text_preview, ellipsis)
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| "↪ ...".to_string());
|
||||||
|
|
||||||
|
let line = Line::from(Span::styled(forward_preview, Style::default().fg(Color::Cyan)));
|
||||||
|
(line, " Выберите чат ← ")
|
||||||
|
} else if app.is_selecting_message() {
|
||||||
|
// Режим выбора сообщения - подсказка зависит от возможностей
|
||||||
|
let selected_msg = app.get_selected_message();
|
||||||
|
let can_edit = selected_msg
|
||||||
|
.as_ref()
|
||||||
|
.map(|m| m.can_be_edited() && m.is_outgoing())
|
||||||
|
.unwrap_or(false);
|
||||||
|
let can_delete = selected_msg
|
||||||
|
.as_ref()
|
||||||
|
.map(|m| m.can_be_deleted_only_for_self() || m.can_be_deleted_for_all_users())
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
let hint = match (can_edit, can_delete) {
|
||||||
|
(true, true) => "↑↓ · Enter ред. · r ответ · f перслть · y копир. · d удал. · Esc",
|
||||||
|
(true, false) => "↑↓ · Enter ред. · r ответ · f переслть · y копир. · Esc",
|
||||||
|
(false, true) => "↑↓ · r ответ · f переслать · y копир. · d удалить · Esc",
|
||||||
|
(false, false) => "↑↓ · r ответить · f переслать · y копировать · Esc",
|
||||||
|
};
|
||||||
|
(
|
||||||
|
Line::from(Span::styled(hint, Style::default().fg(Color::Cyan))),
|
||||||
|
" Выбор сообщения ",
|
||||||
|
)
|
||||||
|
} else if app.is_editing() {
|
||||||
|
// Режим редактирования
|
||||||
|
if app.message_input.is_empty() {
|
||||||
|
let line = Line::from(vec![
|
||||||
|
Span::raw("✏ "),
|
||||||
|
Span::styled("█", Style::default().fg(Color::Magenta)),
|
||||||
|
Span::styled(" Введите новый текст...", Style::default().fg(Color::Gray)),
|
||||||
|
]);
|
||||||
|
(line, " Редактирование (Esc отмена) ")
|
||||||
|
} else {
|
||||||
|
let line = render_input_with_cursor(
|
||||||
|
"✏ ",
|
||||||
|
&app.message_input,
|
||||||
|
app.cursor_position,
|
||||||
|
Color::Magenta,
|
||||||
|
);
|
||||||
|
(line, " Редактирование (Esc отмена) ")
|
||||||
|
}
|
||||||
|
} else if app.is_replying() {
|
||||||
|
// Режим ответа на сообщение
|
||||||
|
let reply_preview = app
|
||||||
|
.get_replying_to_message()
|
||||||
|
.map(|m| {
|
||||||
|
let sender = if m.is_outgoing() {
|
||||||
|
"Вы"
|
||||||
|
} else {
|
||||||
|
m.sender_name()
|
||||||
|
};
|
||||||
|
let text_preview: String = m.text().chars().take(30).collect();
|
||||||
|
let ellipsis = if m.text().chars().count() > 30 {
|
||||||
|
"..."
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
format!("{}: {}{}", sender, text_preview, ellipsis)
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| "...".to_string());
|
||||||
|
|
||||||
|
if app.message_input.is_empty() {
|
||||||
|
let line = Line::from(vec![
|
||||||
|
Span::styled("↪ ", Style::default().fg(Color::Cyan)),
|
||||||
|
Span::styled(reply_preview, Style::default().fg(Color::Gray)),
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled("█", Style::default().fg(Color::Yellow)),
|
||||||
|
]);
|
||||||
|
(line, " Ответ (Esc отмена) ")
|
||||||
|
} else {
|
||||||
|
let short_preview: String = reply_preview.chars().take(15).collect();
|
||||||
|
let prefix = format!("↪ {} > ", short_preview);
|
||||||
|
let line = render_input_with_cursor(
|
||||||
|
&prefix,
|
||||||
|
&app.message_input,
|
||||||
|
app.cursor_position,
|
||||||
|
Color::Yellow,
|
||||||
|
);
|
||||||
|
(line, " Ответ (Esc отмена) ")
|
||||||
|
}
|
||||||
|
} else if app.input_mode == InputMode::Normal {
|
||||||
|
// Normal mode — dim, no cursor
|
||||||
|
if app.message_input.is_empty() {
|
||||||
|
let line = Line::from(vec![Span::styled(
|
||||||
|
"> Press i to type...",
|
||||||
|
Style::default().fg(Color::DarkGray),
|
||||||
|
)]);
|
||||||
|
(line, "")
|
||||||
|
} else {
|
||||||
|
let draft_preview: String = app.message_input.chars().take(60).collect();
|
||||||
|
let ellipsis = if app.message_input.chars().count() > 60 {
|
||||||
|
"..."
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
let line = Line::from(Span::styled(
|
||||||
|
format!("> {}{}", draft_preview, ellipsis),
|
||||||
|
Style::default().fg(Color::DarkGray),
|
||||||
|
));
|
||||||
|
(line, "")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Insert mode — active, with cursor
|
||||||
|
if app.message_input.is_empty() {
|
||||||
|
let line = Line::from(vec![
|
||||||
|
Span::raw("> "),
|
||||||
|
Span::styled("█", Style::default().fg(Color::Yellow)),
|
||||||
|
Span::styled(" Введите сообщение...", Style::default().fg(Color::Gray)),
|
||||||
|
]);
|
||||||
|
(line, "")
|
||||||
|
} else {
|
||||||
|
let line = render_input_with_cursor(
|
||||||
|
"> ",
|
||||||
|
&app.message_input,
|
||||||
|
app.cursor_position,
|
||||||
|
Color::Yellow,
|
||||||
|
);
|
||||||
|
(line, "")
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let input_block = if input_title.is_empty() {
|
||||||
|
let border_style = if app.input_mode == InputMode::Insert {
|
||||||
|
Style::default().fg(Color::Green)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(Color::DarkGray)
|
||||||
|
};
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(border_style)
|
||||||
|
} else {
|
||||||
|
let title_color = if app.is_replying() || app.is_forwarding() {
|
||||||
|
Color::Cyan
|
||||||
|
} else {
|
||||||
|
Color::Magenta
|
||||||
|
};
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.title(input_title)
|
||||||
|
.title_style(
|
||||||
|
Style::default()
|
||||||
|
.fg(title_color)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
let input = Paragraph::new(input_line)
|
||||||
|
.block(input_block)
|
||||||
|
.wrap(ratatui::widgets::Wrap { trim: false });
|
||||||
|
f.render_widget(input, area);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
use crate::app::App;
|
use crate::app::App;
|
||||||
use crate::tdlib::TdClientTrait;
|
use crate::app::InputMode;
|
||||||
use crate::tdlib::NetworkState;
|
use crate::tdlib::NetworkState;
|
||||||
|
use crate::tdlib::TdClientTrait;
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
layout::Rect,
|
layout::Rect,
|
||||||
style::{Color, Style},
|
style::{Color, Style},
|
||||||
@@ -18,18 +19,27 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
|
|||||||
NetworkState::Updating => "⏳ Обновление... | ",
|
NetworkState::Updating => "⏳ Обновление... | ",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let account_indicator = format!("[{}] ", app.current_account_name);
|
||||||
|
|
||||||
let status = if let Some(msg) = &app.status_message {
|
let status = if let Some(msg) = &app.status_message {
|
||||||
format!(" {}{} ", network_indicator, msg)
|
format!(" {}{}{} ", account_indicator, network_indicator, msg)
|
||||||
} else if let Some(err) = &app.error_message {
|
} else if let Some(err) = &app.error_message {
|
||||||
format!(" {}Error: {} ", network_indicator, err)
|
format!(" {}{}Error: {} ", account_indicator, network_indicator, err)
|
||||||
} else if app.is_searching {
|
} else if app.is_searching {
|
||||||
format!(" {}↑/↓: Navigate | Enter: Select | Esc: Cancel ", network_indicator)
|
format!(
|
||||||
|
" {}{}↑/↓: Navigate | Enter: Select | Esc: Cancel ",
|
||||||
|
account_indicator, network_indicator
|
||||||
|
)
|
||||||
} else if app.selected_chat_id.is_some() {
|
} else if app.selected_chat_id.is_some() {
|
||||||
format!(" {}↑/↓: Scroll | Ctrl+U: Profile | Enter: Send | Esc: Close | Ctrl+R: Refresh | Ctrl+C: Quit ", network_indicator)
|
let mode_str = match app.input_mode {
|
||||||
|
InputMode::Normal => "[NORMAL] j/k: Nav | i: Insert | d/r/f/y: Actions | Esc: Close",
|
||||||
|
InputMode::Insert => "[INSERT] Type message | Esc: Normal mode",
|
||||||
|
};
|
||||||
|
format!(" {}{}{} | Ctrl+C: Quit ", account_indicator, network_indicator, mode_str)
|
||||||
} else {
|
} else {
|
||||||
format!(
|
format!(
|
||||||
" {}↑/↓: Navigate | Enter: Open | Ctrl+S: Search | Ctrl+R: Refresh | Ctrl+C: Quit ",
|
" {}{}↑/↓: Navigate | Enter: Open | Ctrl+S: Search | Ctrl+R: Refresh | Ctrl+C: Quit ",
|
||||||
network_indicator
|
account_indicator, network_indicator
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -42,7 +52,7 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
|
|||||||
} else if app.status_message.is_some() {
|
} else if app.status_message.is_some() {
|
||||||
Style::default().fg(Color::Yellow)
|
Style::default().fg(Color::Yellow)
|
||||||
} else {
|
} else {
|
||||||
Style::default().fg(Color::DarkGray)
|
Style::default().fg(Color::Rgb(160, 160, 160))
|
||||||
};
|
};
|
||||||
|
|
||||||
let footer = Paragraph::new(status).style(style);
|
let footer = Paragraph::new(status).style(style);
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
|
//! Chat message area rendering.
|
||||||
|
//!
|
||||||
|
//! Renders message bubbles grouped by date/sender, pinned bar, and delegates
|
||||||
|
//! to modals (search, pinned, reactions, delete) and compose_bar.
|
||||||
|
|
||||||
|
use crate::app::methods::{messages::MessageMethods, modal::ModalMethods, search::SearchMethods};
|
||||||
use crate::app::App;
|
use crate::app::App;
|
||||||
use crate::tdlib::TdClientTrait;
|
|
||||||
use crate::message_grouping::{group_messages, MessageGroup};
|
use crate::message_grouping::{group_messages, MessageGroup};
|
||||||
|
use crate::tdlib::TdClientTrait;
|
||||||
use crate::ui::components;
|
use crate::ui::components;
|
||||||
|
use crate::ui::{compose_bar, modals};
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||||
style::{Color, Modifier, Style},
|
style::{Color, Modifier, Style},
|
||||||
@@ -11,7 +18,12 @@ use ratatui::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
/// Рендерит заголовок чата с typing status
|
/// Рендерит заголовок чата с typing status
|
||||||
fn render_chat_header<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>, chat: &crate::tdlib::ChatInfo) {
|
fn render_chat_header<T: TdClientTrait>(
|
||||||
|
f: &mut Frame,
|
||||||
|
area: Rect,
|
||||||
|
app: &App<T>,
|
||||||
|
chat: &crate::tdlib::ChatInfo,
|
||||||
|
) {
|
||||||
let typing_action = app
|
let typing_action = app
|
||||||
.td_client
|
.td_client
|
||||||
.typing_status()
|
.typing_status()
|
||||||
@@ -27,10 +39,7 @@ fn render_chat_header<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>,
|
|||||||
.add_modifier(Modifier::BOLD),
|
.add_modifier(Modifier::BOLD),
|
||||||
)];
|
)];
|
||||||
if let Some(username) = &chat.username {
|
if let Some(username) = &chat.username {
|
||||||
spans.push(Span::styled(
|
spans.push(Span::styled(format!(" {}", username), Style::default().fg(Color::Gray)));
|
||||||
format!(" {}", username),
|
|
||||||
Style::default().fg(Color::Gray),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
spans.push(Span::styled(
|
spans.push(Span::styled(
|
||||||
format!(" {}", action),
|
format!(" {}", action),
|
||||||
@@ -83,33 +92,20 @@ fn render_pinned_bar<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>)
|
|||||||
Span::raw(" ".repeat(padding)),
|
Span::raw(" ".repeat(padding)),
|
||||||
Span::styled(pinned_hint, Style::default().fg(Color::Gray)),
|
Span::styled(pinned_hint, Style::default().fg(Color::Gray)),
|
||||||
]);
|
]);
|
||||||
let pinned_bar =
|
let pinned_bar = Paragraph::new(pinned_line).style(Style::default().bg(Color::Rgb(40, 20, 40)));
|
||||||
Paragraph::new(pinned_line).style(Style::default().bg(Color::Rgb(40, 20, 40)));
|
|
||||||
f.render_widget(pinned_bar, area);
|
f.render_widget(pinned_bar, area);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_input_with_cursor(
|
|
||||||
prefix: &str,
|
|
||||||
text: &str,
|
|
||||||
cursor_pos: usize,
|
|
||||||
color: Color,
|
|
||||||
) -> Line<'static> {
|
|
||||||
// Используем компонент input_field
|
|
||||||
components::render_input_field(prefix, text, cursor_pos, color)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Информация о строке после переноса: текст и позиция в оригинале
|
/// Информация о строке после переноса: текст и позиция в оригинале
|
||||||
struct WrappedLine {
|
pub(super) struct WrappedLine {
|
||||||
text: String,
|
pub text: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Разбивает текст на строки с учётом максимальной ширины
|
/// Разбивает текст на строки с учётом максимальной ширины
|
||||||
/// (используется только для search/pinned режимов, основной рендеринг через message_bubble)
|
/// (используется только для search/pinned режимов, основной рендеринг через message_bubble)
|
||||||
fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
|
pub(super) fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
|
||||||
if max_width == 0 {
|
if max_width == 0 {
|
||||||
return vec![WrappedLine {
|
return vec![WrappedLine { text: text.to_string() }];
|
||||||
text: text.to_string(),
|
|
||||||
}];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut result = Vec::new();
|
let mut result = Vec::new();
|
||||||
@@ -134,9 +130,7 @@ fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
|
|||||||
current_line.push_str(&word);
|
current_line.push_str(&word);
|
||||||
current_width += 1 + word_width;
|
current_width += 1 + word_width;
|
||||||
} else {
|
} else {
|
||||||
result.push(WrappedLine {
|
result.push(WrappedLine { text: current_line });
|
||||||
text: current_line,
|
|
||||||
});
|
|
||||||
current_line = word;
|
current_line = word;
|
||||||
current_width = word_width;
|
current_width = word_width;
|
||||||
}
|
}
|
||||||
@@ -158,29 +152,23 @@ fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
|
|||||||
current_line.push(' ');
|
current_line.push(' ');
|
||||||
current_line.push_str(&word);
|
current_line.push_str(&word);
|
||||||
} else {
|
} else {
|
||||||
result.push(WrappedLine {
|
result.push(WrappedLine { text: current_line });
|
||||||
text: current_line,
|
|
||||||
});
|
|
||||||
current_line = word;
|
current_line = word;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !current_line.is_empty() {
|
if !current_line.is_empty() {
|
||||||
result.push(WrappedLine {
|
result.push(WrappedLine { text: current_line });
|
||||||
text: current_line,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.is_empty() {
|
if result.is_empty() {
|
||||||
result.push(WrappedLine {
|
result.push(WrappedLine { text: String::new() });
|
||||||
text: String::new(),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
/// Рендерит список сообщений с группировкой по дате/отправителю и автоскроллом
|
/// Рендерит список сообщений с группировкой по дате/отправителю и автоскроллом
|
||||||
fn render_message_list<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
|
fn render_message_list<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &mut App<T>) {
|
||||||
let content_width = area.width.saturating_sub(2) as usize;
|
let content_width = area.width.saturating_sub(2) as usize;
|
||||||
|
|
||||||
// Messages с группировкой по дате и отправителю
|
// Messages с группировкой по дате и отправителю
|
||||||
@@ -191,6 +179,13 @@ fn render_message_list<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>
|
|||||||
// Номер строки, где начинается выбранное сообщение (для автоскролла)
|
// Номер строки, где начинается выбранное сообщение (для автоскролла)
|
||||||
let mut selected_msg_line: Option<usize> = None;
|
let mut selected_msg_line: Option<usize> = None;
|
||||||
|
|
||||||
|
// ОПТИМИЗАЦИЯ: Убрали массовый preloading всех изображений.
|
||||||
|
// Теперь загружаем только видимые изображения во втором проходе (см. ниже).
|
||||||
|
|
||||||
|
// Собираем информацию о развёрнутых изображениях (для второго прохода)
|
||||||
|
#[cfg(feature = "images")]
|
||||||
|
let mut deferred_images: Vec<components::DeferredImageRender> = Vec::new();
|
||||||
|
|
||||||
// Используем message_grouping для группировки сообщений
|
// Используем message_grouping для группировки сообщений
|
||||||
let grouped = group_messages(&app.td_client.current_chat_messages());
|
let grouped = group_messages(&app.td_client.current_chat_messages());
|
||||||
let mut is_first_date = true;
|
let mut is_first_date = true;
|
||||||
@@ -204,10 +199,7 @@ fn render_message_list<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>
|
|||||||
is_first_date = false;
|
is_first_date = false;
|
||||||
is_first_sender = true; // Сбрасываем счётчик заголовков после даты
|
is_first_sender = true; // Сбрасываем счётчик заголовков после даты
|
||||||
}
|
}
|
||||||
MessageGroup::SenderHeader {
|
MessageGroup::SenderHeader { is_outgoing, sender_name } => {
|
||||||
is_outgoing,
|
|
||||||
sender_name,
|
|
||||||
} => {
|
|
||||||
// Рендерим заголовок отправителя
|
// Рендерим заголовок отправителя
|
||||||
lines.extend(components::render_sender_header(
|
lines.extend(components::render_sender_header(
|
||||||
is_outgoing,
|
is_outgoing,
|
||||||
@@ -225,12 +217,85 @@ fn render_message_list<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Рендерим сообщение
|
// Рендерим сообщение
|
||||||
lines.extend(components::render_message_bubble(
|
let bubble_lines = components::render_message_bubble(
|
||||||
&msg,
|
&msg,
|
||||||
app.config(),
|
app.config(),
|
||||||
content_width,
|
content_width,
|
||||||
selected_msg_id,
|
selected_msg_id,
|
||||||
));
|
app.playback_state.as_ref(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Собираем deferred image renders для всех загруженных фото
|
||||||
|
#[cfg(feature = "images")]
|
||||||
|
if let Some(photo) = msg.photo_info() {
|
||||||
|
if let crate::tdlib::PhotoDownloadState::Downloaded(path) =
|
||||||
|
&photo.download_state
|
||||||
|
{
|
||||||
|
let inline_width =
|
||||||
|
content_width.min(crate::constants::INLINE_IMAGE_MAX_WIDTH);
|
||||||
|
let img_height = components::calculate_image_height(
|
||||||
|
photo.width,
|
||||||
|
photo.height,
|
||||||
|
inline_width,
|
||||||
|
);
|
||||||
|
let img_width = inline_width as u16;
|
||||||
|
let bubble_len = bubble_lines.len();
|
||||||
|
let placeholder_start = lines.len() + bubble_len - img_height as usize;
|
||||||
|
|
||||||
|
deferred_images.push(components::DeferredImageRender {
|
||||||
|
message_id: msg.id(),
|
||||||
|
photo_path: path.clone(),
|
||||||
|
line_offset: placeholder_start,
|
||||||
|
x_offset: 0,
|
||||||
|
width: img_width,
|
||||||
|
height: img_height,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.extend(bubble_lines);
|
||||||
|
}
|
||||||
|
MessageGroup::Album(album_messages) => {
|
||||||
|
#[cfg(feature = "images")]
|
||||||
|
{
|
||||||
|
let is_selected = album_messages
|
||||||
|
.iter()
|
||||||
|
.any(|m| selected_msg_id == Some(m.id()));
|
||||||
|
if is_selected {
|
||||||
|
selected_msg_line = Some(lines.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
let (bubble_lines, album_deferred) = components::render_album_bubble(
|
||||||
|
&album_messages,
|
||||||
|
app.config(),
|
||||||
|
content_width,
|
||||||
|
selected_msg_id,
|
||||||
|
);
|
||||||
|
|
||||||
|
for mut d in album_deferred {
|
||||||
|
d.line_offset += lines.len();
|
||||||
|
deferred_images.push(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.extend(bubble_lines);
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "images"))]
|
||||||
|
{
|
||||||
|
// Fallback: рендерим каждое сообщение отдельно
|
||||||
|
for msg in &album_messages {
|
||||||
|
let is_selected = selected_msg_id == Some(msg.id());
|
||||||
|
if is_selected {
|
||||||
|
selected_msg_line = Some(lines.len());
|
||||||
|
}
|
||||||
|
lines.extend(components::render_message_bubble(
|
||||||
|
msg,
|
||||||
|
app.config(),
|
||||||
|
content_width,
|
||||||
|
selected_msg_id,
|
||||||
|
app.playback_state.as_ref(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -244,11 +309,7 @@ fn render_message_list<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>
|
|||||||
let total_lines = lines.len();
|
let total_lines = lines.len();
|
||||||
|
|
||||||
// Базовый скролл (показываем последние сообщения)
|
// Базовый скролл (показываем последние сообщения)
|
||||||
let base_scroll = if total_lines > visible_height {
|
let base_scroll = total_lines.saturating_sub(visible_height);
|
||||||
total_lines - visible_height
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
};
|
|
||||||
|
|
||||||
// Если выбрано сообщение, автоскроллим к нему
|
// Если выбрано сообщение, автоскроллим к нему
|
||||||
let scroll_offset = if app.is_selecting_message() {
|
let scroll_offset = if app.is_selecting_message() {
|
||||||
@@ -275,156 +336,67 @@ fn render_message_list<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>
|
|||||||
.block(Block::default().borders(Borders::ALL))
|
.block(Block::default().borders(Borders::ALL))
|
||||||
.scroll((scroll_offset, 0));
|
.scroll((scroll_offset, 0));
|
||||||
f.render_widget(messages_widget, area);
|
f.render_widget(messages_widget, area);
|
||||||
|
|
||||||
|
// Второй проход: рендерим изображения поверх placeholder-ов
|
||||||
|
#[cfg(feature = "images")]
|
||||||
|
{
|
||||||
|
use ratatui_image::StatefulImage;
|
||||||
|
|
||||||
|
// THROTTLING: Рендерим изображения максимум 15 FPS (каждые 66ms)
|
||||||
|
let should_render_images = app
|
||||||
|
.last_image_render_time
|
||||||
|
.map(|t| t.elapsed() > std::time::Duration::from_millis(66))
|
||||||
|
.unwrap_or(true);
|
||||||
|
|
||||||
|
if !deferred_images.is_empty() && should_render_images {
|
||||||
|
let content_x = area.x + 1;
|
||||||
|
let content_y = area.y + 1;
|
||||||
|
|
||||||
|
for d in &deferred_images {
|
||||||
|
let y_in_content = d.line_offset as i32 - scroll_offset as i32;
|
||||||
|
|
||||||
|
// Пропускаем изображения, которые полностью за пределами видимости
|
||||||
|
if y_in_content < 0 || y_in_content as usize >= visible_height {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let img_y = content_y + y_in_content as u16;
|
||||||
|
let remaining_height = (content_y + visible_height as u16).saturating_sub(img_y);
|
||||||
|
|
||||||
|
// ВАЖНО: Не рендерим частично видимые изображения (убирает сжатие и мигание)
|
||||||
|
if d.height > remaining_height {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Рендерим с ПОЛНОЙ высотой (не сжимаем)
|
||||||
|
let img_rect = Rect::new(content_x + d.x_offset, img_y, d.width, d.height);
|
||||||
|
|
||||||
|
// ОПТИМИЗАЦИЯ: Загружаем только видимые изображения (не все сразу)
|
||||||
|
// Используем inline_renderer с Halfblocks для скорости
|
||||||
|
if let Some(renderer) = &mut app.inline_image_renderer {
|
||||||
|
// Загружаем только если видимо (early return если уже в кеше)
|
||||||
|
let _ = renderer.load_image(d.message_id, &d.photo_path);
|
||||||
|
|
||||||
|
if let Some(protocol) = renderer.get_protocol(&d.message_id) {
|
||||||
|
f.render_stateful_widget(StatefulImage::default(), img_rect, protocol);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляем время последнего рендеринга (для throttling)
|
||||||
|
app.last_image_render_time = Some(std::time::Instant::now());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Рендерит input box с поддержкой разных режимов (forward/select/edit/reply/normal)
|
pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &mut App<T>) {
|
||||||
fn render_input_box<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
|
// Модальное окно просмотра изображения (приоритет выше всех)
|
||||||
let (input_line, input_title) = if app.is_forwarding() {
|
#[cfg(feature = "images")]
|
||||||
// Режим пересылки - показываем превью сообщения
|
if let Some(modal_state) = app.image_modal.clone() {
|
||||||
let forward_preview = app
|
modals::render_image_viewer(f, app, &modal_state);
|
||||||
.get_forwarding_message()
|
return;
|
||||||
.map(|m| {
|
}
|
||||||
let text_preview: String = m.text().chars().take(40).collect();
|
|
||||||
let ellipsis = if m.text().chars().count() > 40 {
|
|
||||||
"..."
|
|
||||||
} else {
|
|
||||||
""
|
|
||||||
};
|
|
||||||
format!("↪ {}{}", text_preview, ellipsis)
|
|
||||||
})
|
|
||||||
.unwrap_or_else(|| "↪ ...".to_string());
|
|
||||||
|
|
||||||
let line = Line::from(Span::styled(forward_preview, Style::default().fg(Color::Cyan)));
|
|
||||||
(line, " Выберите чат ← ")
|
|
||||||
} else if app.is_selecting_message() {
|
|
||||||
// Режим выбора сообщения - подсказка зависит от возможностей
|
|
||||||
let selected_msg = app.get_selected_message();
|
|
||||||
let can_edit = selected_msg
|
|
||||||
.as_ref()
|
|
||||||
.map(|m| m.can_be_edited() && m.is_outgoing())
|
|
||||||
.unwrap_or(false);
|
|
||||||
let can_delete = selected_msg
|
|
||||||
.as_ref()
|
|
||||||
.map(|m| m.can_be_deleted_only_for_self() || m.can_be_deleted_for_all_users())
|
|
||||||
.unwrap_or(false);
|
|
||||||
|
|
||||||
let hint = match (can_edit, can_delete) {
|
|
||||||
(true, true) => "↑↓ · Enter ред. · r ответ · f перслть · y копир. · d удал. · Esc",
|
|
||||||
(true, false) => "↑↓ · Enter ред. · r ответ · f переслть · y копир. · Esc",
|
|
||||||
(false, true) => "↑↓ · r ответ · f переслать · y копир. · d удалить · Esc",
|
|
||||||
(false, false) => "↑↓ · r ответить · f переслать · y копировать · Esc",
|
|
||||||
};
|
|
||||||
(
|
|
||||||
Line::from(Span::styled(hint, Style::default().fg(Color::Cyan))),
|
|
||||||
" Выбор сообщения ",
|
|
||||||
)
|
|
||||||
} else if app.is_editing() {
|
|
||||||
// Режим редактирования
|
|
||||||
if app.message_input.is_empty() {
|
|
||||||
// Пустой инпут - показываем курсор и placeholder
|
|
||||||
let line = Line::from(vec![
|
|
||||||
Span::raw("✏ "),
|
|
||||||
Span::styled("█", Style::default().fg(Color::Magenta)),
|
|
||||||
Span::styled(" Введите новый текст...", Style::default().fg(Color::Gray)),
|
|
||||||
]);
|
|
||||||
(line, " Редактирование (Esc отмена) ")
|
|
||||||
} else {
|
|
||||||
// Текст с курсором
|
|
||||||
let line = render_input_with_cursor(
|
|
||||||
"✏ ",
|
|
||||||
&app.message_input,
|
|
||||||
app.cursor_position,
|
|
||||||
Color::Magenta,
|
|
||||||
);
|
|
||||||
(line, " Редактирование (Esc отмена) ")
|
|
||||||
}
|
|
||||||
} else if app.is_replying() {
|
|
||||||
// Режим ответа на сообщение
|
|
||||||
let reply_preview = app
|
|
||||||
.get_replying_to_message()
|
|
||||||
.map(|m| {
|
|
||||||
let sender = if m.is_outgoing() {
|
|
||||||
"Вы"
|
|
||||||
} else {
|
|
||||||
m.sender_name()
|
|
||||||
};
|
|
||||||
let text_preview: String = m.text().chars().take(30).collect();
|
|
||||||
let ellipsis = if m.text().chars().count() > 30 {
|
|
||||||
"..."
|
|
||||||
} else {
|
|
||||||
""
|
|
||||||
};
|
|
||||||
format!("{}: {}{}", sender, text_preview, ellipsis)
|
|
||||||
})
|
|
||||||
.unwrap_or_else(|| "...".to_string());
|
|
||||||
|
|
||||||
if app.message_input.is_empty() {
|
|
||||||
let line = Line::from(vec![
|
|
||||||
Span::styled("↪ ", Style::default().fg(Color::Cyan)),
|
|
||||||
Span::styled(reply_preview, Style::default().fg(Color::Gray)),
|
|
||||||
Span::raw(" "),
|
|
||||||
Span::styled("█", Style::default().fg(Color::Yellow)),
|
|
||||||
]);
|
|
||||||
(line, " Ответ (Esc отмена) ")
|
|
||||||
} else {
|
|
||||||
let short_preview: String = reply_preview.chars().take(15).collect();
|
|
||||||
let prefix = format!("↪ {} > ", short_preview);
|
|
||||||
let line = render_input_with_cursor(
|
|
||||||
&prefix,
|
|
||||||
&app.message_input,
|
|
||||||
app.cursor_position,
|
|
||||||
Color::Yellow,
|
|
||||||
);
|
|
||||||
(line, " Ответ (Esc отмена) ")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Обычный режим
|
|
||||||
if app.message_input.is_empty() {
|
|
||||||
// Пустой инпут - показываем курсор и placeholder
|
|
||||||
let line = Line::from(vec![
|
|
||||||
Span::raw("> "),
|
|
||||||
Span::styled("█", Style::default().fg(Color::Yellow)),
|
|
||||||
Span::styled(" Введите сообщение...", Style::default().fg(Color::Gray)),
|
|
||||||
]);
|
|
||||||
(line, "")
|
|
||||||
} else {
|
|
||||||
// Текст с курсором
|
|
||||||
let line = render_input_with_cursor(
|
|
||||||
"> ",
|
|
||||||
&app.message_input,
|
|
||||||
app.cursor_position,
|
|
||||||
Color::Yellow,
|
|
||||||
);
|
|
||||||
(line, "")
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let input_block = if input_title.is_empty() {
|
|
||||||
Block::default().borders(Borders::ALL)
|
|
||||||
} else {
|
|
||||||
let title_color = if app.is_replying() || app.is_forwarding() {
|
|
||||||
Color::Cyan
|
|
||||||
} else {
|
|
||||||
Color::Magenta
|
|
||||||
};
|
|
||||||
Block::default()
|
|
||||||
.borders(Borders::ALL)
|
|
||||||
.title(input_title)
|
|
||||||
.title_style(
|
|
||||||
Style::default()
|
|
||||||
.fg(title_color)
|
|
||||||
.add_modifier(Modifier::BOLD),
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
let input = Paragraph::new(input_line)
|
|
||||||
.block(input_block)
|
|
||||||
.wrap(ratatui::widgets::Wrap { trim: false });
|
|
||||||
f.render_widget(input, area);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
|
|
||||||
// Режим профиля
|
// Режим профиля
|
||||||
if app.is_profile_mode() {
|
if app.is_profile_mode() {
|
||||||
if let Some(profile) = app.get_profile_info() {
|
if let Some(profile) = app.get_profile_info() {
|
||||||
@@ -435,27 +407,27 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
|
|||||||
|
|
||||||
// Режим поиска по сообщениям
|
// Режим поиска по сообщениям
|
||||||
if app.is_message_search_mode() {
|
if app.is_message_search_mode() {
|
||||||
render_search_mode(f, area, app);
|
modals::render_search(f, area, app);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Режим просмотра закреплённых сообщений
|
// Режим просмотра закреплённых сообщений
|
||||||
if app.is_pinned_mode() {
|
if app.is_pinned_mode() {
|
||||||
render_pinned_mode(f, area, app);
|
modals::render_pinned(f, area, app);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(chat) = app.get_selected_chat() {
|
if let Some(chat) = app.get_selected_chat().cloned() {
|
||||||
// Вычисляем динамическую высоту инпута на основе длины текста
|
// Вычисляем динамическую высоту инпута на основе длины текста
|
||||||
let input_width = area.width.saturating_sub(4) as usize; // -2 для рамок, -2 для "> "
|
let input_width = area.width.saturating_sub(4) as usize; // -2 для рамок, -2 для "> "
|
||||||
let input_text_len = app.message_input.chars().count() + 2; // +2 для "> "
|
let input_lines: u16 = if input_width > 0 {
|
||||||
let input_lines = if input_width > 0 {
|
let len = app.message_input.chars().count() + 2; // +2 для "> "
|
||||||
((input_text_len as f32 / input_width as f32).ceil() as u16).max(1)
|
((len as f32 / input_width as f32).ceil() as u16).max(1)
|
||||||
} else {
|
} else {
|
||||||
1
|
1
|
||||||
};
|
};
|
||||||
// Минимум 3 строки (1 контент + 2 рамки), максимум 10
|
// Минимум 3 строки (1 контент + 2 рамки), максимум 10
|
||||||
let input_height = (input_lines + 2).min(10).max(3);
|
let input_height = (input_lines + 2).clamp(3, 10);
|
||||||
|
|
||||||
// Проверяем, есть ли закреплённое сообщение
|
// Проверяем, есть ли закреплённое сообщение
|
||||||
let has_pinned = app.td_client.current_pinned_message().is_some();
|
let has_pinned = app.td_client.current_pinned_message().is_some();
|
||||||
@@ -483,7 +455,7 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Chat header с typing status
|
// Chat header с typing status
|
||||||
render_chat_header(f, message_chunks[0], app, chat);
|
render_chat_header(f, message_chunks[0], app, &chat);
|
||||||
|
|
||||||
// Pinned bar (если есть закреплённое сообщение)
|
// Pinned bar (если есть закреплённое сообщение)
|
||||||
render_pinned_bar(f, message_chunks[1], app);
|
render_pinned_bar(f, message_chunks[1], app);
|
||||||
@@ -492,7 +464,7 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
|
|||||||
render_message_list(f, message_chunks[2], app);
|
render_message_list(f, message_chunks[2], app);
|
||||||
|
|
||||||
// Input box с wrap для длинного текста и блочным курсором
|
// Input box с wrap для длинного текста и блочным курсором
|
||||||
render_input_box(f, message_chunks[3], app);
|
compose_bar::render(f, message_chunks[3], app);
|
||||||
} else {
|
} else {
|
||||||
let empty = Paragraph::new("Выберите чат")
|
let empty = Paragraph::new("Выберите чат")
|
||||||
.block(Block::default().borders(Borders::ALL))
|
.block(Block::default().borders(Borders::ALL))
|
||||||
@@ -503,391 +475,13 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
|
|||||||
|
|
||||||
// Модалка подтверждения удаления
|
// Модалка подтверждения удаления
|
||||||
if app.is_confirm_delete_shown() {
|
if app.is_confirm_delete_shown() {
|
||||||
render_delete_confirm_modal(f, area);
|
modals::render_delete_confirm(f, area);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Модалка выбора реакции
|
// Модалка выбора реакции
|
||||||
if let crate::app::ChatState::ReactionPicker {
|
if let crate::app::ChatState::ReactionPicker { available_reactions, selected_index, .. } =
|
||||||
available_reactions,
|
&app.chat_state
|
||||||
selected_index,
|
|
||||||
..
|
|
||||||
} = &app.chat_state
|
|
||||||
{
|
{
|
||||||
render_reaction_picker_modal(f, area, available_reactions, *selected_index);
|
modals::render_reaction_picker(f, area, available_reactions, *selected_index);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Рендерит режим поиска по сообщениям
|
|
||||||
fn render_search_mode<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
|
|
||||||
// Извлекаем данные из ChatState
|
|
||||||
let (query, results, selected_index) =
|
|
||||||
if let crate::app::ChatState::SearchInChat {
|
|
||||||
query,
|
|
||||||
results,
|
|
||||||
selected_index,
|
|
||||||
} = &app.chat_state
|
|
||||||
{
|
|
||||||
(query.as_str(), results.as_slice(), *selected_index)
|
|
||||||
} else {
|
|
||||||
return; // Некорректное состояние, не рендерим
|
|
||||||
};
|
|
||||||
|
|
||||||
let chunks = Layout::default()
|
|
||||||
.direction(Direction::Vertical)
|
|
||||||
.constraints([
|
|
||||||
Constraint::Length(3), // Search input
|
|
||||||
Constraint::Min(0), // Search results
|
|
||||||
Constraint::Length(3), // Help bar
|
|
||||||
])
|
|
||||||
.split(area);
|
|
||||||
|
|
||||||
// Search input
|
|
||||||
let total = results.len();
|
|
||||||
let current = if total > 0 {
|
|
||||||
selected_index + 1
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
};
|
|
||||||
|
|
||||||
let input_line = if query.is_empty() {
|
|
||||||
Line::from(vec![
|
|
||||||
Span::styled("🔍 ", Style::default().fg(Color::Yellow)),
|
|
||||||
Span::styled("█", Style::default().fg(Color::Yellow)),
|
|
||||||
Span::styled(" Введите текст для поиска...", Style::default().fg(Color::Gray)),
|
|
||||||
])
|
|
||||||
} else {
|
|
||||||
Line::from(vec![
|
|
||||||
Span::styled("🔍 ", Style::default().fg(Color::Yellow)),
|
|
||||||
Span::styled(query, Style::default().fg(Color::White)),
|
|
||||||
Span::styled("█", Style::default().fg(Color::Yellow)),
|
|
||||||
Span::styled(format!(" ({}/{})", current, total), Style::default().fg(Color::Gray)),
|
|
||||||
])
|
|
||||||
};
|
|
||||||
|
|
||||||
let search_input = Paragraph::new(input_line).block(
|
|
||||||
Block::default()
|
|
||||||
.borders(Borders::ALL)
|
|
||||||
.border_style(Style::default().fg(Color::Yellow))
|
|
||||||
.title(" Поиск по сообщениям ")
|
|
||||||
.title_style(
|
|
||||||
Style::default()
|
|
||||||
.fg(Color::Yellow)
|
|
||||||
.add_modifier(Modifier::BOLD),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
f.render_widget(search_input, chunks[0]);
|
|
||||||
|
|
||||||
// Search results
|
|
||||||
let content_width = chunks[1].width.saturating_sub(2) as usize;
|
|
||||||
let mut lines: Vec<Line> = Vec::new();
|
|
||||||
|
|
||||||
if results.is_empty() {
|
|
||||||
if !query.is_empty() {
|
|
||||||
lines.push(Line::from(Span::styled(
|
|
||||||
"Ничего не найдено",
|
|
||||||
Style::default().fg(Color::Gray),
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
for (idx, msg) in results.iter().enumerate() {
|
|
||||||
let is_selected = idx == selected_index;
|
|
||||||
|
|
||||||
// Пустая строка между результатами
|
|
||||||
if idx > 0 {
|
|
||||||
lines.push(Line::from(""));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Маркер выбора, имя и дата
|
|
||||||
let marker = if is_selected { "▶ " } else { " " };
|
|
||||||
let sender_color = if msg.is_outgoing() {
|
|
||||||
Color::Green
|
|
||||||
} else {
|
|
||||||
Color::Cyan
|
|
||||||
};
|
|
||||||
let sender_name = if msg.is_outgoing() {
|
|
||||||
"Вы".to_string()
|
|
||||||
} else {
|
|
||||||
msg.sender_name().to_string()
|
|
||||||
};
|
|
||||||
|
|
||||||
lines.push(Line::from(vec![
|
|
||||||
Span::styled(
|
|
||||||
marker,
|
|
||||||
Style::default()
|
|
||||||
.fg(Color::Yellow)
|
|
||||||
.add_modifier(Modifier::BOLD),
|
|
||||||
),
|
|
||||||
Span::styled(
|
|
||||||
format!("{} ", sender_name),
|
|
||||||
Style::default()
|
|
||||||
.fg(sender_color)
|
|
||||||
.add_modifier(Modifier::BOLD),
|
|
||||||
),
|
|
||||||
Span::styled(
|
|
||||||
format!("({})", crate::utils::format_datetime(msg.date())),
|
|
||||||
Style::default().fg(Color::Gray),
|
|
||||||
),
|
|
||||||
]));
|
|
||||||
|
|
||||||
// Текст сообщения (с переносом)
|
|
||||||
let msg_color = if is_selected {
|
|
||||||
Color::Yellow
|
|
||||||
} else {
|
|
||||||
Color::White
|
|
||||||
};
|
|
||||||
let max_width = content_width.saturating_sub(4);
|
|
||||||
let wrapped = wrap_text_with_offsets(msg.text(), max_width);
|
|
||||||
let wrapped_count = wrapped.len();
|
|
||||||
|
|
||||||
for wrapped_line in wrapped.into_iter().take(2) {
|
|
||||||
lines.push(Line::from(vec![
|
|
||||||
Span::raw(" "),
|
|
||||||
Span::styled(wrapped_line.text, Style::default().fg(msg_color)),
|
|
||||||
]));
|
|
||||||
}
|
|
||||||
if wrapped_count > 2 {
|
|
||||||
lines.push(Line::from(vec![
|
|
||||||
Span::raw(" "),
|
|
||||||
Span::styled("...", Style::default().fg(Color::Gray)),
|
|
||||||
]));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Скролл к выбранному результату
|
|
||||||
let visible_height = chunks[1].height.saturating_sub(2) as usize;
|
|
||||||
let lines_per_result = 4;
|
|
||||||
let selected_line = selected_index * lines_per_result;
|
|
||||||
let scroll_offset = if selected_line > visible_height / 2 {
|
|
||||||
(selected_line - visible_height / 2) as u16
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
};
|
|
||||||
|
|
||||||
let results_widget = Paragraph::new(lines)
|
|
||||||
.block(
|
|
||||||
Block::default()
|
|
||||||
.borders(Borders::ALL)
|
|
||||||
.border_style(Style::default().fg(Color::Yellow)),
|
|
||||||
)
|
|
||||||
.scroll((scroll_offset, 0));
|
|
||||||
f.render_widget(results_widget, chunks[1]);
|
|
||||||
|
|
||||||
// Help bar
|
|
||||||
let help_line = Line::from(vec![
|
|
||||||
Span::styled(
|
|
||||||
" ↑↓ ",
|
|
||||||
Style::default()
|
|
||||||
.fg(Color::Yellow)
|
|
||||||
.add_modifier(Modifier::BOLD),
|
|
||||||
),
|
|
||||||
Span::raw("навигация"),
|
|
||||||
Span::raw(" "),
|
|
||||||
Span::styled(
|
|
||||||
" n/N ",
|
|
||||||
Style::default()
|
|
||||||
.fg(Color::Yellow)
|
|
||||||
.add_modifier(Modifier::BOLD),
|
|
||||||
),
|
|
||||||
Span::raw("след./пред."),
|
|
||||||
Span::raw(" "),
|
|
||||||
Span::styled(
|
|
||||||
" Enter ",
|
|
||||||
Style::default()
|
|
||||||
.fg(Color::Green)
|
|
||||||
.add_modifier(Modifier::BOLD),
|
|
||||||
),
|
|
||||||
Span::raw("перейти"),
|
|
||||||
Span::raw(" "),
|
|
||||||
Span::styled(" Esc ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
|
|
||||||
Span::raw("выход"),
|
|
||||||
]);
|
|
||||||
let help = Paragraph::new(help_line)
|
|
||||||
.block(
|
|
||||||
Block::default()
|
|
||||||
.borders(Borders::ALL)
|
|
||||||
.border_style(Style::default().fg(Color::Yellow)),
|
|
||||||
)
|
|
||||||
.alignment(Alignment::Center);
|
|
||||||
f.render_widget(help, chunks[2]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Рендерит режим просмотра закреплённых сообщений
|
|
||||||
fn render_pinned_mode<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
|
|
||||||
// Извлекаем данные из ChatState
|
|
||||||
let (messages, selected_index) = if let crate::app::ChatState::PinnedMessages {
|
|
||||||
messages,
|
|
||||||
selected_index,
|
|
||||||
} = &app.chat_state
|
|
||||||
{
|
|
||||||
(messages.as_slice(), *selected_index)
|
|
||||||
} else {
|
|
||||||
return; // Некорректное состояние
|
|
||||||
};
|
|
||||||
|
|
||||||
let chunks = Layout::default()
|
|
||||||
.direction(Direction::Vertical)
|
|
||||||
.constraints([
|
|
||||||
Constraint::Length(3), // Header
|
|
||||||
Constraint::Min(0), // Pinned messages list
|
|
||||||
Constraint::Length(3), // Help bar
|
|
||||||
])
|
|
||||||
.split(area);
|
|
||||||
|
|
||||||
// Header
|
|
||||||
let total = messages.len();
|
|
||||||
let current = selected_index + 1;
|
|
||||||
let header_text = format!("📌 ЗАКРЕПЛЁННЫЕ ({}/{})", current, total);
|
|
||||||
let header = Paragraph::new(header_text)
|
|
||||||
.block(
|
|
||||||
Block::default()
|
|
||||||
.borders(Borders::ALL)
|
|
||||||
.border_style(Style::default().fg(Color::Magenta)),
|
|
||||||
)
|
|
||||||
.style(
|
|
||||||
Style::default()
|
|
||||||
.fg(Color::Magenta)
|
|
||||||
.add_modifier(Modifier::BOLD),
|
|
||||||
);
|
|
||||||
f.render_widget(header, chunks[0]);
|
|
||||||
|
|
||||||
// Pinned messages list
|
|
||||||
let content_width = chunks[1].width.saturating_sub(2) as usize;
|
|
||||||
let mut lines: Vec<Line> = Vec::new();
|
|
||||||
|
|
||||||
for (idx, msg) in messages.iter().enumerate() {
|
|
||||||
let is_selected = idx == selected_index;
|
|
||||||
|
|
||||||
// Пустая строка между сообщениями
|
|
||||||
if idx > 0 {
|
|
||||||
lines.push(Line::from(""));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Маркер выбора и имя отправителя
|
|
||||||
let marker = if is_selected { "▶ " } else { " " };
|
|
||||||
let sender_color = if msg.is_outgoing() {
|
|
||||||
Color::Green
|
|
||||||
} else {
|
|
||||||
Color::Cyan
|
|
||||||
};
|
|
||||||
let sender_name = if msg.is_outgoing() {
|
|
||||||
"Вы".to_string()
|
|
||||||
} else {
|
|
||||||
msg.sender_name().to_string()
|
|
||||||
};
|
|
||||||
|
|
||||||
lines.push(Line::from(vec![
|
|
||||||
Span::styled(
|
|
||||||
marker,
|
|
||||||
Style::default()
|
|
||||||
.fg(Color::Yellow)
|
|
||||||
.add_modifier(Modifier::BOLD),
|
|
||||||
),
|
|
||||||
Span::styled(
|
|
||||||
format!("{} ", sender_name),
|
|
||||||
Style::default()
|
|
||||||
.fg(sender_color)
|
|
||||||
.add_modifier(Modifier::BOLD),
|
|
||||||
),
|
|
||||||
Span::styled(
|
|
||||||
format!("({})", crate::utils::format_datetime(msg.date())),
|
|
||||||
Style::default().fg(Color::Gray),
|
|
||||||
),
|
|
||||||
]));
|
|
||||||
|
|
||||||
// Текст сообщения (с переносом)
|
|
||||||
let msg_color = if is_selected {
|
|
||||||
Color::Yellow
|
|
||||||
} else {
|
|
||||||
Color::White
|
|
||||||
};
|
|
||||||
let max_width = content_width.saturating_sub(4);
|
|
||||||
let wrapped = wrap_text_with_offsets(msg.text(), max_width);
|
|
||||||
let wrapped_count = wrapped.len();
|
|
||||||
|
|
||||||
for wrapped_line in wrapped.into_iter().take(3) {
|
|
||||||
// Максимум 3 строки на сообщение
|
|
||||||
lines.push(Line::from(vec![
|
|
||||||
Span::raw(" "), // Отступ
|
|
||||||
Span::styled(wrapped_line.text, Style::default().fg(msg_color)),
|
|
||||||
]));
|
|
||||||
}
|
|
||||||
if wrapped_count > 3 {
|
|
||||||
lines.push(Line::from(vec![
|
|
||||||
Span::raw(" "),
|
|
||||||
Span::styled("...", Style::default().fg(Color::Gray)),
|
|
||||||
]));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if lines.is_empty() {
|
|
||||||
lines.push(Line::from(Span::styled(
|
|
||||||
"Нет закреплённых сообщений",
|
|
||||||
Style::default().fg(Color::Gray),
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Скролл к выбранному сообщению
|
|
||||||
let visible_height = chunks[1].height.saturating_sub(2) as usize;
|
|
||||||
let lines_per_msg = 5; // Примерно строк на сообщение
|
|
||||||
let selected_line = selected_index * lines_per_msg;
|
|
||||||
let scroll_offset = if selected_line > visible_height / 2 {
|
|
||||||
(selected_line - visible_height / 2) as u16
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
};
|
|
||||||
|
|
||||||
let messages_widget = Paragraph::new(lines)
|
|
||||||
.block(
|
|
||||||
Block::default()
|
|
||||||
.borders(Borders::ALL)
|
|
||||||
.border_style(Style::default().fg(Color::Magenta)),
|
|
||||||
)
|
|
||||||
.scroll((scroll_offset, 0));
|
|
||||||
f.render_widget(messages_widget, chunks[1]);
|
|
||||||
|
|
||||||
// Help bar
|
|
||||||
let help_line = Line::from(vec![
|
|
||||||
Span::styled(
|
|
||||||
" ↑↓ ",
|
|
||||||
Style::default()
|
|
||||||
.fg(Color::Yellow)
|
|
||||||
.add_modifier(Modifier::BOLD),
|
|
||||||
),
|
|
||||||
Span::raw("навигация"),
|
|
||||||
Span::raw(" "),
|
|
||||||
Span::styled(
|
|
||||||
" Enter ",
|
|
||||||
Style::default()
|
|
||||||
.fg(Color::Green)
|
|
||||||
.add_modifier(Modifier::BOLD),
|
|
||||||
),
|
|
||||||
Span::raw("перейти"),
|
|
||||||
Span::raw(" "),
|
|
||||||
Span::styled(" Esc ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
|
|
||||||
Span::raw("выход"),
|
|
||||||
]);
|
|
||||||
let help = Paragraph::new(help_line)
|
|
||||||
.block(
|
|
||||||
Block::default()
|
|
||||||
.borders(Borders::ALL)
|
|
||||||
.border_style(Style::default().fg(Color::Magenta)),
|
|
||||||
)
|
|
||||||
.alignment(Alignment::Center);
|
|
||||||
f.render_widget(help, chunks[2]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Рендерит модалку подтверждения удаления
|
|
||||||
fn render_delete_confirm_modal(f: &mut Frame, area: Rect) {
|
|
||||||
components::modal::render_delete_confirm_modal(f, area);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Рендерит модалку выбора реакции
|
|
||||||
fn render_reaction_picker_modal(
|
|
||||||
f: &mut Frame,
|
|
||||||
area: Rect,
|
|
||||||
available_reactions: &[String],
|
|
||||||
selected_index: usize,
|
|
||||||
) {
|
|
||||||
components::render_emoji_picker(f, area, available_reactions, selected_index);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
|
//! UI rendering module.
|
||||||
|
//!
|
||||||
|
//! Routes rendering by screen (Loading → Auth → Main) and checks terminal size.
|
||||||
|
|
||||||
mod auth;
|
mod auth;
|
||||||
pub mod chat_list;
|
pub mod chat_list;
|
||||||
pub mod components;
|
pub mod components;
|
||||||
|
mod compose_bar;
|
||||||
pub mod footer;
|
pub mod footer;
|
||||||
mod loading;
|
mod loading;
|
||||||
mod main_screen;
|
mod main_screen;
|
||||||
pub mod messages;
|
pub mod messages;
|
||||||
|
mod modals;
|
||||||
pub mod profile;
|
pub mod profile;
|
||||||
|
|
||||||
use crate::app::{App, AppScreen};
|
use crate::app::{App, AppScreen};
|
||||||
@@ -33,6 +39,11 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, app: &mut App<T>) {
|
|||||||
AppScreen::Auth => auth::render(f, app),
|
AppScreen::Auth => auth::render(f, app),
|
||||||
AppScreen::Main => main_screen::render(f, app),
|
AppScreen::Main => main_screen::render(f, app),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Global overlay: account switcher (renders on top of ANY screen)
|
||||||
|
if app.account_switcher.is_some() {
|
||||||
|
modals::render_account_switcher(f, area, app);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_size_warning(f: &mut Frame, width: u16, height: u16) {
|
fn render_size_warning(f: &mut Frame, width: u16, height: u16) {
|
||||||
|
|||||||
190
src/ui/modals/account_switcher.rs
Normal file
190
src/ui/modals/account_switcher.rs
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
//! Account switcher modal
|
||||||
|
//!
|
||||||
|
//! Renders a centered popup with account list (SelectAccount) or
|
||||||
|
//! new account name input (AddAccount).
|
||||||
|
|
||||||
|
use crate::app::{AccountSwitcherState, App};
|
||||||
|
use crate::tdlib::TdClientTrait;
|
||||||
|
use ratatui::{
|
||||||
|
layout::Rect,
|
||||||
|
style::{Color, Modifier, Style},
|
||||||
|
text::{Line, Span},
|
||||||
|
widgets::{Block, Borders, Clear, Paragraph},
|
||||||
|
Frame,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Renders the account switcher modal overlay.
|
||||||
|
pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
|
||||||
|
let Some(state) = &app.account_switcher else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
match state {
|
||||||
|
AccountSwitcherState::SelectAccount { accounts, selected_index, current_account } => {
|
||||||
|
render_select_account(f, area, accounts, *selected_index, current_account);
|
||||||
|
}
|
||||||
|
AccountSwitcherState::AddAccount { name_input, cursor_position, error } => {
|
||||||
|
render_add_account(f, area, name_input, *cursor_position, error.as_deref());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_select_account(
|
||||||
|
f: &mut Frame,
|
||||||
|
area: Rect,
|
||||||
|
accounts: &[crate::accounts::AccountProfile],
|
||||||
|
selected_index: usize,
|
||||||
|
current_account: &str,
|
||||||
|
) {
|
||||||
|
let mut lines: Vec<Line> = Vec::new();
|
||||||
|
lines.push(Line::from(""));
|
||||||
|
|
||||||
|
for (idx, account) in accounts.iter().enumerate() {
|
||||||
|
let is_selected = idx == selected_index;
|
||||||
|
let is_current = account.name == current_account;
|
||||||
|
|
||||||
|
let marker = if is_current { "● " } else { " " };
|
||||||
|
let suffix = if is_current { " (текущий)" } else { "" };
|
||||||
|
let display = format!("{}{} ({}){}", marker, account.name, account.display_name, suffix);
|
||||||
|
|
||||||
|
let style = if is_selected {
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Yellow)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
|
} else if is_current {
|
||||||
|
Style::default().fg(Color::Green)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(Color::White)
|
||||||
|
};
|
||||||
|
|
||||||
|
lines.push(Line::from(Span::styled(format!(" {}", display), style)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Separator
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
" ──────────────────────",
|
||||||
|
Style::default().fg(Color::DarkGray),
|
||||||
|
)));
|
||||||
|
|
||||||
|
// Add account item
|
||||||
|
let add_selected = selected_index == accounts.len();
|
||||||
|
let add_style = if add_selected {
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Yellow)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(Color::Cyan)
|
||||||
|
};
|
||||||
|
lines.push(Line::from(Span::styled(" + Добавить аккаунт", add_style)));
|
||||||
|
|
||||||
|
lines.push(Line::from(""));
|
||||||
|
|
||||||
|
// Help bar
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::styled(" j/k ", Style::default().fg(Color::Yellow)),
|
||||||
|
Span::styled("Nav", Style::default().fg(Color::DarkGray)),
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled(" Enter ", Style::default().fg(Color::Green)),
|
||||||
|
Span::styled("Select", Style::default().fg(Color::DarkGray)),
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled(" a ", Style::default().fg(Color::Cyan)),
|
||||||
|
Span::styled("Add", Style::default().fg(Color::DarkGray)),
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled(" Esc ", Style::default().fg(Color::Red)),
|
||||||
|
Span::styled("Close", Style::default().fg(Color::DarkGray)),
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Calculate dynamic height: header(3) + accounts + separator(1) + add(1) + empty(1) + help(1) + footer(1)
|
||||||
|
let content_height = (accounts.len() as u16) + 7;
|
||||||
|
let height = content_height.min(area.height.saturating_sub(4));
|
||||||
|
let width = 40u16.min(area.width.saturating_sub(4));
|
||||||
|
|
||||||
|
let x = area.x + (area.width.saturating_sub(width)) / 2;
|
||||||
|
let y = area.y + (area.height.saturating_sub(height)) / 2;
|
||||||
|
let modal_area = Rect::new(x, y, width, height);
|
||||||
|
|
||||||
|
f.render_widget(Clear, modal_area);
|
||||||
|
|
||||||
|
let modal = Paragraph::new(lines).block(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(Style::default().fg(Color::Cyan))
|
||||||
|
.title(" АККАУНТЫ ")
|
||||||
|
.title_style(
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Cyan)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
f.render_widget(modal, modal_area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_add_account(
|
||||||
|
f: &mut Frame,
|
||||||
|
area: Rect,
|
||||||
|
name_input: &str,
|
||||||
|
_cursor_position: usize,
|
||||||
|
error: Option<&str>,
|
||||||
|
) {
|
||||||
|
let mut lines: Vec<Line> = Vec::new();
|
||||||
|
lines.push(Line::from(""));
|
||||||
|
|
||||||
|
// Input field
|
||||||
|
let input_display = if name_input.is_empty() {
|
||||||
|
Span::styled("_", Style::default().fg(Color::DarkGray))
|
||||||
|
} else {
|
||||||
|
Span::styled(format!("{}_", name_input), Style::default().fg(Color::White))
|
||||||
|
};
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::styled(" Имя: ", Style::default().fg(Color::Cyan)),
|
||||||
|
input_display,
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Hint
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
" (a-z, 0-9, -, _)",
|
||||||
|
Style::default().fg(Color::DarkGray),
|
||||||
|
)));
|
||||||
|
|
||||||
|
lines.push(Line::from(""));
|
||||||
|
|
||||||
|
// Error
|
||||||
|
if let Some(err) = error {
|
||||||
|
lines.push(Line::from(Span::styled(format!(" {}", err), Style::default().fg(Color::Red))));
|
||||||
|
lines.push(Line::from(""));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Help bar
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::styled(" Enter ", Style::default().fg(Color::Green)),
|
||||||
|
Span::styled("Create", Style::default().fg(Color::DarkGray)),
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled(" Esc ", Style::default().fg(Color::Red)),
|
||||||
|
Span::styled("Back", Style::default().fg(Color::DarkGray)),
|
||||||
|
]));
|
||||||
|
|
||||||
|
let height = if error.is_some() { 10 } else { 8 };
|
||||||
|
let height = (height as u16).min(area.height.saturating_sub(4));
|
||||||
|
let width = 40u16.min(area.width.saturating_sub(4));
|
||||||
|
|
||||||
|
let x = area.x + (area.width.saturating_sub(width)) / 2;
|
||||||
|
let y = area.y + (area.height.saturating_sub(height)) / 2;
|
||||||
|
let modal_area = Rect::new(x, y, width, height);
|
||||||
|
|
||||||
|
f.render_widget(Clear, modal_area);
|
||||||
|
|
||||||
|
let modal = Paragraph::new(lines).block(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(Style::default().fg(Color::Cyan))
|
||||||
|
.title(" НОВЫЙ АККАУНТ ")
|
||||||
|
.title_style(
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Cyan)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
f.render_widget(modal, modal_area);
|
||||||
|
}
|
||||||
8
src/ui/modals/delete_confirm.rs
Normal file
8
src/ui/modals/delete_confirm.rs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
//! Delete confirmation modal
|
||||||
|
|
||||||
|
use ratatui::{layout::Rect, Frame};
|
||||||
|
|
||||||
|
/// Renders delete confirmation modal
|
||||||
|
pub fn render(f: &mut Frame, area: Rect) {
|
||||||
|
crate::ui::components::modal::render_delete_confirm_modal(f, area);
|
||||||
|
}
|
||||||
178
src/ui/modals/image_viewer.rs
Normal file
178
src/ui/modals/image_viewer.rs
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
//! Модальное окно для полноэкранного просмотра изображений.
|
||||||
|
//!
|
||||||
|
//! Поддерживает:
|
||||||
|
//! - Автоматическое масштабирование с сохранением aspect ratio
|
||||||
|
//! - Максимизация по ширине/высоте терминала
|
||||||
|
//! - Затемнение фона
|
||||||
|
//! - Hotkeys: Esc/q для закрытия, ←/→ для навигации между фото
|
||||||
|
|
||||||
|
use crate::app::App;
|
||||||
|
use crate::tdlib::r#trait::TdClientTrait;
|
||||||
|
use crate::tdlib::ImageModalState;
|
||||||
|
use ratatui::{
|
||||||
|
layout::{Alignment, Rect},
|
||||||
|
style::{Color, Style},
|
||||||
|
text::{Line, Span},
|
||||||
|
widgets::{Block, Clear, Paragraph},
|
||||||
|
Frame,
|
||||||
|
};
|
||||||
|
use ratatui_image::StatefulImage;
|
||||||
|
|
||||||
|
/// Рендерит модальное окно с полноэкранным изображением
|
||||||
|
pub fn render<T: TdClientTrait>(f: &mut Frame, app: &mut App<T>, modal_state: &ImageModalState) {
|
||||||
|
let area = f.area();
|
||||||
|
|
||||||
|
// Затемняем весь фон
|
||||||
|
f.render_widget(Clear, area);
|
||||||
|
f.render_widget(Block::default().style(Style::default().bg(Color::Black)), area);
|
||||||
|
|
||||||
|
// Резервируем место для подсказок (2 строки внизу)
|
||||||
|
let image_area_height = area.height.saturating_sub(2);
|
||||||
|
|
||||||
|
// Вычисляем размер изображения с сохранением aspect ratio
|
||||||
|
let (img_width, img_height) = calculate_modal_size(
|
||||||
|
modal_state.photo_width,
|
||||||
|
modal_state.photo_height,
|
||||||
|
area.width,
|
||||||
|
image_area_height,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Центрируем изображение
|
||||||
|
let img_x = (area.width.saturating_sub(img_width)) / 2;
|
||||||
|
let img_y = (image_area_height.saturating_sub(img_height)) / 2;
|
||||||
|
let img_rect = Rect::new(img_x, img_y, img_width, img_height);
|
||||||
|
|
||||||
|
// Рендерим изображение (используем modal_renderer для высокого качества)
|
||||||
|
if let Some(renderer) = &mut app.modal_image_renderer {
|
||||||
|
// Проверяем есть ли протокол уже в кеше
|
||||||
|
if let Some(protocol) = renderer.get_protocol(&modal_state.message_id) {
|
||||||
|
// Протокол готов - рендерим изображение (iTerm2/Sixel - высокое качество)
|
||||||
|
f.render_stateful_widget(StatefulImage::default(), img_rect, protocol);
|
||||||
|
} else {
|
||||||
|
// Протокола нет - показываем индикатор загрузки
|
||||||
|
let loading_text = vec![
|
||||||
|
Line::from(""),
|
||||||
|
Line::from(Span::styled(
|
||||||
|
"⏳ Загрузка изображения...",
|
||||||
|
Style::default().fg(Color::Gray),
|
||||||
|
)),
|
||||||
|
Line::from(""),
|
||||||
|
Line::from(Span::styled(
|
||||||
|
"(декодирование в высоком качестве)",
|
||||||
|
Style::default().fg(Color::DarkGray),
|
||||||
|
)),
|
||||||
|
];
|
||||||
|
let loading = Paragraph::new(loading_text)
|
||||||
|
.alignment(Alignment::Center)
|
||||||
|
.block(Block::default());
|
||||||
|
f.render_widget(loading, img_rect);
|
||||||
|
|
||||||
|
// Загружаем изображение (может занять время для iTerm2/Sixel)
|
||||||
|
let _ = renderer.load_image(modal_state.message_id, &modal_state.photo_path);
|
||||||
|
|
||||||
|
// Триггерим перерисовку для показа загруженного изображения
|
||||||
|
app.needs_redraw = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Подсказки внизу
|
||||||
|
let hint = "[Esc/q] Закрыть [←/→] Пред/След фото";
|
||||||
|
let hint_y = area.height.saturating_sub(1);
|
||||||
|
let hint_rect = Rect::new(0, hint_y, area.width, 1);
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(Span::styled(hint, Style::default().fg(Color::Gray)))
|
||||||
|
.alignment(Alignment::Center),
|
||||||
|
hint_rect,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Информация о размере (опционально)
|
||||||
|
let info = format!(
|
||||||
|
"{}x{} | {:.1}%",
|
||||||
|
modal_state.photo_width,
|
||||||
|
modal_state.photo_height,
|
||||||
|
(img_width as f64 / modal_state.photo_width as f64) * 100.0
|
||||||
|
);
|
||||||
|
let info_y = area.height.saturating_sub(2);
|
||||||
|
let info_rect = Rect::new(0, info_y, area.width, 1);
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(Span::styled(info, Style::default().fg(Color::DarkGray)))
|
||||||
|
.alignment(Alignment::Center),
|
||||||
|
info_rect,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Вычисляет размер изображения для модалки с сохранением aspect ratio.
|
||||||
|
///
|
||||||
|
/// # Логика масштабирования:
|
||||||
|
/// - Если изображение меньше терминала → показываем как есть
|
||||||
|
/// - Если ширина больше → масштабируем по ширине
|
||||||
|
/// - Если высота больше → масштабируем по высоте
|
||||||
|
/// - Сохраняем aspect ratio
|
||||||
|
fn calculate_modal_size(
|
||||||
|
img_width: i32,
|
||||||
|
img_height: i32,
|
||||||
|
term_width: u16,
|
||||||
|
term_height: u16,
|
||||||
|
) -> (u16, u16) {
|
||||||
|
let aspect_ratio = img_width as f64 / img_height as f64;
|
||||||
|
|
||||||
|
// Если изображение помещается целиком
|
||||||
|
if img_width <= term_width as i32 && img_height <= term_height as i32 {
|
||||||
|
return (img_width as u16, img_height as u16);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Начинаем с максимального размера терминала
|
||||||
|
let mut width = term_width as f64;
|
||||||
|
let mut height = term_height as f64;
|
||||||
|
|
||||||
|
// Подгоняем по aspect ratio
|
||||||
|
let term_aspect = width / height;
|
||||||
|
|
||||||
|
if term_aspect > aspect_ratio {
|
||||||
|
// Терминал шире → ограничены по высоте
|
||||||
|
width = height * aspect_ratio;
|
||||||
|
} else {
|
||||||
|
// Терминал выше → ограничены по ширине
|
||||||
|
height = width / aspect_ratio;
|
||||||
|
}
|
||||||
|
|
||||||
|
(width as u16, height as u16)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_calculate_modal_size_fits() {
|
||||||
|
// Изображение помещается целиком
|
||||||
|
let (w, h) = calculate_modal_size(50, 30, 100, 50);
|
||||||
|
assert_eq!(w, 50);
|
||||||
|
assert_eq!(h, 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_calculate_modal_size_scale_width() {
|
||||||
|
// Ограничены по ширине (изображение шире терминала)
|
||||||
|
let (w, h) = calculate_modal_size(200, 100, 100, 100);
|
||||||
|
assert_eq!(w, 100);
|
||||||
|
assert_eq!(h, 50); // aspect ratio 2:1
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_calculate_modal_size_scale_height() {
|
||||||
|
// Ограничены по высоте (изображение выше терминала)
|
||||||
|
let (w, h) = calculate_modal_size(100, 200, 100, 100);
|
||||||
|
assert_eq!(w, 50); // aspect ratio 1:2
|
||||||
|
assert_eq!(h, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_calculate_modal_size_aspect_ratio() {
|
||||||
|
// Проверка сохранения aspect ratio
|
||||||
|
let (w, h) = calculate_modal_size(1920, 1080, 100, 100);
|
||||||
|
let aspect = w as f64 / h as f64;
|
||||||
|
let expected_aspect = 1920.0 / 1080.0;
|
||||||
|
assert!((aspect - expected_aspect).abs() < 0.01);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user