Compare commits

...

24 Commits

Author SHA1 Message Date
d3565c9ff9 Merge pull request 'fix(images): eliminate race condition when pressing v on downloading photo' (#26) from refactor into main
Reviewed-on: #26
2026-03-02 23:19:14 +00:00
Mikhail Kilin
90776448ce fix(images): eliminate race condition when pressing v on downloading photo
Some checks failed
ci/woodpecker/pr/check Pipeline failed
Previously, handle_view_image called td_client.download_file() synchronously
while process_pending_chat_init already had a background synchronous=true
download in flight for the same file. TDLib returned is_downloading_completed=false
causing the view to fail on first press.

Fix: replace the blocking download in NotDownloaded/Downloading branches with
a pending_image_open intent flag. The main loop opens the modal automatically
when the background download result arrives via photo_download_rx. If no
background channel exists, a new one is started via direct tdlib_rs call.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 02:15:51 +03:00
6344e0ff6a Merge pull request 'refactor' (#25) from refactor into main
Reviewed-on: #25
2026-03-02 22:22:24 +00:00
Mikhail Kilin
c89a5e13f8 chore: remove leftover backup files from src/
Some checks failed
ci/woodpecker/pr/check Pipeline failed
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 01:17:47 +03:00
Mikhail Kilin
07a41ff796 chore: remove unused and outdated files
- config.example.toml: duplicate of config.toml.example
- REFACTORING_ROADMAP.md, REFACTORING_OPPORTUNITIES.md: refactoring done in Phase 13
- TESTING_PROGRESS.md, TESTING_ROADMAP.md: stale since February, superseded by ROADMAP.md
- CHANGELOG.md: never maintained
- FAQ.md, CONTRIBUTING.md, SECURITY.md, INSTALL.md: boilerplate for a personal project
- .github/: GitHub templates unused (project hosted on Gitea)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 01:15:42 +03:00
Mikhail Kilin
e2971e5ff5 chore: add symbol_info_budget and language_backend fields to serena config
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 01:04:55 +03:00
de18d6978b Merge pull request 'refactor' (#24) from refactor into main
Reviewed-on: #24
2026-03-02 22:00:07 +00:00
Mikhail Kilin
dea3559da7 docs: remove out-of-scope items from Phase 14 Etap 4 roadmap
Some checks failed
ci/woodpecker/pr/check Pipeline failed
Remove account deletion from modal and parallel polling — these won't
be implemented in the current scope.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 00:57:18 +03:00
Mikhail Kilin
260b81443e style: replace DarkGray with Rgb(160,160,160) for better terminal compatibility
DarkGray renders differently across terminals; a specific RGB value gives
consistent appearance. Also always show the account indicator in the footer.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 00:57:06 +03:00
Mikhail Kilin
df89c4e376 test: update footer snapshots to always show account name
Snapshots now reflect the new behaviour where the account indicator
is always visible (including "default"), matching the footer.rs change.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 00:52:56 +03:00
Mikhail Kilin
ec2758ce18 refactor: consolidate message loading logic into chat_loader.rs
Move all three phases of chat message loading from scattered locations
into a single dedicated module for better cohesion and navigability:
- Phase 1: open_chat_and_load_data (from handlers/chat_list.rs)
- Phase 2: process_pending_chat_init (extracted from 70-line inline block in main.rs)
- Phase 3: load_older_messages_if_needed (from handlers/chat.rs)

No behaviour changes — pure refactoring.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 00:48:39 +03:00
564df43910 Merge pull request 'fix: always reserve space for selection marker to prevent text shift' (#23) from refactor into main
Reviewed-on: #23
2026-02-24 12:59:04 +00:00
Mikhail Kilin
a095fe277b fix: always reserve space for selection marker to prevent text shift
Some checks failed
ci/woodpecker/pr/check Pipeline failed
Render "  " (2 spaces) for unselected messages instead of nothing,
so text stays aligned when navigating with the ▶ selection indicator.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 15:49:08 +03:00
42f16b1a2b Merge pull request 'feat: per-account lock protection + fix message navigation' (#22) from refactor into main 2026-02-24 12:39:01 +00:00
Mikhail Kilin
dfd4184039 fix: keep selection on last/first message instead of deselecting
Some checks failed
ci/woodpecker/pr/check Pipeline failed
When pressing down on the last message or up on the first message in
chat navigation, stay on the current message instead of exiting
message selection mode.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 15:35:06 +03:00
Mikhail Kilin
25c57c55fb feat: add per-account lock file protection via fs2
Prevent running multiple tele-tui instances with the same account by
using advisory file locks (flock). Lock is acquired before raw mode so
errors print to normal terminal. Account switching acquires new lock
before releasing old. Also log set_tdlib_parameters errors via tracing
instead of silently discarding them.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 15:35:06 +03:00
044b859cec Merge pull request 'ci/woodpecker-checks' (#21) from ci/woodpecker-checks into main 2026-02-22 15:12:46 +00:00
Mikhail Kilin
51e7941668 chore: remove unused GitHub Actions workflow
All checks were successful
ci/woodpecker/pr/check Pipeline was successful
Woodpecker CI is the active CI system; GitHub Actions never runs on Gitea.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:04:32 +03:00
Mikhail Kilin
3b7ef41cae fix: resolve all 40 clippy warnings (dead_code, unused_imports, lints)
Some checks failed
ci/woodpecker/pr/check Pipeline was successful
CI / Check (pull_request) Has been cancelled
CI / Format (pull_request) Has been cancelled
CI / Clippy (pull_request) Has been cancelled
CI / Build (macos-latest) (pull_request) Has been cancelled
CI / Build (ubuntu-latest) (pull_request) Has been cancelled
CI / Build (windows-latest) (pull_request) Has been cancelled
- Add #[allow(unused_imports)] on pub re-exports used only by lib/tests
- Add #[allow(dead_code)] on public API items unused in binary target
- Fix collapsible_if, redundant_closure, unnecessary_map_or in main.rs
- Prefix unused test variables with underscore

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 17:50:18 +03:00
Mikhail Kilin
166fda93a4 style: fix formatting after clippy changes
Some checks failed
ci/woodpecker/pr/check Pipeline failed
CI / Check (pull_request) Has been cancelled
CI / Format (pull_request) Has been cancelled
CI / Clippy (pull_request) Has been cancelled
CI / Build (macos-latest) (pull_request) Has been cancelled
CI / Build (ubuntu-latest) (pull_request) Has been cancelled
CI / Build (windows-latest) (pull_request) Has been cancelled
2026-02-22 17:33:48 +03:00
Mikhail Kilin
d4e1ed1376 fix: resolve all 23 clippy warnings
Some checks failed
ci/woodpecker/pr/check Pipeline failed
CI / Check (pull_request) Has been cancelled
CI / Format (pull_request) Has been cancelled
CI / Clippy (pull_request) Has been cancelled
CI / Build (macos-latest) (pull_request) Has been cancelled
CI / Build (ubuntu-latest) (pull_request) Has been cancelled
CI / Build (windows-latest) (pull_request) Has been cancelled
2026-02-22 17:28:50 +03:00
Mikhail Kilin
d9eb61dda7 ci: use rust:latest image (deps require rustc 1.88+)
Some checks failed
ci/woodpecker/pr/check Pipeline failed
CI / Check (pull_request) Has been cancelled
CI / Format (pull_request) Has been cancelled
CI / Clippy (pull_request) Has been cancelled
CI / Build (macos-latest) (pull_request) Has been cancelled
CI / Build (ubuntu-latest) (pull_request) Has been cancelled
CI / Build (windows-latest) (pull_request) Has been cancelled
2026-02-22 17:14:31 +03:00
Mikhail Kilin
c7865b46a7 ci: bump rust image to 1.85 (edition 2024 support)
Some checks failed
ci/woodpecker/pr/check Pipeline failed
CI / Check (pull_request) Has been cancelled
CI / Format (pull_request) Has been cancelled
CI / Clippy (pull_request) Has been cancelled
CI / Build (macos-latest) (pull_request) Has been cancelled
CI / Build (ubuntu-latest) (pull_request) Has been cancelled
CI / Build (windows-latest) (pull_request) Has been cancelled
2026-02-22 17:12:14 +03:00
Mikhail Kilin
264f183510 style: auto-format entire codebase with cargo fmt (stable rustfmt.toml)
Some checks failed
ci/woodpecker/pr/check Pipeline failed
CI / Check (pull_request) Has been cancelled
CI / Format (pull_request) Has been cancelled
CI / Clippy (pull_request) Has been cancelled
CI / Build (macos-latest) (pull_request) Has been cancelled
CI / Build (ubuntu-latest) (pull_request) Has been cancelled
CI / Build (windows-latest) (pull_request) Has been cancelled
2026-02-22 17:09:51 +03:00
146 changed files with 2304 additions and 11012 deletions

View File

@@ -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]
## Логи
Если есть логи или сообщения об ошибках, вставьте их сюда:
```
вставьте логи здесь
```
## Дополнительный контекст
Любая другая информация, которая может помочь в решении проблемы.

View File

@@ -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) и этой функции там нет
## Дополнительный контекст
Скриншоты, ссылки на похожие реализации в других приложениях, и т.д.

View File

@@ -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 изменений.
## Дополнительные заметки
Любая дополнительная информация для ревьюверов.

View File

@@ -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

View File

@@ -108,3 +108,14 @@ default_modes:
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
fixed_tools: []
# override of the corresponding setting in serena_config.yml, see the documentation there.
# If null or missing, the value from the global config is used.
symbol_info_budget:
# The language backend to use for this project.
# If not set, the global setting from serena_config.yml is used.
# Valid values: LSP, JetBrains
# Note: the backend is fixed at startup. If a project with a different backend
# is activated post-init, an error will be returned.
language_backend:

View File

@@ -3,13 +3,13 @@ when:
steps:
- name: fmt
image: rust:1.84
image: rust:latest
commands:
- rustup component add rustfmt
- cargo fmt -- --check
- name: clippy
image: rust:1.84
image: rust:latest
environment:
CARGO_HOME: /tmp/cargo
commands:
@@ -18,7 +18,7 @@ steps:
- cargo clippy -- -D warnings
- name: test
image: rust:1.84
image: rust:latest
environment:
CARGO_HOME: /tmp/cargo
commands:

View File

@@ -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

View File

@@ -2,6 +2,31 @@
## Статус: Фаза 14 — Мультиаккаунт (IN PROGRESS)
### Per-Account Lock File Protection — DONE
Защита от запуска двух экземпляров tele-tui с одним аккаунтом + логирование ошибок TDLib.
**Проблема**: При запуске второго экземпляра с тем же аккаунтом, TDLib не может залочить свою БД. `set_tdlib_parameters` молча падает (`let _ = ...`), и приложение зависает на "Инициализация TDLib...".
**Решение**: Advisory file locks через `fs2` (flock):
- **Lock файл**: `~/.local/share/tele-tui/accounts/{name}/tele-tui.lock`
- **Автоматическое освобождение** при crash/SIGKILL (ядро ОС закрывает file descriptors)
- **При старте**: acquire lock ДО `enable_raw_mode()` → ошибка выводится в обычный терминал
- **При переключении аккаунтов**: acquire new → release old → switch (при ошибке — остаёмся на старом)
- **Логирование**: `set_tdlib_parameters` ошибки теперь логируются через `tracing::error!`
**Новые файлы:**
- `src/accounts/lock.rs``acquire_lock()`, `release_lock()`, `account_lock_path()` + 4 теста
**Модифицированные файлы:**
- `Cargo.toml` — зависимость `fs2 = "0.4"`
- `src/accounts/mod.rs``pub mod lock;` + re-exports
- `src/app/mod.rs` — поле `account_lock: Option<File>` в `App<T>`
- `src/main.rs` — acquire lock при старте, lock при переключении аккаунтов, логирование set_tdlib_parameters
- `src/tdlib/client.rs` — логирование set_tdlib_parameters в `recreate_client()`
---
### Photo Albums (Media Groups) — DONE
Фото-альбомы (несколько фото в одном сообщении) теперь группируются в один пузырь с сеткой фото.

View File

@@ -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).

11
Cargo.lock generated
View File

@@ -1169,6 +1169,16 @@ dependencies = [
"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]]
name = "futures-channel"
version = "0.3.31"
@@ -3383,6 +3393,7 @@ dependencies = [
"crossterm",
"dirs 5.0.1",
"dotenvy",
"fs2",
"image",
"insta",
"notify-rust",

View File

@@ -37,6 +37,7 @@ thiserror = "1.0"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
base64 = "0.22.1"
fs2 = "0.4"
[dev-dependencies]
insta = "1.34"

227
FAQ.md
View File

@@ -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.

View File

@@ -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/
```

View File

@@ -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

View File

@@ -164,7 +164,5 @@
- `pending_account_switch` флаг → обработка в main loop
- [ ] **Этап 4: Расширенные возможности мультиаккаунта**
- Удаление аккаунта из модалки
- Хоткеи `Ctrl+1`..`Ctrl+9` — быстрое переключение
- Бейджи непрочитанных с других аккаунтов (требует множественных TdClient)
- Параллельный polling updates со всех аккаунтов

View File

@@ -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 недели
- **Низкие**: включаются в следующий релиз
## Спасибо
Мы ценим ваш вклад в безопасность проекта!

View File

@@ -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

View File

@@ -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__/`

View File

@@ -1,6 +1,6 @@
use criterion::{black_box, criterion_group, criterion_main, Criterion};
use tele_tui::formatting::format_text_with_entities;
use tdlib_rs::enums::{TextEntity, TextEntityType};
use tele_tui::formatting::format_text_with_entities;
fn create_text_with_entities() -> (String, Vec<TextEntity>) {
let text = "This is bold and italic text with code and a link and mention".to_string();
@@ -41,9 +41,7 @@ fn benchmark_format_simple_text(c: &mut Criterion) {
let entities = vec![];
c.bench_function("format_simple_text", |b| {
b.iter(|| {
format_text_with_entities(black_box(&text), black_box(&entities))
});
b.iter(|| 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();
c.bench_function("format_markdown_text", |b| {
b.iter(|| {
format_text_with_entities(black_box(&text), black_box(&entities))
});
b.iter(|| 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| {
b.iter(|| {
format_text_with_entities(black_box(&text), black_box(&entities))
});
b.iter(|| format_text_with_entities(black_box(&text), black_box(&entities)));
});
}

View File

@@ -1,5 +1,5 @@
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) {
c.bench_function("format_timestamp_50_times", |b| {
@@ -34,10 +34,5 @@ fn benchmark_get_day(c: &mut Criterion) {
});
}
criterion_group!(
benches,
benchmark_format_timestamp,
benchmark_format_date,
benchmark_get_day
);
criterion_group!(benches, benchmark_format_timestamp, benchmark_format_date, benchmark_get_day);
criterion_main!(benches);

View File

@@ -8,7 +8,10 @@ fn create_test_messages(count: usize) -> Vec<tele_tui::tdlib::MessageInfo> {
.map(|i| {
let builder = MessageBuilder::new(MessageId::new(i as i64))
.sender_name(&format!("User{}", i % 10))
.text(&format!("Test message number {} with some longer text to make it more realistic", i))
.text(&format!(
"Test message number {} with some longer text to make it more realistic",
i
))
.date(1640000000 + (i as i32 * 60));
if i % 2 == 0 {
@@ -24,9 +27,7 @@ fn benchmark_group_100_messages(c: &mut Criterion) {
let messages = create_test_messages(100);
c.bench_function("group_100_messages", |b| {
b.iter(|| {
group_messages(black_box(&messages))
});
b.iter(|| group_messages(black_box(&messages)));
});
}
@@ -34,9 +35,7 @@ fn benchmark_group_500_messages(c: &mut Criterion) {
let messages = create_test_messages(500);
c.bench_function("group_500_messages", |b| {
b.iter(|| {
group_messages(black_box(&messages))
});
b.iter(|| group_messages(black_box(&messages)));
});
}

View File

@@ -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

View File

@@ -6,15 +6,6 @@ max_width = 100
tab_spaces = 4
newline_style = "Unix"
# Imports
imports_granularity = "Crate"
group_imports = "StdExternalCrate"
# Comments
wrap_comments = true
comment_width = 80
normalize_comments = true
# Formatting
use_small_heuristics = "Default"
fn_call_width = 80

127
src/accounts/lock.rs Normal file
View 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));
}
}

View File

@@ -61,8 +61,8 @@ pub fn load_or_create() -> AccountsConfig {
/// 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())?;
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() {
@@ -111,17 +111,10 @@ fn migrate_legacy() {
// Move (rename) the directory
match fs::rename(&legacy_path, &target) {
Ok(()) => {
tracing::info!(
"Migrated ./tdlib_data/ -> {}",
target.display()
);
tracing::info!("Migrated ./tdlib_data/ -> {}", target.display());
}
Err(e) => {
tracing::error!(
"Could not migrate ./tdlib_data/ to {}: {}",
target.display(),
e
);
tracing::error!("Could not migrate ./tdlib_data/ to {}: {}", target.display(), e);
}
}
}

View File

@@ -4,8 +4,12 @@
//! 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};

View File

@@ -6,10 +6,10 @@
/// - По статусу (archived, muted, и т.д.)
///
/// Используется как в App, так и в UI слое для консистентной фильтрации.
use crate::tdlib::ChatInfo;
/// Критерии фильтрации чатов
#[allow(dead_code)]
#[derive(Debug, Clone, Default)]
pub struct ChatFilterCriteria {
/// Фильтр по папке (folder_id)
@@ -34,6 +34,7 @@ pub struct ChatFilterCriteria {
pub hide_archived: bool,
}
#[allow(dead_code)]
impl ChatFilterCriteria {
/// Создаёт критерии с дефолтными значениями
pub fn new() -> Self {
@@ -42,18 +43,12 @@ impl ChatFilterCriteria {
/// Фильтр только по папке
pub fn by_folder(folder_id: Option<i32>) -> Self {
Self {
folder_id,
..Default::default()
}
Self { folder_id, ..Default::default() }
}
/// Фильтр только по поисковому запросу
pub fn by_search(query: String) -> Self {
Self {
search_query: Some(query),
..Default::default()
}
Self { search_query: Some(query), ..Default::default() }
}
/// Builder: установить папку
@@ -154,8 +149,10 @@ impl ChatFilterCriteria {
}
/// Централизованный фильтр чатов
#[allow(dead_code)]
pub struct ChatFilter;
#[allow(dead_code)]
impl ChatFilter {
/// Фильтрует список чатов по критериям
///
@@ -176,10 +173,7 @@ impl ChatFilter {
///
/// let filtered = ChatFilter::filter(&all_chats, &criteria);
/// ```
pub fn filter<'a>(
chats: &'a [ChatInfo],
criteria: &ChatFilterCriteria,
) -> Vec<&'a ChatInfo> {
pub fn filter<'a>(chats: &'a [ChatInfo], criteria: &ChatFilterCriteria) -> Vec<&'a ChatInfo> {
chats.iter().filter(|chat| criteria.matches(chat)).collect()
}
@@ -309,8 +303,7 @@ mod tests {
let filtered = ChatFilter::filter(&chats, &criteria);
assert_eq!(filtered.len(), 2); // Chat 1 and Chat 3 have unread
let criteria = ChatFilterCriteria::new()
.pinned_only(true);
let criteria = ChatFilterCriteria::new().pinned_only(true);
let filtered = ChatFilter::filter(&chats, &criteria);
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_mentions(&chats, &criteria), 3); // 1 + 2
}
}

View File

@@ -14,9 +14,10 @@ pub enum InputMode {
}
/// Состояния чата - взаимоисключающие режимы работы с чатом
#[derive(Debug, Clone)]
#[derive(Debug, Clone, Default)]
pub enum ChatState {
/// Обычный режим - просмотр сообщений, набор текста
#[default]
Normal,
/// Выбор сообщения для действия (edit/delete/reply/forward/reaction)
@@ -90,12 +91,6 @@ pub enum ChatState {
},
}
impl Default for ChatState {
fn default() -> Self {
ChatState::Normal
}
}
impl ChatState {
/// Проверка: находимся в режиме выбора сообщения
pub fn is_message_selection(&self) -> bool {

View File

@@ -2,8 +2,8 @@
//!
//! Handles reply, forward, and draft functionality
use crate::app::{App, ChatState};
use crate::app::methods::messages::MessageMethods;
use crate::app::{App, ChatState};
use crate::tdlib::{MessageInfo, TdClientTrait};
/// Compose methods for reply/forward/draft
@@ -44,9 +44,7 @@ pub trait ComposeMethods<T: TdClientTrait> {
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(),
};
self.chat_state = ChatState::Reply { message_id: msg.id() };
return true;
}
false
@@ -72,9 +70,7 @@ impl<T: TdClientTrait> ComposeMethods<T> for App<T> {
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_state = ChatState::Forward { message_id: msg.id() };
// Сбрасываем выбор чата на первый
self.chat_list_state.select(Some(0));
return true;

View File

@@ -61,8 +61,7 @@ impl<T: TdClientTrait> MessageMethods<T> for App<T> {
// Перескакиваем через все сообщения текущего альбома назад
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
while new_index > 0 && messages[new_index].media_album_id() == current_album_id
{
new_index -= 1;
}
@@ -110,24 +109,20 @@ impl<T: TdClientTrait> MessageMethods<T> for App<T> {
}
}
if new_index >= total {
self.chat_state = ChatState::Normal;
} else {
if new_index < total {
*selected_index = new_index;
self.stop_playback();
}
self.stop_playback();
} else {
// Дошли до самого нового сообщения - выходим из режима выбора
self.chat_state = ChatState::Normal;
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()
})
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 {
@@ -158,10 +153,7 @@ impl<T: TdClientTrait> MessageMethods<T> for App<T> {
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,
};
self.chat_state = ChatState::Editing { message_id: id, selected_index: idx };
return true;
}
false

View File

@@ -7,14 +7,19 @@
//! - search: Search in chats and messages
//! - modal: Modal dialogs (Profile, Pinned, Reactions, Delete)
pub mod navigation;
pub mod messages;
pub mod compose;
pub mod search;
pub mod messages;
pub mod modal;
pub mod navigation;
pub mod search;
pub use navigation::NavigationMethods;
pub use messages::MessageMethods;
#[allow(unused_imports)]
pub use compose::ComposeMethods;
pub use search::SearchMethods;
#[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;

View File

@@ -106,10 +106,7 @@ impl<T: TdClientTrait> ModalMethods<T> for App<T> {
fn enter_pinned_mode(&mut self, messages: Vec<MessageInfo>) {
if !messages.is_empty() {
self.chat_state = ChatState::PinnedMessages {
messages,
selected_index: 0,
};
self.chat_state = ChatState::PinnedMessages { messages, selected_index: 0 };
}
}
@@ -118,11 +115,7 @@ impl<T: TdClientTrait> ModalMethods<T> for App<T> {
}
fn select_previous_pinned(&mut self) {
if let ChatState::PinnedMessages {
selected_index,
messages,
} = &mut self.chat_state
{
if let ChatState::PinnedMessages { selected_index, messages } = &mut self.chat_state {
if *selected_index + 1 < messages.len() {
*selected_index += 1;
}
@@ -138,11 +131,7 @@ impl<T: TdClientTrait> ModalMethods<T> for App<T> {
}
fn get_selected_pinned(&self) -> Option<&MessageInfo> {
if let ChatState::PinnedMessages {
messages,
selected_index,
} = &self.chat_state
{
if let ChatState::PinnedMessages { messages, selected_index } = &self.chat_state {
messages.get(*selected_index)
} else {
None
@@ -170,10 +159,7 @@ impl<T: TdClientTrait> ModalMethods<T> for App<T> {
}
fn select_previous_profile_action(&mut self) {
if let ChatState::Profile {
selected_action, ..
} = &mut self.chat_state
{
if let ChatState::Profile { selected_action, .. } = &mut self.chat_state {
if *selected_action > 0 {
*selected_action -= 1;
}
@@ -181,10 +167,7 @@ impl<T: TdClientTrait> ModalMethods<T> for App<T> {
}
fn select_next_profile_action(&mut self, max_actions: usize) {
if let ChatState::Profile {
selected_action, ..
} = &mut self.chat_state
{
if let ChatState::Profile { selected_action, .. } = &mut self.chat_state {
if *selected_action < max_actions.saturating_sub(1) {
*selected_action += 1;
}
@@ -192,41 +175,25 @@ impl<T: TdClientTrait> ModalMethods<T> for App<T> {
}
fn show_leave_group_confirmation(&mut self) {
if let ChatState::Profile {
leave_group_confirmation_step,
..
} = &mut self.chat_state
{
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
{
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
{
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
{
if let ChatState::Profile { leave_group_confirmation_step, .. } = &self.chat_state {
*leave_group_confirmation_step
} else {
0
@@ -242,10 +209,7 @@ impl<T: TdClientTrait> ModalMethods<T> for App<T> {
}
fn get_selected_profile_action(&self) -> Option<usize> {
if let ChatState::Profile {
selected_action, ..
} = &self.chat_state
{
if let ChatState::Profile { selected_action, .. } = &self.chat_state {
Some(*selected_action)
} else {
None
@@ -277,11 +241,8 @@ impl<T: TdClientTrait> ModalMethods<T> for App<T> {
}
fn select_next_reaction(&mut self) {
if let ChatState::ReactionPicker {
selected_index,
available_reactions,
..
} = &mut self.chat_state
if let ChatState::ReactionPicker { selected_index, available_reactions, .. } =
&mut self.chat_state
{
if *selected_index + 1 < available_reactions.len() {
*selected_index += 1;
@@ -290,11 +251,8 @@ impl<T: TdClientTrait> ModalMethods<T> for App<T> {
}
fn get_selected_reaction(&self) -> Option<&String> {
if let ChatState::ReactionPicker {
available_reactions,
selected_index,
..
} = &self.chat_state
if let ChatState::ReactionPicker { available_reactions, selected_index, .. } =
&self.chat_state
{
available_reactions.get(*selected_index)
} else {

View File

@@ -2,8 +2,8 @@
//!
//! Handles chat list navigation and selection
use crate::app::{App, ChatState, InputMode};
use crate::app::methods::search::SearchMethods;
use crate::app::{App, ChatState, InputMode};
use crate::tdlib::TdClientTrait;
/// Navigation methods for chat list
@@ -87,6 +87,7 @@ impl<T: TdClientTrait> NavigationMethods<T> for App<T> {
#[cfg(feature = "images")]
{
self.photo_download_rx = None;
self.pending_image_open = None;
}
// Сбрасываем состояние чата в нормальный режим
self.chat_state = ChatState::Normal;

View File

@@ -51,9 +51,11 @@ pub trait SearchMethods<T: TdClientTrait> {
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]>;
}
@@ -71,8 +73,7 @@ impl<T: TdClientTrait> SearchMethods<T> for App<T> {
fn get_filtered_chats(&self) -> Vec<&ChatInfo> {
// Используем ChatFilter для централизованной фильтрации
let mut criteria = ChatFilterCriteria::new()
.with_folder(self.selected_folder_id);
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());
@@ -113,12 +114,7 @@ impl<T: TdClientTrait> SearchMethods<T> for App<T> {
}
fn select_next_search_result(&mut self) {
if let ChatState::SearchInChat {
selected_index,
results,
..
} = &mut self.chat_state
{
if let ChatState::SearchInChat { selected_index, results, .. } = &mut self.chat_state {
if *selected_index + 1 < results.len() {
*selected_index += 1;
}
@@ -126,12 +122,7 @@ impl<T: TdClientTrait> SearchMethods<T> for App<T> {
}
fn get_selected_search_result(&self) -> Option<&MessageInfo> {
if let ChatState::SearchInChat {
results,
selected_index,
..
} = &self.chat_state
{
if let ChatState::SearchInChat { results, selected_index, .. } = &self.chat_state {
results.get(*selected_index)
} else {
None

View File

@@ -5,13 +5,14 @@
mod chat_filter;
mod chat_state;
mod state;
pub mod methods;
mod state;
pub use chat_filter::{ChatFilter, ChatFilterCriteria};
pub use chat_state::{ChatState, InputMode};
pub use state::AppScreen;
#[allow(unused_imports)]
pub use methods::*;
pub use state::AppScreen;
use crate::accounts::AccountProfile;
use crate::tdlib::{ChatInfo, TdClient, TdClientTrait};
@@ -19,6 +20,19 @@ use crate::types::ChatId;
use ratatui::widgets::ListState;
use std::path::PathBuf;
/// Pending intent to open the image modal once a photo finishes downloading.
///
/// Set when the user presses `v` on a photo that is still downloading.
/// The main loop opens the modal automatically when the download completes.
#[cfg(feature = "images")]
#[derive(Debug, Clone)]
pub struct PendingImageOpen {
pub file_id: i32,
pub message_id: crate::types::MessageId,
pub photo_width: i32,
pub photo_height: i32,
}
/// State of the account switcher modal overlay.
#[derive(Debug, Clone)]
pub enum AccountSwitcherState {
@@ -107,6 +121,7 @@ pub struct App<T: TdClientTrait = TdClient> {
/// Время последней отправки typing status (для throttling)
pub last_typing_sent: Option<std::time::Instant>,
// Image support
#[allow(dead_code)]
#[cfg(feature = "images")]
pub image_cache: Option<crate::media::cache::ImageCache>,
/// Renderer для inline preview в чате (Halfblocks - быстро)
@@ -121,6 +136,12 @@ pub struct App<T: TdClientTrait = TdClient> {
/// Время последнего рендеринга изображений (для throttling до 15 FPS)
#[cfg(feature = "images")]
pub last_image_render_time: Option<std::time::Instant>,
/// Pending intent: открыть модалку для этого фото когда загрузится
#[cfg(feature = "images")]
pub pending_image_open: Option<PendingImageOpen>,
// Account lock
/// Advisory file lock to prevent concurrent access to the same account
pub account_lock: Option<std::fs::File>,
// Account switcher
/// Account switcher modal state (global overlay)
pub account_switcher: Option<AccountSwitcherState>,
@@ -145,6 +166,7 @@ pub struct App<T: TdClientTrait = TdClient> {
pub last_playback_tick: Option<std::time::Instant>,
}
#[allow(dead_code)]
impl<T: TdClientTrait> App<T> {
/// Creates a new App instance with the given configuration and client.
///
@@ -165,9 +187,7 @@ impl<T: TdClientTrait> App<T> {
let audio_cache_size_mb = config.audio.cache_size_mb;
#[cfg(feature = "images")]
let image_cache = Some(crate::media::cache::ImageCache::new(
config.images.cache_size_mb,
));
let image_cache = Some(crate::media::cache::ImageCache::new(config.images.cache_size_mb));
#[cfg(feature = "images")]
let inline_image_renderer = crate::media::image_renderer::ImageRenderer::new_fast();
#[cfg(feature = "images")]
@@ -196,6 +216,8 @@ impl<T: TdClientTrait> App<T> {
search_query: String::new(),
needs_redraw: true,
last_typing_sent: None,
// Account lock
account_lock: None,
// Account switcher
account_switcher: None,
current_account_name: "default".to_string(),
@@ -213,6 +235,8 @@ impl<T: TdClientTrait> App<T> {
image_modal: None,
#[cfg(feature = "images")]
last_image_render_time: None,
#[cfg(feature = "images")]
pending_image_open: None,
// Voice playback
audio_player: crate::audio::AudioPlayer::new().ok(),
voice_cache: crate::audio::VoiceCache::new(audio_cache_size_mb).ok(),
@@ -275,11 +299,8 @@ impl<T: TdClientTrait> App<T> {
/// Navigate to next item in account switcher list.
pub fn account_switcher_select_next(&mut self) {
if let Some(AccountSwitcherState::SelectAccount {
accounts,
selected_index,
..
}) = &mut self.account_switcher
if let Some(AccountSwitcherState::SelectAccount { accounts, selected_index, .. }) =
&mut self.account_switcher
{
// +1 for the "Add account" item at the end
let max_index = accounts.len();
@@ -372,20 +393,6 @@ impl<T: TdClientTrait> App<T> {
.and_then(|id| self.chats.iter().find(|c| c.id == id))
}
// ========== Getter/Setter методы для инкапсуляции ==========
// Config

View File

@@ -97,13 +97,13 @@ impl VoiceCache {
/// 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))?;
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

View File

@@ -58,7 +58,8 @@ impl AudioPlayer {
let mut cmd = Command::new("ffplay");
cmd.arg("-nodisp")
.arg("-autoexit")
.arg("-loglevel").arg("quiet");
.arg("-loglevel")
.arg("quiet");
if start_secs > 0.0 {
cmd.arg("-ss").arg(format!("{:.1}", start_secs));
@@ -132,19 +133,19 @@ impl AudioPlayer {
.arg("-CONT")
.arg(pid.to_string())
.output();
let _ = Command::new("kill")
.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()
}
@@ -154,13 +155,16 @@ impl AudioPlayer {
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())
}

View File

@@ -4,7 +4,6 @@
/// - Загрузку из конфигурационного файла
/// - Множественные binding для одной команды (EN/RU раскладки)
/// - Type-safe команды через enum
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
@@ -49,12 +48,12 @@ pub enum Command {
SelectMessage,
// Media
ViewImage, // v - просмотр фото
ViewImage, // v - просмотр фото
// Voice playback
TogglePlayback, // Space - play/pause
SeekForward, // → - seek +5s
SeekBackward, // ← - seek -5s
TogglePlayback, // Space - play/pause
SeekForward, // → - seek +5s
SeekBackward, // ← - seek -5s
// Input
SubmitMessage,
@@ -83,31 +82,21 @@ pub struct KeyBinding {
impl KeyBinding {
pub fn new(key: KeyCode) -> Self {
Self {
key,
modifiers: KeyModifiers::NONE,
}
Self { key, modifiers: KeyModifiers::NONE }
}
pub fn with_ctrl(key: KeyCode) -> Self {
Self {
key,
modifiers: KeyModifiers::CONTROL,
}
Self { key, modifiers: KeyModifiers::CONTROL }
}
#[allow(dead_code)]
pub fn with_shift(key: KeyCode) -> Self {
Self {
key,
modifiers: KeyModifiers::SHIFT,
}
Self { key, modifiers: KeyModifiers::SHIFT }
}
#[allow(dead_code)]
pub fn with_alt(key: KeyCode) -> Self {
Self {
key,
modifiers: KeyModifiers::ALT,
}
Self { key, modifiers: KeyModifiers::ALT }
}
pub fn matches(&self, event: &KeyEvent) -> bool {
@@ -123,55 +112,81 @@ pub struct 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();
// Navigation
bindings.insert(Command::MoveUp, vec![
KeyBinding::new(KeyCode::Up),
KeyBinding::new(KeyCode::Char('k')),
KeyBinding::new(KeyCode::Char('р')), // RU (custom mapping, not standard ЙЦУКЕН)
]);
bindings.insert(Command::MoveDown, vec![
KeyBinding::new(KeyCode::Down),
KeyBinding::new(KeyCode::Char('j')),
KeyBinding::new(KeyCode::Char('о')), // RU
]);
bindings.insert(Command::MoveLeft, vec![
KeyBinding::new(KeyCode::Left),
KeyBinding::new(KeyCode::Char('h')),
KeyBinding::new(KeyCode::Char('л')), // RU (custom mapping, not standard ЙЦУКЕН)
]);
bindings.insert(Command::MoveRight, vec![
KeyBinding::new(KeyCode::Right),
KeyBinding::new(KeyCode::Char('l')),
KeyBinding::new(KeyCode::Char('д')), // RU
]);
bindings.insert(Command::PageUp, vec![
KeyBinding::new(KeyCode::PageUp),
KeyBinding::with_ctrl(KeyCode::Char('u')),
]);
bindings.insert(Command::PageDown, vec![
KeyBinding::new(KeyCode::PageDown),
KeyBinding::with_ctrl(KeyCode::Char('d')),
]);
bindings.insert(
Command::MoveUp,
vec![
KeyBinding::new(KeyCode::Up),
KeyBinding::new(KeyCode::Char('k')),
KeyBinding::new(KeyCode::Char('р')), // RU (custom mapping, not standard ЙЦУКЕН)
],
);
bindings.insert(
Command::MoveDown,
vec![
KeyBinding::new(KeyCode::Down),
KeyBinding::new(KeyCode::Char('j')),
KeyBinding::new(KeyCode::Char('о')), // RU
],
);
bindings.insert(
Command::MoveLeft,
vec![
KeyBinding::new(KeyCode::Left),
KeyBinding::new(KeyCode::Char('h')),
KeyBinding::new(KeyCode::Char('л')), // RU (custom mapping, not standard ЙЦУКЕН)
],
);
bindings.insert(
Command::MoveRight,
vec![
KeyBinding::new(KeyCode::Right),
KeyBinding::new(KeyCode::Char('l')),
KeyBinding::new(KeyCode::Char('д')), // RU
],
);
bindings.insert(
Command::PageUp,
vec![
KeyBinding::new(KeyCode::PageUp),
KeyBinding::with_ctrl(KeyCode::Char('u')),
],
);
bindings.insert(
Command::PageDown,
vec![
KeyBinding::new(KeyCode::PageDown),
KeyBinding::with_ctrl(KeyCode::Char('d')),
],
);
// Global
bindings.insert(Command::Quit, vec![
KeyBinding::new(KeyCode::Char('q')),
KeyBinding::new(KeyCode::Char('й')), // RU
KeyBinding::with_ctrl(KeyCode::Char('c')),
]);
bindings.insert(Command::OpenSearch, vec![
KeyBinding::with_ctrl(KeyCode::Char('s')),
]);
bindings.insert(Command::OpenSearchInChat, vec![
KeyBinding::with_ctrl(KeyCode::Char('f')),
]);
bindings.insert(Command::Help, vec![
KeyBinding::new(KeyCode::Char('?')),
]);
bindings.insert(
Command::Quit,
vec![
KeyBinding::new(KeyCode::Char('q')),
KeyBinding::new(KeyCode::Char('й')), // RU
KeyBinding::with_ctrl(KeyCode::Char('c')),
],
);
bindings.insert(Command::OpenSearch, vec![KeyBinding::with_ctrl(KeyCode::Char('s'))]);
bindings.insert(Command::OpenSearchInChat, vec![KeyBinding::with_ctrl(KeyCode::Char('f'))]);
bindings.insert(Command::Help, vec![KeyBinding::new(KeyCode::Char('?'))]);
// Chat list
// Note: Enter обрабатывается через Command::SubmitMessage в handle_enter_key()
@@ -188,109 +203,117 @@ impl Keybindings {
9 => Command::SelectFolder9,
_ => unreachable!(),
};
bindings.insert(cmd, vec![
KeyBinding::new(KeyCode::Char(char::from_digit(i, 10).unwrap())),
]);
bindings.insert(
cmd,
vec![KeyBinding::new(KeyCode::Char(
char::from_digit(i, 10).unwrap(),
))],
);
}
// Message actions
// Note: EditMessage (Up) обрабатывается напрямую в handle_open_chat_keyboard_input
// в зависимости от контекста (пустой инпут). Не привязываем здесь, чтобы не
// конфликтовать с Command::MoveUp в списке чатов.
bindings.insert(Command::DeleteMessage, vec![
KeyBinding::new(KeyCode::Delete),
KeyBinding::new(KeyCode::Char('d')),
KeyBinding::new(KeyCode::Char('в')), // RU
]);
bindings.insert(Command::ReplyMessage, vec![
KeyBinding::new(KeyCode::Char('r')),
KeyBinding::new(KeyCode::Char('к')), // RU
]);
bindings.insert(Command::ForwardMessage, vec![
KeyBinding::new(KeyCode::Char('f')),
KeyBinding::new(KeyCode::Char('а')), // RU
]);
bindings.insert(Command::CopyMessage, vec![
KeyBinding::new(KeyCode::Char('y')),
KeyBinding::new(KeyCode::Char('н')), // RU
]);
bindings.insert(Command::ReactMessage, vec![
KeyBinding::new(KeyCode::Char('e')),
KeyBinding::new(KeyCode::Char('у')), // RU
]);
bindings.insert(
Command::DeleteMessage,
vec![
KeyBinding::new(KeyCode::Delete),
KeyBinding::new(KeyCode::Char('d')),
KeyBinding::new(KeyCode::Char('в')), // RU
],
);
bindings.insert(
Command::ReplyMessage,
vec![
KeyBinding::new(KeyCode::Char('r')),
KeyBinding::new(KeyCode::Char('к')), // RU
],
);
bindings.insert(
Command::ForwardMessage,
vec![
KeyBinding::new(KeyCode::Char('f')),
KeyBinding::new(KeyCode::Char('а')), // RU
],
);
bindings.insert(
Command::CopyMessage,
vec![
KeyBinding::new(KeyCode::Char('y')),
KeyBinding::new(KeyCode::Char('н')), // RU
],
);
bindings.insert(
Command::ReactMessage,
vec![
KeyBinding::new(KeyCode::Char('e')),
KeyBinding::new(KeyCode::Char('у')), // RU
],
);
// Note: SelectMessage обрабатывается через Command::SubmitMessage в handle_enter_key()
// Media
bindings.insert(Command::ViewImage, vec![
KeyBinding::new(KeyCode::Char('v')),
KeyBinding::new(KeyCode::Char('м')), // RU
]);
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),
]);
bindings.insert(Command::TogglePlayback, vec![KeyBinding::new(KeyCode::Char(' '))]);
bindings.insert(Command::SeekForward, vec![KeyBinding::new(KeyCode::Right)]);
bindings.insert(Command::SeekBackward, vec![KeyBinding::new(KeyCode::Left)]);
// Input
bindings.insert(Command::SubmitMessage, vec![
KeyBinding::new(KeyCode::Enter),
]);
bindings.insert(Command::Cancel, vec![
KeyBinding::new(KeyCode::Esc),
]);
bindings.insert(Command::SubmitMessage, vec![KeyBinding::new(KeyCode::Enter)]);
bindings.insert(Command::Cancel, vec![KeyBinding::new(KeyCode::Esc)]);
bindings.insert(Command::NewLine, vec![]);
bindings.insert(Command::DeleteChar, vec![
KeyBinding::new(KeyCode::Backspace),
]);
bindings.insert(Command::DeleteWord, vec![
KeyBinding::with_ctrl(KeyCode::Backspace),
KeyBinding::with_ctrl(KeyCode::Char('w')),
]);
bindings.insert(Command::MoveToStart, vec![
KeyBinding::new(KeyCode::Home),
KeyBinding::with_ctrl(KeyCode::Char('a')),
]);
bindings.insert(Command::MoveToEnd, vec![
KeyBinding::new(KeyCode::End),
KeyBinding::with_ctrl(KeyCode::Char('e')),
]);
bindings.insert(Command::DeleteChar, vec![KeyBinding::new(KeyCode::Backspace)]);
bindings.insert(
Command::DeleteWord,
vec![
KeyBinding::with_ctrl(KeyCode::Backspace),
KeyBinding::with_ctrl(KeyCode::Char('w')),
],
);
bindings.insert(
Command::MoveToStart,
vec![
KeyBinding::new(KeyCode::Home),
KeyBinding::with_ctrl(KeyCode::Char('a')),
],
);
bindings.insert(
Command::MoveToEnd,
vec![
KeyBinding::new(KeyCode::End),
KeyBinding::with_ctrl(KeyCode::Char('e')),
],
);
// Vim mode
bindings.insert(Command::EnterInsertMode, vec![
KeyBinding::new(KeyCode::Char('i')),
KeyBinding::new(KeyCode::Char('ш')), // RU
]);
bindings.insert(
Command::EnterInsertMode,
vec![
KeyBinding::new(KeyCode::Char('i')),
KeyBinding::new(KeyCode::Char('ш')), // RU
],
);
// Profile
bindings.insert(Command::OpenProfile, vec![
KeyBinding::with_ctrl(KeyCode::Char('u')), // Ctrl+U instead of Ctrl+I
KeyBinding::with_ctrl(KeyCode::Char('г')), // RU
]);
bindings.insert(
Command::OpenProfile,
vec![
KeyBinding::with_ctrl(KeyCode::Char('u')), // Ctrl+U instead of Ctrl+I
KeyBinding::with_ctrl(KeyCode::Char('г')), // RU
],
);
Self { bindings }
}
/// Ищет команду по клавише
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
@@ -395,14 +418,15 @@ mod key_code_serde {
let s = String::deserialize(deserializer)?;
if s.starts_with("Char('") && s.ends_with("')") {
let c = s.chars().nth(6).ok_or_else(|| {
serde::de::Error::custom("Invalid Char format")
})?;
let c = s
.chars()
.nth(6)
.ok_or_else(|| serde::de::Error::custom("Invalid Char format"))?;
return Ok(KeyCode::Char(c));
}
if s.starts_with("F") {
let n = s[1..].parse().map_err(serde::de::Error::custom)?;
if let Some(suffix) = s.strip_prefix("F") {
let n = suffix.parse().map_err(serde::de::Error::custom)?;
return Ok(KeyCode::F(n));
}

View File

@@ -26,7 +26,7 @@ pub use keybindings::{Command, Keybindings};
/// println!("Timezone: {}", config.general.timezone);
/// println!("Incoming color: {}", config.colors.incoming_message);
/// ```
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Config {
/// Общие настройки (timezone и т.д.).
#[serde(default)]
@@ -260,19 +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(),
images: ImagesConfig::default(),
audio: AudioConfig::default(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -284,10 +271,22 @@ mod tests {
let keybindings = &config.keybindings;
// Test that keybindings exist for common commands
assert!(keybindings.get_command(&KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)) == Some(Command::ReplyMessage));
assert!(keybindings.get_command(&KeyEvent::new(KeyCode::Char('к'), KeyModifiers::NONE)) == Some(Command::ReplyMessage));
assert!(keybindings.get_command(&KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE)) == Some(Command::ForwardMessage));
assert!(keybindings.get_command(&KeyEvent::new(KeyCode::Char('а'), KeyModifiers::NONE)) == Some(Command::ForwardMessage));
assert!(
keybindings.get_command(&KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE))
== Some(Command::ReplyMessage)
);
assert!(
keybindings.get_command(&KeyEvent::new(KeyCode::Char('к'), KeyModifiers::NONE))
== Some(Command::ReplyMessage)
);
assert!(
keybindings.get_command(&KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE))
== Some(Command::ForwardMessage)
);
assert!(
keybindings.get_command(&KeyEvent::new(KeyCode::Char('а'), KeyModifiers::NONE))
== Some(Command::ForwardMessage)
);
}
#[test]
@@ -355,10 +354,24 @@ mod tests {
#[test]
fn test_config_validate_valid_all_standard_colors() {
let colors = [
"black", "red", "green", "yellow", "blue", "magenta",
"cyan", "gray", "grey", "white", "darkgray", "darkgrey",
"lightred", "lightgreen", "lightyellow", "lightblue",
"lightmagenta", "lightcyan"
"black",
"red",
"green",
"yellow",
"blue",
"magenta",
"cyan",
"gray",
"grey",
"white",
"darkgray",
"darkgrey",
"lightred",
"lightgreen",
"lightyellow",
"lightblue",
"lightmagenta",
"lightcyan",
];
for color in colors {
@@ -369,11 +382,7 @@ mod tests {
config.colors.reaction_chosen = color.to_string();
config.colors.reaction_other = color.to_string();
assert!(
config.validate().is_ok(),
"Color '{}' should be valid",
color
);
assert!(config.validate().is_ok(), "Color '{}' should be valid", color);
}
}

View File

@@ -50,6 +50,7 @@ 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;
/// Размер кэша изображений по умолчанию (в МБ)

View File

@@ -126,23 +126,25 @@ pub fn format_text_with_entities(
let start = entity.offset as usize;
let end = (entity.offset + entity.length) as usize;
for i in start..end.min(chars.len()) {
for item in char_styles
.iter_mut()
.take(end.min(chars.len()))
.skip(start)
{
match &entity.r#type {
TextEntityType::Bold => char_styles[i].bold = true,
TextEntityType::Italic => char_styles[i].italic = true,
TextEntityType::Underline => char_styles[i].underline = true,
TextEntityType::Strikethrough => char_styles[i].strikethrough = true,
TextEntityType::Bold => item.bold = true,
TextEntityType::Italic => item.italic = true,
TextEntityType::Underline => item.underline = true,
TextEntityType::Strikethrough => item.strikethrough = true,
TextEntityType::Code | TextEntityType::Pre | TextEntityType::PreCode(_) => {
char_styles[i].code = true
item.code = true
}
TextEntityType::Spoiler => char_styles[i].spoiler = true,
TextEntityType::Spoiler => item.spoiler = true,
TextEntityType::Url
| TextEntityType::TextUrl(_)
| TextEntityType::EmailAddress
| TextEntityType::PhoneNumber => char_styles[i].url = true,
TextEntityType::Mention | TextEntityType::MentionName(_) => {
char_styles[i].mention = true
}
| TextEntityType::PhoneNumber => item.url = true,
TextEntityType::Mention | TextEntityType::MentionName(_) => item.mention = true,
_ => {}
}
}
@@ -277,11 +279,7 @@ mod tests {
#[test]
fn test_format_text_with_bold() {
let text = "Hello";
let entities = vec![TextEntity {
offset: 0,
length: 5,
r#type: TextEntityType::Bold,
}];
let entities = vec![TextEntity { offset: 0, length: 5, r#type: TextEntityType::Bold }];
let spans = format_text_with_entities(text, &entities, Color::White);
assert_eq!(spans.len(), 1);

View File

@@ -20,7 +20,8 @@ pub async fn handle<T: TdClientTrait>(app: &mut App<T>, key_code: KeyCode) {
app.status_message = Some("Отправка номера...".to_string());
match with_timeout_msg(
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
@@ -84,7 +85,8 @@ pub async fn handle<T: TdClientTrait>(app: &mut App<T>, key_code: KeyCode) {
app.status_message = Some("Проверка пароля...".to_string());
match with_timeout_msg(
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

View File

@@ -6,17 +6,17 @@
//! - Editing and sending messages
//! - Loading older messages
use super::chat_loader::{load_older_messages_if_needed, open_chat_and_load_data};
use crate::app::methods::{
compose::ComposeMethods, messages::MessageMethods, modal::ModalMethods,
navigation::NavigationMethods,
};
use crate::app::App;
use crate::app::InputMode;
use crate::app::methods::{
compose::ComposeMethods, messages::MessageMethods,
modal::ModalMethods, navigation::NavigationMethods,
};
use crate::tdlib::{TdClientTrait, ChatAction};
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 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 std::time::{Duration, Instant};
@@ -29,7 +29,11 @@ use std::time::{Duration, Instant};
/// - Пересылку сообщения (f/а)
/// - Копирование сообщения (y/н)
/// - Добавление реакции (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 {
Some(crate::config::Command::MoveUp) => {
app.select_previous_message();
@@ -44,9 +48,7 @@ pub async fn handle_message_selection<T: TdClientTrait>(app: &mut App<T>, _key:
let can_delete =
msg.can_be_deleted_only_for_self() || msg.can_be_deleted_for_all_users();
if can_delete {
app.chat_state = crate::app::ChatState::DeleteConfirmation {
message_id: msg.id(),
};
app.chat_state = crate::app::ChatState::DeleteConfirmation { message_id: msg.id() };
}
}
Some(crate::config::Command::EnterInsertMode) => {
@@ -129,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()
.any(|m| m.id() == msg_id);
if !msg_exists {
app.error_message = Some(format!(
"Сообщение {} не найдено в кэше чата {}",
msg_id.as_i64(), chat_id
));
app.error_message =
Some(format!("Сообщение {} не найдено в кэше чата {}", msg_id.as_i64(), chat_id));
app.chat_state = crate::app::ChatState::Normal;
app.message_input.clear();
app.cursor_position = 0;
@@ -148,7 +155,8 @@ pub async fn edit_message<T: TdClientTrait>(app: &mut App<T>, chat_id: i64, msg_
match with_timeout_msg(
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
@@ -160,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();
// Если в старом сообщении был reply и в новом он "Unknown" - сохраняем старый
if let Some(old_reply) = old_reply_to {
if edited_msg.interactions.reply_to.as_ref()
.map_or(true, |r| r.sender_name == "Unknown") {
if edited_msg
.interactions
.reply_to
.as_ref()
.is_none_or(|r| r.sender_name == "Unknown")
{
edited_msg.interactions.reply_to = Some(old_reply);
}
}
@@ -189,13 +201,13 @@ pub async fn send_new_message<T: TdClientTrait>(app: &mut App<T>, chat_id: i64,
};
// Создаём ReplyInfo ДО отправки, пока сообщение точно доступно
let reply_info = app.get_replying_to_message().map(|m| {
crate::tdlib::ReplyInfo {
let reply_info = app
.get_replying_to_message()
.map(|m| crate::tdlib::ReplyInfo {
message_id: m.id(),
sender_name: m.sender_name().to_string(),
text: m.text().to_string(),
}
});
});
app.message_input.clear();
app.cursor_position = 0;
@@ -206,11 +218,14 @@ pub async fn send_new_message<T: TdClientTrait>(app: &mut App<T>, chat_id: i64,
app.last_typing_sent = None;
// Отменяем 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(
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
@@ -304,7 +319,8 @@ pub async fn send_reaction<T: TdClientTrait>(app: &mut App<T>) {
// Send reaction with timeout
let result = with_timeout_msg(
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;
@@ -324,49 +340,6 @@ 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);
}
}
/// Обработка ввода клавиатуры в открытом чате
///
/// Обрабатывает:
@@ -408,7 +381,8 @@ pub async fn handle_open_chat_keyboard_input<T: TdClientTrait>(app: &mut App<T>,
// Игнорируем символы с Ctrl/Alt модификаторами (кроме Shift)
// Это позволяет обрабатывать хоткеи типа Ctrl+U для профиля
if key.modifiers.contains(KeyModifiers::CONTROL)
|| key.modifiers.contains(KeyModifiers::ALT) {
|| key.modifiers.contains(KeyModifiers::ALT)
{
return;
}
@@ -434,7 +408,9 @@ pub async fn handle_open_chat_keyboard_input<T: TdClientTrait>(app: &mut App<T>,
.unwrap_or(true);
if should_send_typing {
if let Some(chat_id) = app.get_selected_chat_id() {
app.td_client.send_chat_action(ChatId::new(chat_id), ChatAction::Typing).await;
app.td_client
.send_chat_action(ChatId::new(chat_id), ChatAction::Typing)
.await;
app.last_typing_sent = Some(Instant::now());
}
}
@@ -608,47 +584,44 @@ async fn handle_view_image<T: TdClientTrait>(app: &mut App<T>) {
});
app.needs_redraw = true;
}
PhotoDownloadState::Downloading => {
app.status_message = Some("Загрузка фото...".to_string());
}
PhotoDownloadState::NotDownloaded => {
// Скачиваем фото и открываем
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;
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;
// Если нет активной фоновой загрузки — запускаем свою
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)),
}
}
// Открываем модалку
app.image_modal = Some(ImageModalState {
message_id: msg_id,
photo_path: path,
photo_width,
photo_height,
});
app.status_message = None;
}
Err(e) => {
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::Error(e.clone());
break;
}
}
}
app.error_message = Some(format!("Ошибка загрузки фото: {}", e));
app.status_message = None;
}
})
.await;
let result =
result.unwrap_or_else(|_| Err("Таймаут загрузки".to_string()));
let _ = tx.send((file_id, result));
});
}
}
PhotoDownloadState::Error(_) => {
@@ -660,8 +633,7 @@ async fn handle_view_image<T: TdClientTrait>(app: &mut App<T>) {
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());
photo.download_state = PhotoDownloadState::Downloaded(path.clone());
break;
}
}
@@ -748,13 +720,25 @@ async fn handle_play_voice<T: TdClientTrait>(app: &mut App<T>) {
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()) {
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));
let _ = cache.store(
&file_id.to_string(),
Path::new(&found_path),
);
}
return handle_play_voice_from_path(app, &found_path, &voice, &msg).await;
return handle_play_voice_from_path(
app,
&found_path,
voice,
&msg,
)
.await;
}
}
}
@@ -770,7 +754,7 @@ async fn handle_play_voice<T: TdClientTrait>(app: &mut App<T>) {
let _ = cache.store(&file_id.to_string(), Path::new(&audio_path));
}
handle_play_voice_from_path(app, &audio_path, &voice, &msg).await;
handle_play_voice_from_path(app, &audio_path, voice, &msg).await;
}
VoiceDownloadState::Downloading => {
app.status_message = Some("Загрузка голосового...".to_string());
@@ -780,7 +764,7 @@ async fn handle_play_voice<T: TdClientTrait>(app: &mut App<T>) {
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;
handle_play_voice_from_path(app, &path_str, voice, &msg).await;
return;
}
@@ -793,7 +777,7 @@ async fn handle_play_voice<T: TdClientTrait>(app: &mut App<T>) {
let _ = cache.store(&cache_key, std::path::Path::new(&path));
}
handle_play_voice_from_path(app, &path, &voice, &msg).await;
handle_play_voice_from_path(app, &path, voice, &msg).await;
}
Err(e) => {
app.error_message = Some(format!("Ошибка загрузки: {}", e));
@@ -826,4 +810,3 @@ async fn _download_and_expand<T: TdClientTrait>(app: &mut App<T>, msg_id: crate:
// Закомментировано - будет реализовано в Этапе 4
}
*/

View File

@@ -5,12 +5,10 @@
//! - Folder selection
//! - Opening chats
use crate::app::methods::navigation::NavigationMethods;
use crate::app::App;
use crate::app::InputMode;
use crate::app::methods::{compose::ComposeMethods, messages::MessageMethods, navigation::NavigationMethods};
use crate::tdlib::TdClientTrait;
use crate::types::{ChatId, MessageId};
use crate::utils::{with_timeout, with_timeout_msg};
use crate::utils::with_timeout;
use crossterm::event::KeyEvent;
use std::time::Duration;
@@ -19,7 +17,11 @@ use std::time::Duration;
/// Обрабатывает:
/// - Up/Down/j/k: навигация между чатами
/// - Цифры 1-9: переключение папок (1=All, 2-9=папки из TDLib)
pub async fn handle_chat_list_navigation<T: TdClientTrait>(app: &mut App<T>, _key: KeyEvent, command: Option<crate::config::Command>) {
pub async fn handle_chat_list_navigation<T: TdClientTrait>(
app: &mut App<T>,
_key: KeyEvent,
command: Option<crate::config::Command>,
) {
match command {
Some(crate::config::Command::MoveDown) => {
app.next_chat();
@@ -65,71 +67,11 @@ pub async fn select_folder<T: TdClientTrait>(app: &mut App<T>, folder_idx: usize
let folder_id = folder.id;
app.selected_folder_id = Some(folder_id);
app.status_message = Some("Загрузка чатов папки...".to_string());
let _ = with_timeout(
Duration::from_secs(5),
app.td_client.load_folder_chats(folder_id, 50),
)
.await;
let _ =
with_timeout(Duration::from_secs(5), app.td_client.load_folder_chats(folder_id, 50))
.await;
app.status_message = None;
app.chat_list_state.select(Some(0));
}
}
/// Открывает чат и загружает последние сообщения (быстро).
///
/// Загружает только 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;
}
}
}

View 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);
}
}

View File

@@ -6,10 +6,10 @@
//! - Edit mode
//! - Cursor movement and text editing
use crate::app::App;
use crate::app::methods::{
compose::ComposeMethods, navigation::NavigationMethods, search::SearchMethods,
};
use crate::app::App;
use crate::tdlib::TdClientTrait;
use crate::types::ChatId;
use crate::utils::with_timeout_msg;
@@ -22,7 +22,11 @@ use std::time::Duration;
/// - Навигацию по списку чатов (Up/Down)
/// - Пересылку сообщения в выбранный чат (Enter)
/// - Отмену пересылки (Esc)
pub async fn handle_forward_mode<T: TdClientTrait>(app: &mut App<T>, _key: KeyEvent, command: Option<crate::config::Command>) {
pub async fn handle_forward_mode<T: TdClientTrait>(
app: &mut App<T>,
_key: KeyEvent,
command: Option<crate::config::Command>,
) {
match command {
Some(crate::config::Command::Cancel) => {
app.cancel_forward();
@@ -63,11 +67,8 @@ pub async fn forward_selected_message<T: TdClientTrait>(app: &mut App<T>) {
// Forward the message with timeout
let result = with_timeout_msg(
Duration::from_secs(5),
app.td_client.forward_messages(
to_chat_id,
ChatId::new(from_chat_id),
vec![msg_id],
),
app.td_client
.forward_messages(to_chat_id, ChatId::new(from_chat_id), vec![msg_id]),
"Таймаут пересылки",
)
.await;

View File

@@ -6,8 +6,8 @@
//! - Ctrl+P: View pinned messages
//! - Ctrl+F: Search messages in chat
use crate::app::App;
use crate::app::methods::{modal::ModalMethods, search::SearchMethods};
use crate::app::App;
use crate::tdlib::TdClientTrait;
use crate::types::ChatId;
use crate::utils::{with_timeout, with_timeout_msg};
@@ -47,7 +47,8 @@ pub async fn handle_global_commands<T: TdClientTrait>(app: &mut App<T>, key: Key
KeyCode::Char('r') if has_ctrl => {
// Ctrl+R - обновить список чатов
app.status_message = Some("Обновление чатов...".to_string());
let _ = with_timeout(Duration::from_secs(5), app.td_client.load_chats(50)).await;
let _ =
with_timeout(Duration::from_secs(5), app.td_client.load_chats(50)).await;
// Синхронизируем muted чаты после обновления
app.td_client.sync_notification_muted_chats();
app.status_message = None;

View File

@@ -6,19 +6,22 @@
//! - profile: Profile helper functions
//! - chat: Keyboard input handling for open chat view
//! - chat_list: Navigation and interaction in the chat list
//! - chat_loader: All phases of chat message loading
//! - compose: Text input, editing, and message composition
//! - modal: Modal dialogs (delete confirmation, emoji picker, etc.)
//! - search: Search functionality (chat search, message search)
pub mod clipboard;
pub mod global;
pub mod profile;
pub mod chat;
pub mod chat_list;
pub mod chat_loader;
pub mod clipboard;
pub mod compose;
pub mod global;
pub mod modal;
pub mod profile;
pub mod search;
pub use chat_loader::{load_older_messages_if_needed, open_chat_and_load_data, process_pending_chat_init};
pub use clipboard::*;
pub use global::*;
pub use profile::get_available_actions_count;

View File

@@ -7,13 +7,13 @@
//! - Pinned messages view
//! - Profile information modal
use crate::app::{AccountSwitcherState, 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::types::{ChatId, MessageId};
use crate::utils::{with_timeout_msg, modal_handler::handle_yes_no};
use crate::input::handlers::get_available_actions_count;
use super::scroll_to_message;
use crate::utils::{modal_handler::handle_yes_no, with_timeout_msg};
use crossterm::event::{KeyCode, KeyEvent};
use std::time::Duration;
@@ -65,47 +65,45 @@ pub async fn handle_account_switcher<T: TdClientTrait>(
}
}
}
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
{
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.insert(*cursor_position, c);
chars.remove(*cursor_position - 1);
*name_input = chars.into_iter().collect();
*cursor_position += 1;
*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;
}
}
_ => {}
},
}
}
@@ -116,7 +114,11 @@ pub async fn handle_account_switcher<T: TdClientTrait>(
/// - Навигацию по действиям профиля (Up/Down)
/// - Выполнение выбранного действия (Enter): открыть в браузере, скопировать ID, покинуть группу
/// - Выход из режима профиля (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();
if confirmation_step > 0 {
@@ -189,10 +191,7 @@ pub async fn handle_profile_mode<T: TdClientTrait>(app: &mut App<T>, key: KeyEve
// Действие: Открыть в браузере
if let Some(username) = &profile.username {
if action_index == current_idx {
let url = format!(
"https://t.me/{}",
username.trim_start_matches('@')
);
let url = format!("https://t.me/{}", username.trim_start_matches('@'));
#[cfg(feature = "url-open")]
{
match open::that(&url) {
@@ -208,7 +207,7 @@ pub async fn handle_profile_mode<T: TdClientTrait>(app: &mut App<T>, key: KeyEve
#[cfg(not(feature = "url-open"))]
{
app.error_message = Some(
"Открытие URL недоступно (требуется feature 'url-open')".to_string()
"Открытие URL недоступно (требуется feature 'url-open')".to_string(),
);
}
return;
@@ -324,7 +323,11 @@ pub async fn handle_delete_confirmation<T: TdClientTrait>(app: &mut App<T>, key:
/// - Навигацию по сетке реакций: Left/Right, Up/Down (сетка 8x6)
/// - Добавление/удаление реакции (Enter)
/// - Выход из режима (Esc)
pub async fn handle_reaction_picker_mode<T: TdClientTrait>(app: &mut App<T>, _key: KeyEvent, command: Option<crate::config::Command>) {
pub async fn handle_reaction_picker_mode<T: TdClientTrait>(
app: &mut App<T>,
_key: KeyEvent,
command: Option<crate::config::Command>,
) {
match command {
Some(crate::config::Command::MoveLeft) => {
app.select_previous_reaction();
@@ -335,10 +338,8 @@ pub async fn handle_reaction_picker_mode<T: TdClientTrait>(app: &mut App<T>, _ke
app.needs_redraw = true;
}
Some(crate::config::Command::MoveUp) => {
if let crate::app::ChatState::ReactionPicker {
selected_index,
..
} = &mut app.chat_state
if let crate::app::ChatState::ReactionPicker { selected_index, .. } =
&mut app.chat_state
{
if *selected_index >= 8 {
*selected_index = selected_index.saturating_sub(8);
@@ -377,7 +378,11 @@ pub async fn handle_reaction_picker_mode<T: TdClientTrait>(app: &mut App<T>, _ke
/// - Навигацию по закреплённым сообщениям (Up/Down)
/// - Переход к сообщению в истории (Enter)
/// - Выход из режима (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 {
Some(crate::config::Command::Cancel) => {
app.exit_pinned_mode();

View File

@@ -5,15 +5,15 @@
//! - Message search mode
//! - Search query input
use crate::app::App;
use crate::app::methods::{navigation::NavigationMethods, search::SearchMethods};
use crate::app::App;
use crate::tdlib::TdClientTrait;
use crate::types::{ChatId, MessageId};
use crate::utils::with_timeout;
use crossterm::event::{KeyCode, KeyEvent};
use std::time::Duration;
use super::chat_list::open_chat_and_load_data;
use super::chat_loader::open_chat_and_load_data;
use super::scroll_to_message;
/// Обработка режима поиска по чатам
@@ -23,7 +23,11 @@ use super::scroll_to_message;
/// - Навигацию по отфильтрованному списку (Up/Down)
/// - Открытие выбранного чата (Enter)
/// - Отмену поиска (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 {
Some(crate::config::Command::Cancel) => {
app.cancel_search();
@@ -40,19 +44,17 @@ pub async fn handle_chat_search_mode<T: TdClientTrait>(app: &mut App<T>, key: Ke
Some(crate::config::Command::MoveUp) => {
app.previous_filtered_chat();
}
_ => {
match key.code {
KeyCode::Backspace => {
app.search_query.pop();
app.chat_list_state.select(Some(0));
}
KeyCode::Char(c) => {
app.search_query.push(c);
app.chat_list_state.select(Some(0));
}
_ => {}
_ => match key.code {
KeyCode::Backspace => {
app.search_query.pop();
app.chat_list_state.select(Some(0));
}
}
KeyCode::Char(c) => {
app.search_query.push(c);
app.chat_list_state.select(Some(0));
}
_ => {}
},
}
}
@@ -63,7 +65,11 @@ pub async fn handle_chat_search_mode<T: TdClientTrait>(app: &mut App<T>, key: Ke
/// - Переход к выбранному сообщению (Enter)
/// - Редактирование поискового запроса (Backspace, Char)
/// - Выход из режима поиска (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 {
Some(crate::config::Command::Cancel) => {
app.exit_message_search_mode();
@@ -80,33 +86,31 @@ pub async fn handle_message_search_mode<T: TdClientTrait>(app: &mut App<T>, key:
app.exit_message_search_mode();
}
}
_ => {
match key.code {
KeyCode::Char('N') => {
app.select_previous_search_result();
}
KeyCode::Char('n') => {
app.select_next_search_result();
}
KeyCode::Backspace => {
let Some(mut query) = app.get_search_query().map(|s| s.to_string()) else {
return;
};
query.pop();
app.update_search_query(query.clone());
perform_message_search(app, &query).await;
}
KeyCode::Char(c) => {
let Some(mut query) = app.get_search_query().map(|s| s.to_string()) else {
return;
};
query.push(c);
app.update_search_query(query.clone());
perform_message_search(app, &query).await;
}
_ => {}
_ => match key.code {
KeyCode::Char('N') => {
app.select_previous_search_result();
}
}
KeyCode::Char('n') => {
app.select_next_search_result();
}
KeyCode::Backspace => {
let Some(mut query) = app.get_search_query().map(|s| s.to_string()) else {
return;
};
query.pop();
app.update_search_query(query.clone());
perform_message_search(app, &query).await;
}
KeyCode::Char(c) => {
let Some(mut query) = app.get_search_query().map(|s| s.to_string()) else {
return;
};
query.push(c);
app.update_search_query(query.clone());
perform_message_search(app, &query).await;
}
_ => {}
},
}
}

View File

@@ -3,35 +3,26 @@
//! 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::InputMode;
use crate::app::methods::{
compose::ComposeMethods,
messages::MessageMethods,
modal::ModalMethods,
navigation::NavigationMethods,
search::SearchMethods,
};
use crate::tdlib::TdClientTrait;
use crate::input::handlers::{
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,
modal::{
handle_account_switcher,
handle_profile_mode, handle_profile_open, handle_delete_confirmation,
handle_reaction_picker_mode, handle_pinned_mode,
handle_account_switcher, handle_delete_confirmation, handle_pinned_mode,
handle_profile_mode, handle_profile_open, handle_reaction_picker_mode,
},
search::{handle_chat_search_mode, handle_message_search_mode},
compose::handle_forward_mode,
chat_list::handle_chat_list_navigation,
chat::{
handle_message_selection, handle_enter_key,
handle_open_chat_keyboard_input,
},
};
use crate::tdlib::TdClientTrait;
use crossterm::event::KeyEvent;
/// Обработка клавиши Esc в Normal mode
///
/// Закрывает чат с сохранением черновика
@@ -55,7 +46,10 @@ async fn handle_escape_normal<T: TdClientTrait>(app: &mut App<T>) {
let _ = app.td_client.set_draft_message(chat_id, draft_text).await;
} 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();
@@ -331,4 +325,3 @@ async fn navigate_to_adjacent_photo<T: TdClientTrait>(app: &mut App<T>, directio
};
app.status_message = Some(msg.to_string());
}

File diff suppressed because it is too large Load Diff

View File

@@ -29,6 +29,7 @@ use tdlib_rs::enums::Update;
use app::{App, AppScreen};
use constants::{POLL_TIMEOUT_MS, SHUTDOWN_TIMEOUT_SECS};
use input::{handle_auth_input, handle_main_input};
use input::handlers::process_pending_chat_init;
use tdlib::AuthState;
use utils::{disable_tdlib_logs, with_timeout_ignore};
@@ -37,10 +38,8 @@ 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" {
if i + 1 < args.len() {
return Some(args[i + 1].clone());
}
if args[i] == "--account" && i + 1 < args.len() {
return Some(args[i + 1].clone());
}
i += 1;
}
@@ -57,7 +56,7 @@ async fn main() -> Result<(), io::Error> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn"))
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn")),
)
.init();
@@ -70,18 +69,28 @@ async fn main() -> Result<(), io::Error> {
// Резолвим аккаунт из 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);
});
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),
account_arg
.as_deref()
.unwrap_or(&accounts_config.default_account),
)
.unwrap_or(db_path);
// Acquire per-account lock BEFORE raw mode (so error prints to normal terminal)
let account_lock = accounts::acquire_lock(
account_arg.as_deref().unwrap_or(&accounts_config.default_account),
)
.unwrap_or_else(|e| {
eprintln!("Error: {}", e);
std::process::exit(1);
});
// Отключаем логи TDLib ДО создания клиента
disable_tdlib_logs();
@@ -103,6 +112,7 @@ async fn main() -> Result<(), io::Error> {
// Create app state with account-specific db_path
let mut app = App::new(config, db_path);
app.current_account_name = account_name;
app.account_lock = Some(account_lock);
// Запускаем инициализацию TDLib в фоне (только для реального клиента)
let client_id = app.td_client.client_id();
@@ -111,15 +121,15 @@ async fn main() -> Result<(), io::Error> {
let db_path_str = app.td_client.db_path.to_string_lossy().to_string();
tokio::spawn(async move {
let _ = tdlib_rs::functions::set_tdlib_parameters(
false, // use_test_dc
db_path_str, // database_directory
"".to_string(), // files_directory
"".to_string(), // database_encryption_key
true, // use_file_database
true, // use_chat_info_database
true, // use_message_database
false, // use_secret_chats
if let Err(e) = tdlib_rs::functions::set_tdlib_parameters(
false, // use_test_dc
db_path_str, // database_directory
"".to_string(), // files_directory
"".to_string(), // database_encryption_key
true, // use_file_database
true, // use_chat_info_database
true, // use_message_database
false, // use_secret_chats
api_id,
api_hash,
"en".to_string(), // system_language_code
@@ -128,7 +138,10 @@ async fn main() -> Result<(), io::Error> {
env!("CARGO_PKG_VERSION").to_string(), // application_version
client_id,
)
.await;
.await
{
tracing::error!("set_tdlib_parameters failed: {:?}", e);
}
});
let res = run_app(&mut terminal, &mut app).await;
@@ -160,7 +173,7 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
let polling_handle = tokio::spawn(async move {
while !should_stop_clone.load(Ordering::Relaxed) {
// receive() с таймаутом 0.1 сек чтобы периодически проверять флаг
let result = tokio::task::spawn_blocking(|| tdlib_rs::receive()).await;
let result = tokio::task::spawn_blocking(tdlib_rs::receive).await;
if let Ok(Some((update, _client_id))) = result {
if update_tx.send(update).is_err() {
break; // Канал закрыт, выходим
@@ -203,6 +216,47 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
}
}
}
// Если это фото ждёт открытия в модалке — открываем
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 {
@@ -247,7 +301,7 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
// Проверяем завершение воспроизведения
if playback.position >= playback.duration
|| app.audio_player.as_ref().map_or(false, |p| p.is_stopped())
|| app.audio_player.as_ref().is_some_and(|p| p.is_stopped())
{
stop_playback = true;
}
@@ -292,7 +346,11 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
let _ = tdlib_rs::functions::close(app.td_client.client_id()).await;
// Ждём завершения 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(());
}
@@ -329,86 +387,26 @@ 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() {
// Загружаем недостающие 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;
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();

View File

@@ -6,11 +6,13 @@ 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 {
@@ -33,10 +35,7 @@ impl ImageCache {
let path = self.cache_dir.join(format!("{}.jpg", file_id));
if path.exists() {
// Обновляем mtime для LRU
let _ = filetime::set_file_mtime(
&path,
filetime::FileTime::now(),
);
let _ = filetime::set_file_mtime(&path, filetime::FileTime::now());
Some(path)
} else {
None
@@ -47,8 +46,7 @@ impl ImageCache {
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))?;
fs::copy(source_path, &dest).map_err(|e| format!("Ошибка кэширования: {}", e))?;
// Evict если превышен лимит
self.evict_if_needed();
@@ -93,6 +91,7 @@ impl ImageCache {
}
/// Обёртка для установки mtime без внешней зависимости
#[allow(dead_code)]
mod filetime {
use std::path::Path;

View File

@@ -108,6 +108,7 @@ impl ImageRenderer {
}
/// Удаляет протокол для сообщения
#[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);
@@ -115,6 +116,7 @@ impl ImageRenderer {
}
/// Очищает все протоколы
#[allow(dead_code)]
pub fn clear(&mut self) {
self.protocols.clear();
self.access_order.clear();

View File

@@ -12,9 +12,12 @@ pub enum MessageGroup {
/// Разделитель даты (день в формате timestamp)
DateSeparator(i32),
/// Заголовок отправителя (is_outgoing, sender_name)
SenderHeader { is_outgoing: bool, sender_name: String },
SenderHeader {
is_outgoing: bool,
sender_name: String,
},
/// Сообщение
Message(MessageInfo),
Message(Box<MessageInfo>),
/// Альбом (группа фото с одинаковым media_album_id)
Album(Vec<MessageInfo>),
}
@@ -75,7 +78,7 @@ pub fn group_messages(messages: &[MessageInfo]) -> Vec<MessageGroup> {
result.push(MessageGroup::Album(std::mem::take(acc)));
} else {
// Одно сообщение — не альбом
result.push(MessageGroup::Message(acc.remove(0)));
result.push(MessageGroup::Message(Box::new(acc.remove(0))));
}
}
@@ -106,10 +109,7 @@ pub fn group_messages(messages: &[MessageInfo]) -> Vec<MessageGroup> {
if show_sender_header {
// Flush аккумулятор перед сменой отправителя
flush_album(&mut album_acc, &mut result);
result.push(MessageGroup::SenderHeader {
is_outgoing: msg.is_outgoing(),
sender_name,
});
result.push(MessageGroup::SenderHeader { is_outgoing: msg.is_outgoing(), sender_name });
last_sender = Some(current_sender);
}
@@ -137,7 +137,7 @@ pub fn group_messages(messages: &[MessageInfo]) -> Vec<MessageGroup> {
// Обычное сообщение (не альбом) — flush аккумулятор
flush_album(&mut album_acc, &mut result);
result.push(MessageGroup::Message(msg.clone()));
result.push(MessageGroup::Message(Box::new(msg.clone())));
}
// Flush оставшийся аккумулятор

View File

@@ -10,6 +10,7 @@ use std::collections::HashSet;
use notify_rust::{Notification, Timeout};
/// Manages desktop notifications
#[allow(dead_code)]
pub struct NotificationManager {
/// Whether notifications are enabled
enabled: bool,
@@ -25,6 +26,7 @@ pub struct NotificationManager {
urgency: String,
}
#[allow(dead_code)]
impl NotificationManager {
/// Creates a new notification manager with default settings
pub fn new() -> Self {
@@ -39,11 +41,7 @@ impl NotificationManager {
}
/// Creates a notification manager with custom settings
pub fn with_config(
enabled: bool,
only_mentions: bool,
show_preview: bool,
) -> Self {
pub fn with_config(enabled: bool, only_mentions: bool, show_preview: bool) -> Self {
Self {
enabled,
muted_chats: HashSet::new(),
@@ -311,22 +309,13 @@ mod tests {
#[test]
fn test_beautify_media_labels() {
// Test photo
assert_eq!(
NotificationManager::beautify_media_labels("[Фото]"),
"📷 Фото"
);
assert_eq!(NotificationManager::beautify_media_labels("[Фото]"), "📷 Фото");
// Test video
assert_eq!(
NotificationManager::beautify_media_labels("[Видео]"),
"🎥 Видео"
);
assert_eq!(NotificationManager::beautify_media_labels("[Видео]"), "🎥 Видео");
// Test sticker with emoji
assert_eq!(
NotificationManager::beautify_media_labels("[Стикер: 😊]"),
"🎨 Стикер: 😊]"
);
assert_eq!(NotificationManager::beautify_media_labels("[Стикер: 😊]"), "🎨 Стикер: 😊]");
// Test audio with title
assert_eq!(
@@ -341,10 +330,7 @@ mod tests {
);
// Test regular text (no changes)
assert_eq!(
NotificationManager::beautify_media_labels("Hello, world!"),
"Hello, world!"
);
assert_eq!(NotificationManager::beautify_media_labels("Hello, world!"), "Hello, world!");
// Test mixed content
assert_eq!(

View File

@@ -5,6 +5,7 @@ use tdlib_rs::functions;
///
/// Отслеживает текущий этап аутентификации пользователя,
/// от инициализации TDLib до полной авторизации.
#[allow(dead_code)]
#[derive(Debug, Clone, PartialEq)]
pub enum AuthState {
/// Ожидание параметров TDLib (начальное состояние).
@@ -72,6 +73,7 @@ pub struct AuthManager {
client_id: i32,
}
#[allow(dead_code)]
impl AuthManager {
/// Создает новый менеджер авторизации.
///
@@ -83,10 +85,7 @@ impl AuthManager {
///
/// Новый экземпляр `AuthManager` в состоянии `WaitTdlibParameters`.
pub fn new(client_id: i32) -> Self {
Self {
state: AuthState::WaitTdlibParameters,
client_id,
}
Self { state: AuthState::WaitTdlibParameters, client_id }
}
/// Проверяет, завершена ли авторизация.

View File

@@ -3,7 +3,7 @@
//! This module contains utility functions for managing 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 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() {
// Удаляем из списка если уже был добавлен
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;
}
@@ -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);
client.user_cache.chat_user_ids.insert(chat_id, user_id);
// Проверяем, есть ли уже username в кэше (peek не обновляет LRU)
client.user_cache.user_usernames
client
.user_cache
.user_usernames
.peek(&user_id)
.map(|u| format!("@{}", u))
}

View File

@@ -197,10 +197,7 @@ impl ChatManager {
ChatType::Secret(_) => "Секретный чат",
};
let is_group = matches!(
&chat.r#type,
ChatType::Supergroup(_) | ChatType::BasicGroup(_)
);
let is_group = matches!(&chat.r#type, ChatType::Supergroup(_) | ChatType::BasicGroup(_));
// Для личных чатов получаем информацию о пользователе
let (bio, phone_number, username, online_status) = if let ChatType::Private(private_chat) =
@@ -208,13 +205,15 @@ impl ChatManager {
{
match functions::get_user(private_chat.user_id, self.client_id).await {
Ok(tdlib_rs::enums::User::User(user)) => {
let bio_opt = if let Ok(tdlib_rs::enums::UserFullInfo::UserFullInfo(full_info)) =
functions::get_user_full_info(private_chat.user_id, self.client_id).await
{
full_info.bio.map(|b| b.text)
} else {
None
};
let bio_opt =
if let Ok(tdlib_rs::enums::UserFullInfo::UserFullInfo(full_info)) =
functions::get_user_full_info(private_chat.user_id, self.client_id)
.await
{
full_info.bio.map(|b| b.text)
} else {
None
};
let online_status_str = match user.status {
tdlib_rs::enums::UserStatus::Online(_) => Some("В сети".to_string()),
@@ -234,10 +233,7 @@ impl ChatManager {
_ => None,
};
let username_opt = user
.usernames
.as_ref()
.map(|u| u.editable_username.clone());
let username_opt = user.usernames.as_ref().map(|u| u.editable_username.clone());
(bio_opt, Some(user.phone_number.clone()), username_opt, online_status_str)
}
@@ -257,7 +253,10 @@ impl ChatManager {
} else {
None
};
let link = full_info.invite_link.as_ref().map(|l| l.invite_link.clone());
let link = full_info
.invite_link
.as_ref()
.map(|l| l.invite_link.clone());
(Some(full_info.member_count), desc, link)
}
_ => (None, None, None),
@@ -324,7 +323,8 @@ impl ChatManager {
/// ).await;
/// ```
pub async fn send_chat_action(&self, chat_id: ChatId, action: ChatAction) {
let _ = functions::send_chat_action(chat_id.as_i64(), 0, Some(action), self.client_id).await;
let _ =
functions::send_chat_action(chat_id.as_i64(), 0, Some(action), self.client_id).await;
}
/// Очищает устаревший typing-статус.
@@ -371,6 +371,7 @@ impl ChatManager {
/// println!("Status: {}", typing_text);
/// }
/// ```
#[allow(dead_code)]
pub fn get_typing_text(&self) -> Option<String> {
self.typing_status
.as_ref()

View File

@@ -1,20 +1,17 @@
use crate::types::{ChatId, MessageId, UserId};
use std::env;
use std::path::PathBuf;
use tdlib_rs::enums::{
ChatList, ConnectionState, Update, UserStatus,
Chat as TdChat
};
use tdlib_rs::types::Message as TdMessage;
use tdlib_rs::enums::{Chat as TdChat, ChatList, ConnectionState, Update, UserStatus};
use tdlib_rs::functions;
use tdlib_rs::types::Message as TdMessage;
use super::auth::{AuthManager, AuthState};
use super::chats::ChatManager;
use super::messages::MessageManager;
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 crate::notifications::NotificationManager;
@@ -61,6 +58,7 @@ pub struct TdClient {
pub network_state: NetworkState,
}
#[allow(dead_code)]
impl TdClient {
/// Creates a new TDLib client instance.
///
@@ -75,16 +73,15 @@ impl TdClient {
/// A new `TdClient` instance ready for authentication.
pub fn new(db_path: PathBuf) -> Self {
// Пробуем загрузить credentials из Config (файл или env)
let (api_id, api_hash) = crate::config::Config::load_credentials()
.unwrap_or_else(|_| {
// Fallback на прямое чтение из env (старое поведение)
let api_id = env::var("API_ID")
.unwrap_or_else(|_| "0".to_string())
.parse()
.unwrap_or(0);
let api_hash = env::var("API_HASH").unwrap_or_default();
(api_id, api_hash)
});
let (api_id, api_hash) = crate::config::Config::load_credentials().unwrap_or_else(|_| {
// Fallback на прямое чтение из env (старое поведение)
let api_id = env::var("API_ID")
.unwrap_or_else(|_| "0".to_string())
.parse()
.unwrap_or(0);
let api_hash = env::var("API_HASH").unwrap_or_default();
(api_id, api_hash)
});
let client_id = tdlib_rs::create_client();
@@ -106,9 +103,11 @@ impl TdClient {
/// Configures notification manager from app config
pub fn configure_notifications(&mut self, config: &crate::config::NotificationsConfig) {
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_urgency(config.urgency.clone());
self.notification_manager
.set_urgency(config.urgency.clone());
// Note: show_preview is used when formatting notification body
}
@@ -116,7 +115,8 @@ impl TdClient {
///
/// Should be called after chats are loaded to ensure muted chats don't trigger notifications.
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
@@ -257,12 +257,17 @@ impl TdClient {
.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
}
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(
@@ -442,7 +447,10 @@ impl TdClient {
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;
}
@@ -450,7 +458,9 @@ impl TdClient {
&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
}
@@ -481,19 +491,6 @@ impl TdClient {
// ==================== Helper методы для упрощения обработки updates ====================
/// Находит мутабельную ссылку на чат по ID.
///
/// Упрощает повторяющийся паттерн `self.chats_mut().iter_mut().find(...)`.
///
/// # Arguments
///
/// * `chat_id` - ID чата для поиска
///
/// # Returns
///
/// * `Some(&mut ChatInfo)` - если чат найден
/// * `None` - если чат не найден
/// Обрабатываем одно обновление от TDLib
pub fn handle_update(&mut self, update: Update) {
match update {
@@ -519,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| {
chat.order = pos.order;
chat.is_pinned = pos.is_pinned;
@@ -530,27 +531,43 @@ impl TdClient {
self.chats_mut().sort_by(|a, b| b.order.cmp(&a.order));
}
Update::ChatReadInbox(update) => {
crate::tdlib::chat_helpers::update_chat(self, ChatId::new(update.chat_id), |chat| {
chat.unread_count = update.unread_count;
});
crate::tdlib::chat_helpers::update_chat(
self,
ChatId::new(update.chat_id),
|chat| {
chat.unread_count = update.unread_count;
},
);
}
Update::ChatUnreadMentionCount(update) => {
crate::tdlib::chat_helpers::update_chat(self, ChatId::new(update.chat_id), |chat| {
chat.unread_mention_count = update.unread_mention_count;
});
crate::tdlib::chat_helpers::update_chat(
self,
ChatId::new(update.chat_id),
|chat| {
chat.unread_mention_count = update.unread_mention_count;
},
);
}
Update::ChatNotificationSettings(update) => {
crate::tdlib::chat_helpers::update_chat(self, ChatId::new(update.chat_id), |chat| {
// mute_for > 0 означает что чат замьючен
chat.is_muted = update.notification_settings.mute_for > 0;
});
crate::tdlib::chat_helpers::update_chat(
self,
ChatId::new(update.chat_id),
|chat| {
// mute_for > 0 означает что чат замьючен
chat.is_muted = update.notification_settings.mute_for > 0;
},
);
}
Update::ChatReadOutbox(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| {
chat.last_read_outbox_message_id = last_read_msg_id;
});
crate::tdlib::chat_helpers::update_chat(
self,
ChatId::new(update.chat_id),
|chat| {
chat.last_read_outbox_message_id = last_read_msg_id;
},
);
// Если это текущий открытый чат — обновляем is_read у сообщений
if Some(ChatId::new(update.chat_id)) == self.current_chat_id() {
for msg in self.current_chat_messages_mut().iter_mut() {
@@ -588,7 +605,9 @@ impl TdClient {
UserStatus::LastMonth(_) => UserOnlineStatus::LastMonth,
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) => {
// Обновляем состояние сетевого соединения
@@ -616,13 +635,15 @@ impl TdClient {
}
}
// 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;
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()),
}
}
@@ -644,7 +665,7 @@ impl TdClient {
let db_path_str = new_client.db_path.to_string_lossy().to_string();
tokio::spawn(async move {
let _ = functions::set_tdlib_parameters(
if let Err(e) = functions::set_tdlib_parameters(
false,
db_path_str,
"".to_string(),
@@ -661,7 +682,10 @@ impl TdClient {
env!("CARGO_PKG_VERSION").to_string(),
new_client_id,
)
.await;
.await
{
tracing::error!("set_tdlib_parameters failed on recreate: {:?}", e);
}
});
// 4. Replace self

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,10 @@
use super::client::TdClient;
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 async_trait::async_trait;
use std::path::PathBuf;
@@ -52,11 +55,19 @@ impl TdClientTrait for TdClient {
}
// ============ 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
}
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
}
@@ -68,7 +79,11 @@ impl TdClientTrait for TdClient {
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
}
@@ -148,7 +163,8 @@ impl TdClientTrait for TdClient {
chat_id: ChatId,
message_id: MessageId,
) -> 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(
@@ -276,7 +292,8 @@ impl TdClientTrait for TdClient {
// ============ Notification methods ============
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 ============

View File

@@ -7,7 +7,10 @@ use crate::types::MessageId;
use tdlib_rs::enums::{MessageContent, MessageSender};
use tdlib_rs::types::Message as TdMessage;
use super::types::{ForwardInfo, MediaInfo, PhotoDownloadState, PhotoInfo, ReactionInfo, ReplyInfo, VoiceDownloadState, VoiceInfo};
use super::types::{
ForwardInfo, MediaInfo, PhotoDownloadState, PhotoInfo, ReactionInfo, ReplyInfo,
VoiceDownloadState, VoiceInfo,
};
/// Извлекает текст контента из TDLib Message
///
@@ -95,9 +98,9 @@ pub async fn extract_sender_name(msg: &TdMessage, client_id: i32) -> String {
match &msg.sender_id {
MessageSender::User(user) => {
match tdlib_rs::functions::get_user(user.user_id, client_id).await {
Ok(tdlib_rs::enums::User::User(u)) => {
format!("{} {}", u.first_name, u.last_name).trim().to_string()
}
Ok(tdlib_rs::enums::User::User(u)) => format!("{} {}", u.first_name, u.last_name)
.trim()
.to_string(),
_ => format!("User {}", user.user_id),
}
}
@@ -155,12 +158,7 @@ pub fn extract_media_info(msg: &TdMessage) -> Option<MediaInfo> {
PhotoDownloadState::NotDownloaded
};
Some(MediaInfo::Photo(PhotoInfo {
file_id,
width,
height,
download_state,
}))
Some(MediaInfo::Photo(PhotoInfo { file_id, width, height, download_state }))
}
MessageContent::MessageVoiceNote(v) => {
let file_id = v.voice_note.voice.id;

View File

@@ -11,11 +11,7 @@ use super::client::TdClient;
use super::types::{ForwardInfo, MessageInfo, ReactionInfo, ReplyInfo};
/// Конвертирует TDLib сообщение в MessageInfo
pub fn convert_message(
client: &mut TdClient,
message: &TdMessage,
chat_id: ChatId,
) -> MessageInfo {
pub fn convert_message(client: &mut TdClient, message: &TdMessage, chat_id: ChatId) -> MessageInfo {
let sender_name = match &message.sender_id {
tdlib_rs::enums::MessageSender::User(user) => {
// Пробуем получить имя из кеша (get обновляет LRU порядок)
@@ -120,7 +116,7 @@ pub fn extract_reply_info(client: &TdClient, message: &TdMessage) -> Option<Repl
let sender_name = reply
.origin
.as_ref()
.map(|origin| get_origin_sender_name(origin))
.map(get_origin_sender_name)
.unwrap_or_else(|| {
// Пробуем найти оригинальное сообщение в текущем списке
let reply_msg_id = MessageId::new(reply.message_id);
@@ -138,12 +134,7 @@ pub fn extract_reply_info(client: &TdClient, message: &TdMessage) -> Option<Repl
.quote
.as_ref()
.map(|q| q.text.text.clone())
.or_else(|| {
reply
.content
.as_ref()
.map(TdClient::extract_content_text)
})
.or_else(|| reply.content.as_ref().map(TdClient::extract_content_text))
.unwrap_or_else(|| {
// Пробуем найти в текущих сообщениях
client
@@ -154,11 +145,7 @@ pub fn extract_reply_info(client: &TdClient, message: &TdMessage) -> Option<Repl
.unwrap_or_default()
});
Some(ReplyInfo {
message_id: reply_msg_id,
sender_name,
text,
})
Some(ReplyInfo { message_id: reply_msg_id, sender_name, text })
}
_ => None,
}
@@ -219,12 +206,7 @@ pub fn update_reply_info_from_loaded_messages(client: &mut TdClient) {
let msg_data: std::collections::HashMap<i64, (String, String)> = client
.current_chat_messages()
.iter()
.map(|m| {
(
m.id().as_i64(),
(m.sender_name().to_string(), m.text().to_string()),
)
})
.map(|m| (m.id().as_i64(), (m.sender_name().to_string(), m.text().to_string())))
.collect();
// Обновляем reply_to для сообщений с неполными данными

View File

@@ -12,8 +12,8 @@ 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,
extract_content_text, extract_entities, extract_forward_info, extract_media_info,
extract_reactions, extract_reply_info, extract_sender_name,
};
// Извлекаем все части сообщения используя вспомогательные функции
@@ -122,12 +122,7 @@ impl MessageManager {
};
// Extract text preview (first 50 chars)
let text_preview: String = orig_info
.content
.text
.chars()
.take(50)
.collect();
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

View File

@@ -95,7 +95,8 @@ impl MessageManager {
// Ограничиваем размер списка (удаляем старые с начала)
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));
self.current_chat_messages
.drain(0..(self.current_chat_messages.len() - MAX_MESSAGES_IN_CHAT));
}
}
}

View File

@@ -2,9 +2,13 @@
use crate::constants::TDLIB_MESSAGE_LIMIT;
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::types::{FormattedText, InputMessageReplyToMessage, InputMessageText, TextParseModeMarkdown};
use tdlib_rs::types::{
FormattedText, InputMessageReplyToMessage, InputMessageText, TextParseModeMarkdown,
};
use tokio::time::{sleep, Duration};
use crate::tdlib::types::{MessageInfo, ReplyInfo};
@@ -103,9 +107,10 @@ impl MessageManager {
// Если это первая загрузка и получили мало сообщений - продолжаем попытки
// TDLib может подгружать данные с сервера постепенно
if all_messages.is_empty() &&
received_count < (chunk_size as usize) &&
attempt < max_attempts_per_chunk {
if all_messages.is_empty()
&& received_count < (chunk_size as usize)
&& attempt < max_attempts_per_chunk
{
// Даём TDLib время на синхронизацию с сервером
sleep(Duration::from_millis(100)).await;
continue;
@@ -201,11 +206,9 @@ impl MessageManager {
match result {
Ok(tdlib_rs::enums::Messages::Messages(messages_obj)) => {
let mut messages = Vec::new();
for msg_opt in messages_obj.messages.iter().rev() {
if let Some(msg) = msg_opt {
if let Some(info) = self.convert_message(msg).await {
messages.push(info);
}
for msg in messages_obj.messages.iter().rev().flatten() {
if let Some(info) = self.convert_message(msg).await {
messages.push(info);
}
}
Ok(messages)
@@ -233,17 +236,20 @@ impl MessageManager {
/// let pinned = msg_manager.get_pinned_messages(chat_id).await?;
/// println!("Found {} pinned messages", pinned.len());
/// ```
pub async fn get_pinned_messages(&mut self, chat_id: ChatId) -> Result<Vec<MessageInfo>, String> {
pub async fn get_pinned_messages(
&mut self,
chat_id: ChatId,
) -> Result<Vec<MessageInfo>, String> {
let result = functions::search_chat_messages(
chat_id.as_i64(),
String::new(),
None,
0, // from_message_id
0, // offset
100, // limit
0, // from_message_id
0, // offset
100, // limit
Some(SearchMessagesFilter::Pinned),
0, // message_thread_id
0, // saved_messages_topic_id
0, // message_thread_id
0, // saved_messages_topic_id
self.client_id,
)
.await;
@@ -310,8 +316,8 @@ impl MessageManager {
0, // offset
100, // limit
None,
0, // message_thread_id
0, // saved_messages_topic_id
0, // message_thread_id
0, // saved_messages_topic_id
self.client_id,
)
.await;
@@ -381,15 +387,9 @@ impl MessageManager {
.await
{
Ok(tdlib_rs::enums::FormattedText::FormattedText(ft)) => {
FormattedText {
text: ft.text,
entities: ft.entities,
}
FormattedText { text: ft.text, entities: ft.entities }
}
Err(_) => FormattedText {
text: text.clone(),
entities: vec![],
},
Err(_) => FormattedText { text: text.clone(), entities: vec![] },
};
let content = InputMessageContent::InputMessageText(InputMessageText {
@@ -460,15 +460,9 @@ impl MessageManager {
.await
{
Ok(tdlib_rs::enums::FormattedText::FormattedText(ft)) => {
FormattedText {
text: ft.text,
entities: ft.entities,
}
FormattedText { text: ft.text, entities: ft.entities }
}
Err(_) => FormattedText {
text: text.clone(),
entities: vec![],
},
Err(_) => FormattedText { text: text.clone(), entities: vec![] },
};
let content = InputMessageContent::InputMessageText(InputMessageText {
@@ -477,8 +471,13 @@ impl MessageManager {
clear_draft: true,
});
let result =
functions::edit_message_text(chat_id.as_i64(), message_id.as_i64(), content, self.client_id).await;
let result = functions::edit_message_text(
chat_id.as_i64(),
message_id.as_i64(),
content,
self.client_id,
)
.await;
match result {
Ok(tdlib_rs::enums::Message::Message(msg)) => self
@@ -509,7 +508,8 @@ impl MessageManager {
) -> Result<(), String> {
let message_ids_i64: Vec<i64> = message_ids.into_iter().map(|id| id.as_i64()).collect();
let result =
functions::delete_messages(chat_id.as_i64(), message_ids_i64, revoke, self.client_id).await;
functions::delete_messages(chat_id.as_i64(), message_ids_i64, revoke, self.client_id)
.await;
match result {
Ok(_) => Ok(()),
Err(e) => Err(format!("Ошибка удаления: {:?}", e)),
@@ -577,17 +577,15 @@ impl MessageManager {
reply_to: None,
date: 0,
input_message_text: InputMessageContent::InputMessageText(InputMessageText {
text: FormattedText {
text: text.clone(),
entities: vec![],
},
text: FormattedText { text: text.clone(), entities: vec![] },
link_preview_options: None,
clear_draft: false,
}),
})
};
let result = functions::set_chat_draft_message(chat_id.as_i64(), 0, draft, self.client_id).await;
let result =
functions::set_chat_draft_message(chat_id.as_i64(), 0, draft, self.client_id).await;
match result {
Ok(_) => Ok(()),
@@ -612,7 +610,8 @@ impl MessageManager {
for (chat_id, message_ids) in batch {
let ids: Vec<i64> = message_ids.iter().map(|id| id.as_i64()).collect();
let _ = functions::view_messages(chat_id.as_i64(), ids, None, true, self.client_id).await;
let _ =
functions::view_messages(chat_id.as_i64(), ids, None, true, self.client_id).await;
}
}
}

View File

@@ -4,8 +4,8 @@ mod chat_helpers; // Chat management helpers
pub mod chats;
pub mod client;
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_converter; // Message conversion utilities (for client.rs)
pub mod messages;
pub mod reactions;
pub mod r#trait;
@@ -17,6 +17,7 @@ pub mod users;
pub use auth::AuthState;
pub use client::TdClient;
pub use r#trait::TdClientTrait;
#[allow(unused_imports)]
pub use types::{
ChatInfo, FolderInfo, MediaInfo, MessageBuilder, MessageInfo, NetworkState, PhotoDownloadState,
PhotoInfo, PlaybackState, PlaybackStatus, ProfileInfo, ReplyInfo, UserOnlineStatus,

View File

@@ -69,7 +69,8 @@ impl ReactionManager {
message_id: MessageId,
) -> Result<Vec<String>, String> {
// Получаем сообщение
let msg_result = functions::get_message(chat_id.as_i64(), message_id.as_i64(), self.client_id).await;
let msg_result =
functions::get_message(chat_id.as_i64(), message_id.as_i64(), self.client_id).await;
let _msg = match msg_result {
Ok(m) => m,
Err(e) => return Err(format!("Ошибка получения сообщения: {:?}", e)),

View File

@@ -14,6 +14,7 @@ use super::ChatInfo;
///
/// This trait defines the interface for both real and fake TDLib clients,
/// enabling dependency injection and easier testing.
#[allow(dead_code)]
#[async_trait]
pub trait TdClientTrait: Send {
// ============ Auth methods ============
@@ -32,11 +33,23 @@ pub trait TdClientTrait: Send {
fn clear_stale_typing_status(&mut self) -> bool;
// ============ Message methods ============
async fn get_chat_history(&mut self, chat_id: ChatId, limit: i32) -> Result<Vec<MessageInfo>, String>;
async fn load_older_messages(&mut self, chat_id: ChatId, from_message_id: MessageId) -> Result<Vec<MessageInfo>, String>;
async fn get_chat_history(
&mut self,
chat_id: ChatId,
limit: i32,
) -> Result<Vec<MessageInfo>, String>;
async fn load_older_messages(
&mut self,
chat_id: ChatId,
from_message_id: MessageId,
) -> Result<Vec<MessageInfo>, String>;
async fn get_pinned_messages(&mut self, chat_id: ChatId) -> Result<Vec<MessageInfo>, String>;
async fn load_current_pinned_message(&mut self, chat_id: ChatId);
async fn search_messages(&self, chat_id: ChatId, query: &str) -> Result<Vec<MessageInfo>, String>;
async fn search_messages(
&self,
chat_id: ChatId,
query: &str,
) -> Result<Vec<MessageInfo>, String>;
async fn send_message(
&mut self,

View File

@@ -71,6 +71,7 @@ pub struct PhotoInfo {
}
/// Состояние загрузки фотографии
#[allow(dead_code)]
#[derive(Debug, Clone)]
pub enum PhotoDownloadState {
NotDownloaded,
@@ -80,6 +81,7 @@ pub enum PhotoDownloadState {
}
/// Информация о голосовом сообщении
#[allow(dead_code)]
#[derive(Debug, Clone)]
pub struct VoiceInfo {
pub file_id: i32,
@@ -91,6 +93,7 @@ pub struct VoiceInfo {
}
/// Состояние загрузки голосового сообщения
#[allow(dead_code)]
#[derive(Debug, Clone)]
pub enum VoiceDownloadState {
NotDownloaded,
@@ -155,6 +158,7 @@ pub struct MessageInfo {
impl MessageInfo {
/// Создать новое сообщение
#[allow(clippy::too_many_arguments)]
pub fn new(
id: MessageId,
sender_name: String,
@@ -179,11 +183,7 @@ impl MessageInfo {
edit_date,
media_album_id: 0,
},
content: MessageContent {
text: content,
entities,
media: None,
},
content: MessageContent { text: content, entities, media: None },
state: MessageState {
is_outgoing,
is_read,
@@ -191,11 +191,7 @@ impl MessageInfo {
can_be_deleted_only_for_self,
can_be_deleted_for_all_users,
},
interactions: MessageInteractions {
reply_to,
forward_from,
reactions,
},
interactions: MessageInteractions { reply_to, forward_from, reactions },
}
}
@@ -251,10 +247,7 @@ impl MessageInfo {
/// Checks if the message contains a mention (@username or user mention)
pub fn has_mention(&self) -> bool {
self.content.entities.iter().any(|entity| {
matches!(
entity.r#type,
TextEntityType::Mention | TextEntityType::MentionName(_)
)
matches!(entity.r#type, TextEntityType::Mention | TextEntityType::MentionName(_))
})
}
@@ -293,6 +286,7 @@ impl MessageInfo {
}
/// Возвращает мутабельную ссылку на 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),
@@ -500,7 +494,6 @@ impl MessageBuilder {
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -568,9 +561,7 @@ mod tests {
#[test]
fn test_message_builder_with_reactions() {
let reaction = ReactionInfo {
emoji: "👍".to_string(),
count: 5,
is_chosen: true,
emoji: "👍".to_string(), count: 5, is_chosen: true
};
let message = MessageBuilder::new(MessageId::new(300))
@@ -628,9 +619,9 @@ mod tests {
.entities(vec![TextEntity {
offset: 6,
length: 4,
r#type: TextEntityType::MentionName(
tdlib_rs::types::TextEntityTypeMentionName { user_id: 123 },
),
r#type: TextEntityType::MentionName(tdlib_rs::types::TextEntityTypeMentionName {
user_id: 123,
}),
}])
.build();
assert!(message_with_mention_name.has_mention());
@@ -706,6 +697,7 @@ pub struct ImageModalState {
}
/// Состояние воспроизведения голосового сообщения
#[allow(dead_code)]
#[derive(Debug, Clone)]
pub struct PlaybackState {
/// ID сообщения, которое воспроизводится
@@ -721,6 +713,7 @@ pub struct PlaybackState {
}
/// Статус воспроизведения
#[allow(dead_code)]
#[derive(Debug, Clone, PartialEq)]
pub enum PlaybackStatus {
Playing,

View File

@@ -5,12 +5,10 @@
use crate::types::{ChatId, MessageId, UserId};
use std::time::Instant;
use tdlib_rs::enums::{
AuthorizationState, ChatAction, ChatList, MessageSender,
};
use tdlib_rs::enums::{AuthorizationState, ChatAction, ChatList, MessageSender};
use tdlib_rs::types::{
UpdateChatAction, UpdateChatDraftMessage, UpdateChatPosition,
UpdateMessageInteractionInfo, UpdateMessageSendSucceeded, UpdateNewMessage, UpdateUser,
UpdateChatAction, UpdateChatDraftMessage, UpdateChatPosition, UpdateMessageInteractionInfo,
UpdateMessageSendSucceeded, UpdateNewMessage, UpdateUser,
};
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() {
// Find and clone chat info to avoid borrow checker issues
if let Some(chat) = client.chats().iter().find(|c| c.id == chat_id).cloned() {
let msg_info = crate::tdlib::message_converter::convert_message(client, &new_msg.message, chat_id);
let msg_info =
crate::tdlib::message_converter::convert_message(client, &new_msg.message, chat_id);
// Get sender name (from message or user cache)
let sender_name = msg_info.sender_name();
// Send notification
let _ = client.notification_manager.notify_new_message(
&chat,
&msg_info,
sender_name,
);
let _ = client
.notification_manager
.notify_new_message(&chat, &msg_info, sender_name);
}
return;
}
// Добавляем новое сообщение если это текущий открытый чат
let msg_info = crate::tdlib::message_converter::convert_message(client, &new_msg.message, chat_id);
let msg_info =
crate::tdlib::message_converter::convert_message(client, &new_msg.message, chat_id);
let msg_id = msg_info.id();
let is_incoming = !msg_info.is_outgoing();
@@ -74,7 +72,9 @@ pub fn handle_new_message_update(client: &mut TdClient, new_msg: UpdateNewMessag
client.push_message(msg_info.clone());
// Если это входящее сообщение — добавляем в очередь для отметки как прочитанное
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::RecordingVideoNote => Some("записывает видеосообщение...".to_string()),
ChatAction::UploadingVideoNote(_) => Some("отправляет видеосообщение...".to_string()),
ChatAction::Cancel | _ => None, // Отмена или неизвестное действие
_ => None, // Отмена или неизвестное действие
};
match action_text {
@@ -181,14 +181,21 @@ pub fn handle_user_update(client: &mut TdClient, update: UpdateUser) {
} else {
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)
if let Some(username) = user.usernames
if let Some(username) = user
.usernames
.as_ref()
.and_then(|u| u.active_usernames.first())
{
client.user_cache.user_usernames.insert(UserId::new(user.id), username.to_string());
client
.user_cache
.user_usernames
.insert(UserId::new(user.id), username.to_string());
// Обновляем username в чатах, связанных с этим пользователем
for (&chat_id, &user_id) in &client.user_cache.chat_user_ids.clone() {
if user_id == UserId::new(user.id) {
@@ -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 из старого сообщения (если было)
let old_reply = client.current_chat_messages()[idx]

View File

@@ -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);
// Обновляем статус
@@ -211,6 +213,7 @@ impl UserCache {
/// # Returns
///
/// Имя пользователя (first_name + last_name) или "User {id}" если не найден.
#[allow(dead_code)]
pub async fn get_user_name(&self, user_id: UserId) -> String {
// Сначала пытаемся получить из кэша
if let Some(name) = self.user_names.peek(&user_id) {
@@ -220,7 +223,9 @@ impl UserCache {
// Загружаем пользователя
match functions::get_user(user_id.as_i64(), self.client_id).await {
Ok(User::User(user)) => {
let name = format!("{} {}", user.first_name, user.last_name).trim().to_string();
let name = format!("{} {}", user.first_name, user.last_name)
.trim()
.to_string();
name
}
_ => format!("User {}", user_id.as_i64()),
@@ -257,8 +262,7 @@ impl UserCache {
}
Err(_) => {
// Если не удалось загрузить, сохраняем placeholder
self.user_names
.insert(user_id, format!("User {}", user_id));
self.user_names.insert(user_id, format!("User {}", user_id));
}
}
}

View File

@@ -1,6 +1,6 @@
use crate::app::App;
use crate::tdlib::TdClientTrait;
use crate::tdlib::AuthState;
use crate::tdlib::TdClientTrait;
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout},
style::{Color, Modifier, Style},

View File

@@ -1,7 +1,7 @@
//! Chat list panel: search box, chat items, and user online status.
use crate::app::App;
use crate::app::methods::{compose::ComposeMethods, search::SearchMethods};
use crate::app::App;
use crate::tdlib::TdClientTrait;
use crate::tdlib::UserOnlineStatus;
use crate::ui::components;
@@ -35,7 +35,7 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &mut App<T>) {
let search_style = if app.is_searching {
Style::default().fg(Color::Yellow)
} else {
Style::default().fg(Color::DarkGray)
Style::default().fg(Color::Rgb(160, 160, 160))
};
let search = Paragraph::new(search_text)
.block(Block::default().borders(Borders::ALL))
@@ -76,7 +76,9 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &mut App<T>) {
app.selected_chat_id
} else {
let filtered = app.get_filtered_chats();
app.chat_list_state.selected().and_then(|i| filtered.get(i).map(|c| c.id))
app.chat_list_state
.selected()
.and_then(|i| filtered.get(i).map(|c| c.id))
};
let (status_text, status_color) = match status_chat_id {
Some(chat_id) => format_user_status(app.td_client.get_user_status_by_chat_id(chat_id)),

View File

@@ -21,7 +21,7 @@ pub fn render_emoji_picker(
) {
// Размеры модалки (зависят от количества реакций)
let emojis_per_row = 8;
let rows = (available_reactions.len() + emojis_per_row - 1) / emojis_per_row;
let rows = available_reactions.len().div_ceil(emojis_per_row);
let modal_width = 50u16;
let modal_height = (rows + 4) as u16; // +4 для заголовка, отступов и подсказки
@@ -29,12 +29,7 @@ pub fn render_emoji_picker(
let x = area.x + (area.width.saturating_sub(modal_width)) / 2;
let y = area.y + (area.height.saturating_sub(modal_height)) / 2;
let modal_area = Rect::new(
x,
y,
modal_width.min(area.width),
modal_height.min(area.height),
);
let modal_area = Rect::new(x, y, modal_width.min(area.width), modal_height.min(area.height));
// Очищаем область под модалкой
f.render_widget(Clear, modal_area);
@@ -87,10 +82,7 @@ pub fn render_emoji_picker(
.add_modifier(Modifier::BOLD),
),
Span::raw("Добавить "),
Span::styled(
" [Esc] ",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
),
Span::styled(" [Esc] ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
Span::raw("Отмена"),
]));

View File

@@ -34,10 +34,7 @@ pub fn render_input_field(
// Символ под курсором (или █ если курсор в конце)
if safe_cursor_pos < chars.len() {
let cursor_char = chars[safe_cursor_pos].to_string();
spans.push(Span::styled(
cursor_char,
Style::default().fg(Color::Black).bg(color),
));
spans.push(Span::styled(cursor_char, Style::default().fg(Color::Black).bg(color)));
} else {
// Курсор в конце - показываем блок
spans.push(Span::styled("", Style::default().fg(color)));

View File

@@ -7,9 +7,9 @@
use crate::config::Config;
use crate::formatting;
use crate::tdlib::{MessageInfo, PlaybackState, PlaybackStatus};
#[cfg(feature = "images")]
use crate::tdlib::PhotoDownloadState;
use crate::tdlib::{MessageInfo, PlaybackState, PlaybackStatus};
use crate::types::MessageId;
use crate::utils::{format_date, format_timestamp_with_tz};
use ratatui::{
@@ -36,10 +36,7 @@ fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
}
if all_lines.is_empty() {
all_lines.push(WrappedLine {
text: String::new(),
start_offset: 0,
});
all_lines.push(WrappedLine { text: String::new(), start_offset: 0 });
}
all_lines
@@ -48,10 +45,7 @@ fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
/// Разбивает один абзац (без `\n`) на строки по ширине
fn wrap_paragraph(text: &str, max_width: usize, base_offset: usize) -> Vec<WrappedLine> {
if max_width == 0 {
return vec![WrappedLine {
text: text.to_string(),
start_offset: base_offset,
}];
return vec![WrappedLine { text: text.to_string(), start_offset: base_offset }];
}
let mut result = Vec::new();
@@ -122,10 +116,7 @@ fn wrap_paragraph(text: &str, max_width: usize, base_offset: usize) -> Vec<Wrapp
}
if result.is_empty() {
result.push(WrappedLine {
text: String::new(),
start_offset: base_offset,
});
result.push(WrappedLine { text: String::new(), start_offset: base_offset });
}
result
@@ -138,7 +129,11 @@ fn wrap_paragraph(text: &str, max_width: usize, base_offset: usize) -> Vec<Wrapp
/// * `date` - timestamp сообщения
/// * `content_width` - ширина области для центрирования
/// * `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();
if !is_first {
@@ -226,9 +221,9 @@ pub fn render_message_bubble(
let mut lines = Vec::new();
let is_selected = selected_msg_id == Some(msg.id());
// Маркер выбора
let selection_marker = if is_selected { "" } else { "" };
let marker_len = selection_marker.chars().count();
// Маркер выбора (всегда резервируем место для ▶, чтобы текст не сдвигался)
let selection_marker = if is_selected { "" } else { " " };
let marker_len = 2;
// Цвет сообщения
let msg_color = if is_selected {
@@ -276,10 +271,8 @@ pub fn render_message_bubble(
Span::styled(reply_line, Style::default().fg(Color::Cyan)),
]));
} else {
lines.push(Line::from(vec![Span::styled(
reply_line,
Style::default().fg(Color::Cyan),
)]));
lines
.push(Line::from(vec![Span::styled(reply_line, Style::default().fg(Color::Cyan))]));
}
}
@@ -301,36 +294,47 @@ pub fn render_message_bubble(
let is_last_line = i == total_wrapped - 1;
let line_len = wrapped.text.chars().count();
let line_entities =
formatting::adjust_entities_for_substring(msg.entities(), wrapped.start_offset, line_len);
let formatted_spans = formatting::format_text_with_entities(&wrapped.text, &line_entities, msg_color);
let line_entities = formatting::adjust_entities_for_substring(
msg.entities(),
wrapped.start_offset,
line_len,
);
let formatted_spans =
formatting::format_text_with_entities(&wrapped.text, &line_entities, msg_color);
if is_last_line {
let full_len = line_len + time_mark_len + marker_len;
let padding = content_width.saturating_sub(full_len + 1);
let mut line_spans = vec![Span::raw(" ".repeat(padding))];
if is_selected && i == 0 {
// Одна строка — маркер на ней
if i == 0 {
// Первая (или единственная) строка — маркер
line_spans.push(Span::styled(
selection_marker,
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
));
} else if is_selected {
// Последняя строка multi-line — пробелы вместо маркера
} else {
// Остальные строки multi-line — пробелы вместо маркера
line_spans.push(Span::raw(" ".repeat(marker_len)));
}
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));
} else {
let padding = content_width.saturating_sub(line_len + marker_len + 1);
let mut line_spans = vec![Span::raw(" ".repeat(padding))];
if i == 0 && is_selected {
if i == 0 {
line_spans.push(Span::styled(
selection_marker,
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
));
} else if is_selected {
} else {
// Средние строки multi-line — пробелы вместо маркера
line_spans.push(Span::raw(" ".repeat(marker_len)));
}
@@ -350,19 +354,24 @@ pub fn render_message_bubble(
for (i, wrapped) in wrapped_lines.into_iter().enumerate() {
let line_len = wrapped.text.chars().count();
let line_entities =
formatting::adjust_entities_for_substring(msg.entities(), wrapped.start_offset, line_len);
let formatted_spans = formatting::format_text_with_entities(&wrapped.text, &line_entities, msg_color);
let line_entities = formatting::adjust_entities_for_substring(
msg.entities(),
wrapped.start_offset,
line_len,
);
let formatted_spans =
formatting::format_text_with_entities(&wrapped.text, &line_entities, msg_color);
if i == 0 {
let mut line_spans = vec![];
if is_selected {
line_spans.push(Span::styled(
selection_marker,
Style::default().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(
selection_marker,
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
));
line_spans
.push(Span::styled(format!(" {}", time_str), Style::default().fg(Color::Gray)));
line_spans.push(Span::raw(" "));
line_spans.extend(formatted_spans);
lines.push(Line::from(line_spans));
@@ -390,12 +399,10 @@ pub fn render_message_bubble(
} else {
format!("[{}]", reaction.emoji)
}
} else if reaction.count > 1 {
format!("{} {}", reaction.emoji, reaction.count)
} else {
if reaction.count > 1 {
format!("{} {}", reaction.emoji, reaction.count)
} else {
reaction.emoji.clone()
}
reaction.emoji.clone()
};
let style = if reaction.is_chosen {
@@ -439,10 +446,7 @@ pub fn render_message_bubble(
_ => "",
};
let bar = render_progress_bar(ps.position, ps.duration, 20);
format!(
"{} {} {:.0}s/{:.0}s",
icon, bar, ps.position, ps.duration
)
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)
@@ -456,10 +460,7 @@ pub fn render_message_bubble(
Span::styled(status_line, Style::default().fg(Color::Cyan)),
]));
} else {
lines.push(Line::from(Span::styled(
status_line,
Style::default().fg(Color::Cyan),
)));
lines.push(Line::from(Span::styled(status_line, Style::default().fg(Color::Cyan))));
}
}
}
@@ -477,10 +478,8 @@ pub fn render_message_bubble(
Span::styled(status, Style::default().fg(Color::Yellow)),
]));
} else {
lines.push(Line::from(Span::styled(
status,
Style::default().fg(Color::Yellow),
)));
lines
.push(Line::from(Span::styled(status, Style::default().fg(Color::Yellow))));
}
}
PhotoDownloadState::Error(e) => {
@@ -492,10 +491,7 @@ pub fn render_message_bubble(
Span::styled(status, Style::default().fg(Color::Red)),
]));
} else {
lines.push(Line::from(Span::styled(
status,
Style::default().fg(Color::Red),
)));
lines.push(Line::from(Span::styled(status, Style::default().fg(Color::Red))));
}
}
PhotoDownloadState::Downloaded(_) => {
@@ -540,16 +536,18 @@ pub fn render_album_bubble(
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};
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().map_or(false, |m| m.is_outgoing());
let is_outgoing = messages.first().is_some_and(|m| m.is_outgoing());
// Selection marker
let selection_marker = if is_selected { "" } else { "" };
// Selection marker (всегда резервируем место)
let selection_marker = if is_selected { "" } else { " " };
// Фильтруем фото
let photos: Vec<&MessageInfo> = messages.iter().filter(|m| m.has_photo()).collect();
@@ -565,17 +563,15 @@ pub fn render_album_bubble(
// Grid layout
let cols = photo_count.min(ALBUM_GRID_MAX_COLS);
let rows = (photo_count + cols - 1) / cols;
let rows = photo_count.div_ceil(cols);
// Добавляем маркер выбора на первую строку
if is_selected {
lines.push(Line::from(vec![
Span::styled(
selection_marker,
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
),
]));
}
// Добавляем маркер выбора на первую строку (всегда — для постоянного отступа)
lines.push(Line::from(vec![Span::styled(
selection_marker,
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)]));
let grid_start_line = lines.len();
@@ -608,7 +604,9 @@ pub fn render_album_bubble(
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;
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)
@@ -617,7 +615,8 @@ pub fn render_album_bubble(
deferred.push(DeferredImageRender {
message_id: msg.id(),
photo_path: path.clone(),
line_offset: grid_start_line + row * ALBUM_PHOTO_HEIGHT as usize,
line_offset: grid_start_line
+ row * ALBUM_PHOTO_HEIGHT as usize,
x_offset: x_off,
width: ALBUM_PHOTO_WIDTH,
height: ALBUM_PHOTO_HEIGHT,
@@ -644,10 +643,7 @@ pub fn render_album_bubble(
}
PhotoDownloadState::NotDownloaded => {
if line_in_row == ALBUM_PHOTO_HEIGHT / 2 {
spans.push(Span::styled(
"📷",
Style::default().fg(Color::Gray),
));
spans.push(Span::styled("📷", Style::default().fg(Color::Gray)));
}
}
}
@@ -706,9 +702,10 @@ pub fn render_album_bubble(
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.push(Line::from(vec![Span::styled(
format!(" {}", time_text),
Style::default().fg(Color::Gray),
)]));
}
}

View File

@@ -91,7 +91,10 @@ pub fn calculate_scroll_offset(
}
/// Renders a help bar with keyboard shortcuts
pub fn render_help_bar(shortcuts: &[(&str, &str, Color)], border_color: Color) -> Paragraph<'static> {
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 {
@@ -99,9 +102,7 @@ pub fn render_help_bar(shortcuts: &[(&str, &str, Color)], border_color: Color) -
}
spans.push(Span::styled(
format!(" {} ", key),
Style::default()
.fg(*color)
.add_modifier(Modifier::BOLD),
Style::default().fg(*color).add_modifier(Modifier::BOLD),
));
spans.push(Span::raw(label.to_string()));
}

View File

@@ -1,17 +1,17 @@
//! Reusable UI components: message bubbles, input fields, modals, lists.
pub mod modal;
pub mod chat_list_item;
pub mod emoji_picker;
pub mod input_field;
pub mod message_bubble;
pub mod message_list;
pub mod chat_list_item;
pub mod emoji_picker;
pub mod modal;
// Экспорт основных функций
pub use input_field::render_input_field;
pub use chat_list_item::render_chat_list_item;
pub use emoji_picker::render_emoji_picker;
pub use message_bubble::{render_date_separator, render_message_bubble, render_sender_header};
pub use input_field::render_input_field;
#[cfg(feature = "images")]
pub use message_bubble::{DeferredImageRender, calculate_image_height, render_album_bubble};
pub use message_list::{render_message_item, calculate_scroll_offset, render_help_bar};
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_list::{calculate_scroll_offset, render_help_bar, render_message_item};

View File

@@ -74,10 +74,7 @@ pub fn render_delete_confirm_modal(f: &mut Frame, area: Rect) {
),
Span::raw("Да"),
Span::raw(" "),
Span::styled(
" [n/Esc] ",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
),
Span::styled(" [n/Esc] ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
Span::raw("Нет"),
]),
];

View File

@@ -1,8 +1,8 @@
//! Compose bar / input box rendering
use crate::app::methods::{compose::ComposeMethods, messages::MessageMethods};
use crate::app::App;
use crate::app::InputMode;
use crate::app::methods::{compose::ComposeMethods, messages::MessageMethods};
use crate::tdlib::TdClientTrait;
use crate::ui::components;
use ratatui::{
@@ -124,13 +124,18 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
} 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)),
]);
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 ellipsis = if app.message_input.chars().count() > 60 {
"..."
} else {
""
};
let line = Line::from(Span::styled(
format!("> {}{}", draft_preview, ellipsis),
Style::default().fg(Color::DarkGray),
@@ -163,7 +168,9 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
} else {
Style::default().fg(Color::DarkGray)
};
Block::default().borders(Borders::ALL).border_style(border_style)
Block::default()
.borders(Borders::ALL)
.border_style(border_style)
} else {
let title_color = if app.is_replying() || app.is_forwarding() {
Color::Cyan

View File

@@ -1,7 +1,7 @@
use crate::app::App;
use crate::app::InputMode;
use crate::tdlib::TdClientTrait;
use crate::tdlib::NetworkState;
use crate::tdlib::TdClientTrait;
use ratatui::{
layout::Rect,
style::{Color, Style},
@@ -19,19 +19,17 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
NetworkState::Updating => "⏳ Обновление... | ",
};
// Account indicator (shown if not "default")
let account_indicator = if app.current_account_name != "default" {
format!("[{}] ", app.current_account_name)
} else {
String::new()
};
let account_indicator = format!("[{}] ", app.current_account_name);
let status = if let Some(msg) = &app.status_message {
format!(" {}{}{} ", account_indicator, network_indicator, msg)
} else if let Some(err) = &app.error_message {
format!(" {}{}Error: {} ", account_indicator, network_indicator, err)
} else if app.is_searching {
format!(" {}{}↑/↓: Navigate | Enter: Select | Esc: Cancel ", account_indicator, network_indicator)
format!(
" {}{}↑/↓: Navigate | Enter: Select | Esc: Cancel ",
account_indicator, network_indicator
)
} else if app.selected_chat_id.is_some() {
let mode_str = match app.input_mode {
InputMode::Normal => "[NORMAL] j/k: Nav | i: Insert | d/r/f/y: Actions | Esc: Close",
@@ -54,7 +52,7 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
} else if app.status_message.is_some() {
Style::default().fg(Color::Yellow)
} else {
Style::default().fg(Color::DarkGray)
Style::default().fg(Color::Rgb(160, 160, 160))
};
let footer = Paragraph::new(status).style(style);

View File

@@ -3,10 +3,10 @@
//! Renders message bubbles grouped by date/sender, pinned bar, and delegates
//! to modals (search, pinned, reactions, delete) and compose_bar.
use crate::app::App;
use crate::app::methods::{messages::MessageMethods, modal::ModalMethods, search::SearchMethods};
use crate::tdlib::TdClientTrait;
use crate::app::App;
use crate::message_grouping::{group_messages, MessageGroup};
use crate::tdlib::TdClientTrait;
use crate::ui::components;
use crate::ui::{compose_bar, modals};
use ratatui::{
@@ -18,7 +18,12 @@ use ratatui::{
};
/// Рендерит заголовок чата с 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
.td_client
.typing_status()
@@ -34,10 +39,7 @@ fn render_chat_header<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>,
.add_modifier(Modifier::BOLD),
)];
if let Some(username) = &chat.username {
spans.push(Span::styled(
format!(" {}", username),
Style::default().fg(Color::Gray),
));
spans.push(Span::styled(format!(" {}", username), Style::default().fg(Color::Gray)));
}
spans.push(Span::styled(
format!(" {}", action),
@@ -90,8 +92,7 @@ fn render_pinned_bar<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>)
Span::raw(" ".repeat(padding)),
Span::styled(pinned_hint, Style::default().fg(Color::Gray)),
]);
let pinned_bar =
Paragraph::new(pinned_line).style(Style::default().bg(Color::Rgb(40, 20, 40)));
let pinned_bar = Paragraph::new(pinned_line).style(Style::default().bg(Color::Rgb(40, 20, 40)));
f.render_widget(pinned_bar, area);
}
@@ -104,9 +105,7 @@ pub(super) struct WrappedLine {
/// (используется только для search/pinned режимов, основной рендеринг через message_bubble)
pub(super) fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
if max_width == 0 {
return vec![WrappedLine {
text: text.to_string(),
}];
return vec![WrappedLine { text: text.to_string() }];
}
let mut result = Vec::new();
@@ -131,9 +130,7 @@ pub(super) fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<Wrappe
current_line.push_str(&word);
current_width += 1 + word_width;
} else {
result.push(WrappedLine {
text: current_line,
});
result.push(WrappedLine { text: current_line });
current_line = word;
current_width = word_width;
}
@@ -155,23 +152,17 @@ pub(super) fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<Wrappe
current_line.push(' ');
current_line.push_str(&word);
} else {
result.push(WrappedLine {
text: current_line,
});
result.push(WrappedLine { text: current_line });
current_line = word;
}
}
if !current_line.is_empty() {
result.push(WrappedLine {
text: current_line,
});
result.push(WrappedLine { text: current_line });
}
if result.is_empty() {
result.push(WrappedLine {
text: String::new(),
});
result.push(WrappedLine { text: String::new() });
}
result
@@ -208,10 +199,7 @@ fn render_message_list<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &mut Ap
is_first_date = false;
is_first_sender = true; // Сбрасываем счётчик заголовков после даты
}
MessageGroup::SenderHeader {
is_outgoing,
sender_name,
} => {
MessageGroup::SenderHeader { is_outgoing, sender_name } => {
// Рендерим заголовок отправителя
lines.extend(components::render_sender_header(
is_outgoing,
@@ -240,9 +228,16 @@ fn render_message_list<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &mut Ap
// Собираем 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);
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;
@@ -314,11 +309,7 @@ fn render_message_list<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &mut Ap
let total_lines = lines.len();
// Базовый скролл (показываем последние сообщения)
let base_scroll = if total_lines > visible_height {
total_lines - visible_height
} else {
0
};
let base_scroll = total_lines.saturating_sub(visible_height);
// Если выбрано сообщение, автоскроллим к нему
let scroll_offset = if app.is_selecting_message() {
@@ -352,7 +343,8 @@ fn render_message_list<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &mut Ap
use ratatui_image::StatefulImage;
// THROTTLING: Рендерим изображения максимум 15 FPS (каждые 66ms)
let should_render_images = app.last_image_render_time
let should_render_images = app
.last_image_render_time
.map(|t| t.elapsed() > std::time::Duration::from_millis(66))
.unwrap_or(true);
@@ -435,7 +427,7 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &mut App<T>) {
1
};
// Минимум 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();
@@ -487,14 +479,9 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &mut App<T>) {
}
// Модалка выбора реакции
if let crate::app::ChatState::ReactionPicker {
available_reactions,
selected_index,
..
} = &app.chat_state
if let crate::app::ChatState::ReactionPicker { available_reactions, selected_index, .. } =
&app.chat_state
{
modals::render_reaction_picker(f, area, available_reactions, *selected_index);
}
}

View File

@@ -4,8 +4,8 @@
mod auth;
pub mod chat_list;
mod compose_bar;
pub mod components;
mod compose_bar;
pub mod footer;
mod loading;
mod main_screen;

View File

@@ -20,18 +20,10 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
};
match state {
AccountSwitcherState::SelectAccount {
accounts,
selected_index,
current_account,
} => {
AccountSwitcherState::SelectAccount { accounts, selected_index, current_account } => {
render_select_account(f, area, accounts, *selected_index, current_account);
}
AccountSwitcherState::AddAccount {
name_input,
cursor_position,
error,
} => {
AccountSwitcherState::AddAccount { name_input, cursor_position, error } => {
render_add_account(f, area, name_input, *cursor_position, error.as_deref());
}
}
@@ -53,10 +45,7 @@ fn render_select_account(
let marker = if is_current { "" } else { " " };
let suffix = if is_current { " (текущий)" } else { "" };
let display = format!(
"{}{} ({}){}",
marker, account.name, account.display_name, suffix
);
let display = format!("{}{} ({}){}", marker, account.name, account.display_name, suffix);
let style = if is_selected {
Style::default()
@@ -86,10 +75,7 @@ fn render_select_account(
} else {
Style::default().fg(Color::Cyan)
};
lines.push(Line::from(Span::styled(
" + Добавить аккаунт",
add_style,
)));
lines.push(Line::from(Span::styled(" + Добавить аккаунт", add_style)));
lines.push(Line::from(""));
@@ -148,10 +134,7 @@ fn render_add_account(
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),
)
Span::styled(format!("{}_", name_input), Style::default().fg(Color::White))
};
lines.push(Line::from(vec![
Span::styled(" Имя: ", Style::default().fg(Color::Cyan)),
@@ -168,10 +151,7 @@ fn render_add_account(
// Error
if let Some(err) = error {
lines.push(Line::from(Span::styled(
format!(" {}", err),
Style::default().fg(Color::Red),
)));
lines.push(Line::from(Span::styled(format!(" {}", err), Style::default().fg(Color::Red))));
lines.push(Line::from(""));
}

View File

@@ -1,6 +1,6 @@
//! Delete confirmation modal
use ratatui::{Frame, layout::Rect};
use ratatui::{layout::Rect, Frame};
/// Renders delete confirmation modal
pub fn render(f: &mut Frame, area: Rect) {

View File

@@ -19,19 +19,12 @@ use ratatui::{
use ratatui_image::StatefulImage;
/// Рендерит модальное окно с полноэкранным изображением
pub fn render<T: TdClientTrait>(
f: &mut Frame,
app: &mut App<T>,
modal_state: &ImageModalState,
) {
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,
);
f.render_widget(Block::default().style(Style::default().bg(Color::Black)), area);
// Резервируем место для подсказок (2 строки внизу)
let image_area_height = area.height.saturating_sub(2);

View File

@@ -10,18 +10,18 @@
pub mod account_switcher;
pub mod delete_confirm;
pub mod pinned;
pub mod reaction_picker;
pub mod search;
pub mod pinned;
#[cfg(feature = "images")]
pub mod image_viewer;
pub use account_switcher::render as render_account_switcher;
pub use delete_confirm::render as render_delete_confirm;
pub use pinned::render as render_pinned;
pub use reaction_picker::render as render_reaction_picker;
pub use search::render as render_search;
pub use pinned::render as render_pinned;
#[cfg(feature = "images")]
pub use image_viewer::render as render_image_viewer;

View File

@@ -2,7 +2,7 @@
use crate::app::App;
use crate::tdlib::TdClientTrait;
use crate::ui::components::{render_message_item, calculate_scroll_offset, render_help_bar};
use crate::ui::components::{calculate_scroll_offset, render_help_bar, render_message_item};
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
@@ -14,15 +14,13 @@ use ratatui::{
/// Renders pinned messages mode
pub fn render<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 (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)

View File

@@ -1,13 +1,8 @@
//! Reaction picker modal
use ratatui::{Frame, layout::Rect};
use ratatui::{layout::Rect, Frame};
/// Renders emoji reaction picker modal
pub fn render(
f: &mut Frame,
area: Rect,
available_reactions: &[String],
selected_index: usize,
) {
pub fn render(f: &mut Frame, area: Rect, available_reactions: &[String], selected_index: usize) {
crate::ui::components::render_emoji_picker(f, area, available_reactions, selected_index);
}

View File

@@ -2,7 +2,7 @@
use crate::app::App;
use crate::tdlib::TdClientTrait;
use crate::ui::components::{render_message_item, calculate_scroll_offset, render_help_bar};
use crate::ui::components::{calculate_scroll_offset, render_help_bar, render_message_item};
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
@@ -15,11 +15,8 @@ use ratatui::{
pub fn render<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
if let crate::app::ChatState::SearchInChat { query, results, selected_index } =
&app.chat_state
{
(query.as_str(), results.as_slice(), *selected_index)
} else {
@@ -37,11 +34,7 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
// Search input
let total = results.len();
let current = if total > 0 {
selected_index + 1
} else {
0
};
let current = if total > 0 { selected_index + 1 } else { 0 };
let input_line = if query.is_empty() {
Line::from(vec![

View File

@@ -1,7 +1,7 @@
use crate::app::App;
use crate::app::methods::modal::ModalMethods;
use crate::tdlib::TdClientTrait;
use crate::app::App;
use crate::tdlib::ProfileInfo;
use crate::tdlib::TdClientTrait;
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},

View File

@@ -6,6 +6,6 @@ pub mod validation;
pub use formatting::*;
// pub use modal_handler::*; // Используется через явный import
pub use retry::{with_timeout, with_timeout_msg, with_timeout_ignore};
pub use retry::{with_timeout, with_timeout_ignore, with_timeout_msg};
pub use tdlib::*;
pub use validation::*;

View File

@@ -105,10 +105,9 @@ mod tests {
#[tokio::test]
async fn test_with_timeout_success() {
let result = with_timeout(Duration::from_secs(1), async {
Ok::<_, String>("success".to_string())
})
.await;
let result =
with_timeout(Duration::from_secs(1), async { Ok::<_, String>("success".to_string()) })
.await;
assert!(result.is_ok());
assert_eq!(result.unwrap(), "success");

View File

@@ -17,11 +17,7 @@ fn test_open_account_switcher() {
assert!(app.account_switcher.is_some());
match &app.account_switcher {
Some(AccountSwitcherState::SelectAccount {
accounts,
selected_index,
current_account,
}) => {
Some(AccountSwitcherState::SelectAccount { accounts, selected_index, current_account }) => {
assert!(!accounts.is_empty());
assert_eq!(*selected_index, 0);
assert_eq!(current_account, "default");
@@ -58,11 +54,7 @@ fn test_account_switcher_navigate_down() {
}
match &app.account_switcher {
Some(AccountSwitcherState::SelectAccount {
selected_index,
accounts,
..
}) => {
Some(AccountSwitcherState::SelectAccount { selected_index, accounts, .. }) => {
// Should be at the "Add account" item (index == accounts.len())
assert_eq!(*selected_index, accounts.len());
}
@@ -137,11 +129,7 @@ fn test_confirm_add_account_transitions_to_add_state() {
app.account_switcher_confirm();
match &app.account_switcher {
Some(AccountSwitcherState::AddAccount {
name_input,
cursor_position,
error,
}) => {
Some(AccountSwitcherState::AddAccount { name_input, cursor_position, error }) => {
assert!(name_input.is_empty());
assert_eq!(*cursor_position, 0);
assert!(error.is_none());

View File

@@ -1,8 +1,6 @@
// Integration tests for accounts module
use tele_tui::accounts::{
account_db_path, validate_account_name, AccountProfile, AccountsConfig,
};
use tele_tui::accounts::{account_db_path, validate_account_name, AccountProfile, AccountsConfig};
#[test]
fn test_default_single_config() {

Some files were not shown because too many files have changed in this diff Show More