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

View File

@@ -3,13 +3,13 @@ when:
steps: steps:
- name: fmt - name: fmt
image: rust:1.84 image: rust:latest
commands: commands:
- rustup component add rustfmt - rustup component add rustfmt
- cargo fmt -- --check - cargo fmt -- --check
- name: clippy - name: clippy
image: rust:1.84 image: rust:latest
environment: environment:
CARGO_HOME: /tmp/cargo CARGO_HOME: /tmp/cargo
commands: commands:
@@ -18,7 +18,7 @@ steps:
- cargo clippy -- -D warnings - cargo clippy -- -D warnings
- name: test - name: test
image: rust:1.84 image: rust:latest
environment: environment:
CARGO_HOME: /tmp/cargo CARGO_HOME: /tmp/cargo
commands: 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) ## Статус: Фаза 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 ### 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", "percent-encoding",
] ]
[[package]]
name = "fs2"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213"
dependencies = [
"libc",
"winapi",
]
[[package]] [[package]]
name = "futures-channel" name = "futures-channel"
version = "0.3.31" version = "0.3.31"
@@ -3383,6 +3393,7 @@ dependencies = [
"crossterm", "crossterm",
"dirs 5.0.1", "dirs 5.0.1",
"dotenvy", "dotenvy",
"fs2",
"image", "image",
"insta", "insta",
"notify-rust", "notify-rust",

View File

@@ -37,6 +37,7 @@ thiserror = "1.0"
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
base64 = "0.22.1" base64 = "0.22.1"
fs2 = "0.4"
[dev-dependencies] [dev-dependencies]
insta = "1.34" 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 - `pending_account_switch` флаг → обработка в main loop
- [ ] **Этап 4: Расширенные возможности мультиаккаунта** - [ ] **Этап 4: Расширенные возможности мультиаккаунта**
- Удаление аккаунта из модалки
- Хоткеи `Ctrl+1`..`Ctrl+9` — быстрое переключение - Хоткеи `Ctrl+1`..`Ctrl+9` — быстрое переключение
- Бейджи непрочитанных с других аккаунтов (требует множественных TdClient) - Бейджи непрочитанных с других аккаунтов (требует множественных 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 criterion::{black_box, criterion_group, criterion_main, Criterion};
use tele_tui::formatting::format_text_with_entities;
use tdlib_rs::enums::{TextEntity, TextEntityType}; use tdlib_rs::enums::{TextEntity, TextEntityType};
use tele_tui::formatting::format_text_with_entities;
fn create_text_with_entities() -> (String, Vec<TextEntity>) { fn create_text_with_entities() -> (String, Vec<TextEntity>) {
let text = "This is bold and italic text with code and a link and mention".to_string(); let text = "This is bold and italic text with code and a link and mention".to_string();
@@ -41,9 +41,7 @@ fn benchmark_format_simple_text(c: &mut Criterion) {
let entities = vec![]; let entities = vec![];
c.bench_function("format_simple_text", |b| { c.bench_function("format_simple_text", |b| {
b.iter(|| { b.iter(|| format_text_with_entities(black_box(&text), black_box(&entities)));
format_text_with_entities(black_box(&text), black_box(&entities))
});
}); });
} }
@@ -51,9 +49,7 @@ fn benchmark_format_markdown_text(c: &mut Criterion) {
let (text, entities) = create_text_with_entities(); let (text, entities) = create_text_with_entities();
c.bench_function("format_markdown_text", |b| { c.bench_function("format_markdown_text", |b| {
b.iter(|| { b.iter(|| format_text_with_entities(black_box(&text), black_box(&entities)));
format_text_with_entities(black_box(&text), black_box(&entities))
});
}); });
} }
@@ -77,9 +73,7 @@ fn benchmark_format_long_text(c: &mut Criterion) {
} }
c.bench_function("format_long_text_with_100_entities", |b| { c.bench_function("format_long_text_with_100_entities", |b| {
b.iter(|| { b.iter(|| format_text_with_entities(black_box(&text), black_box(&entities)));
format_text_with_entities(black_box(&text), black_box(&entities))
});
}); });
} }

View File

@@ -1,5 +1,5 @@
use criterion::{black_box, criterion_group, criterion_main, Criterion}; use criterion::{black_box, criterion_group, criterion_main, Criterion};
use tele_tui::utils::formatting::{format_timestamp_with_tz, format_date, get_day}; use tele_tui::utils::formatting::{format_date, format_timestamp_with_tz, get_day};
fn benchmark_format_timestamp(c: &mut Criterion) { fn benchmark_format_timestamp(c: &mut Criterion) {
c.bench_function("format_timestamp_50_times", |b| { c.bench_function("format_timestamp_50_times", |b| {
@@ -34,10 +34,5 @@ fn benchmark_get_day(c: &mut Criterion) {
}); });
} }
criterion_group!( criterion_group!(benches, benchmark_format_timestamp, benchmark_format_date, benchmark_get_day);
benches,
benchmark_format_timestamp,
benchmark_format_date,
benchmark_get_day
);
criterion_main!(benches); criterion_main!(benches);

View File

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

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 tab_spaces = 4
newline_style = "Unix" newline_style = "Unix"
# Imports
imports_granularity = "Crate"
group_imports = "StdExternalCrate"
# Comments
wrap_comments = true
comment_width = 80
normalize_comments = true
# Formatting # Formatting
use_small_heuristics = "Default" use_small_heuristics = "Default"
fn_call_width = 80 fn_call_width = 80

127
src/accounts/lock.rs Normal file
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`. /// Saves `AccountsConfig` to `accounts.toml`.
pub fn save(config: &AccountsConfig) -> Result<(), String> { pub fn save(config: &AccountsConfig) -> Result<(), String> {
let config_path = accounts_config_path() let config_path =
.ok_or_else(|| "Could not determine config directory".to_string())?; accounts_config_path().ok_or_else(|| "Could not determine config directory".to_string())?;
// Ensure parent directory exists // Ensure parent directory exists
if let Some(parent) = config_path.parent() { if let Some(parent) = config_path.parent() {
@@ -111,17 +111,10 @@ fn migrate_legacy() {
// Move (rename) the directory // Move (rename) the directory
match fs::rename(&legacy_path, &target) { match fs::rename(&legacy_path, &target) {
Ok(()) => { Ok(()) => {
tracing::info!( tracing::info!("Migrated ./tdlib_data/ -> {}", target.display());
"Migrated ./tdlib_data/ -> {}",
target.display()
);
} }
Err(e) => { Err(e) => {
tracing::error!( tracing::error!("Could not migrate ./tdlib_data/ to {}: {}", target.display(), e);
"Could not migrate ./tdlib_data/ to {}: {}",
target.display(),
e
);
} }
} }
} }

View File

@@ -4,8 +4,12 @@
//! Each account has its own TDLib database directory under //! Each account has its own TDLib database directory under
//! `~/.local/share/tele-tui/accounts/{name}/tdlib_data/`. //! `~/.local/share/tele-tui/accounts/{name}/tdlib_data/`.
pub mod lock;
pub mod manager; pub mod manager;
pub mod profile; 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}; 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}; pub use profile::{account_db_path, validate_account_name, AccountProfile, AccountsConfig};

View File

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

View File

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

View File

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

View File

@@ -61,8 +61,7 @@ impl<T: TdClientTrait> MessageMethods<T> for App<T> {
// Перескакиваем через все сообщения текущего альбома назад // Перескакиваем через все сообщения текущего альбома назад
let mut new_index = *selected_index - 1; let mut new_index = *selected_index - 1;
if current_album_id != 0 { if current_album_id != 0 {
while new_index > 0 while new_index > 0 && messages[new_index].media_album_id() == current_album_id
&& messages[new_index].media_album_id() == current_album_id
{ {
new_index -= 1; new_index -= 1;
} }
@@ -110,24 +109,20 @@ impl<T: TdClientTrait> MessageMethods<T> for App<T> {
} }
} }
if new_index >= total { if new_index < total {
self.chat_state = ChatState::Normal;
} else {
*selected_index = new_index; *selected_index = new_index;
}
self.stop_playback();
} else {
// Дошли до самого нового сообщения - выходим из режима выбора
self.chat_state = ChatState::Normal;
self.stop_playback(); self.stop_playback();
} }
// Если new_index >= total — остаёмся на текущем
}
// Если уже на последнем — ничего не делаем, остаёмся на месте
} }
} }
fn get_selected_message(&self) -> Option<MessageInfo> { fn get_selected_message(&self) -> Option<MessageInfo> {
self.chat_state.selected_message_index().and_then(|idx| { self.chat_state
self.td_client.current_chat_messages().get(idx).cloned() .selected_message_index()
}) .and_then(|idx| self.td_client.current_chat_messages().get(idx).cloned())
} }
fn start_editing_selected(&mut self) -> bool { 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 { if let Some((id, content, idx)) = msg_data {
self.cursor_position = content.chars().count(); self.cursor_position = content.chars().count();
self.message_input = content; self.message_input = content;
self.chat_state = ChatState::Editing { self.chat_state = ChatState::Editing { message_id: id, selected_index: idx };
message_id: id,
selected_index: idx,
};
return true; return true;
} }
false false

View File

@@ -7,14 +7,19 @@
//! - search: Search in chats and messages //! - search: Search in chats and messages
//! - modal: Modal dialogs (Profile, Pinned, Reactions, Delete) //! - modal: Modal dialogs (Profile, Pinned, Reactions, Delete)
pub mod navigation;
pub mod messages;
pub mod compose; pub mod compose;
pub mod search; pub mod messages;
pub mod modal; pub mod modal;
pub mod navigation;
pub mod search;
pub use navigation::NavigationMethods; #[allow(unused_imports)]
pub use messages::MessageMethods;
pub use compose::ComposeMethods; pub use compose::ComposeMethods;
pub use search::SearchMethods; #[allow(unused_imports)]
pub use messages::MessageMethods;
#[allow(unused_imports)]
pub use modal::ModalMethods; 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>) { fn enter_pinned_mode(&mut self, messages: Vec<MessageInfo>) {
if !messages.is_empty() { if !messages.is_empty() {
self.chat_state = ChatState::PinnedMessages { self.chat_state = ChatState::PinnedMessages { messages, selected_index: 0 };
messages,
selected_index: 0,
};
} }
} }
@@ -118,11 +115,7 @@ impl<T: TdClientTrait> ModalMethods<T> for App<T> {
} }
fn select_previous_pinned(&mut self) { fn select_previous_pinned(&mut self) {
if let ChatState::PinnedMessages { if let ChatState::PinnedMessages { selected_index, messages } = &mut self.chat_state {
selected_index,
messages,
} = &mut self.chat_state
{
if *selected_index + 1 < messages.len() { if *selected_index + 1 < messages.len() {
*selected_index += 1; *selected_index += 1;
} }
@@ -138,11 +131,7 @@ impl<T: TdClientTrait> ModalMethods<T> for App<T> {
} }
fn get_selected_pinned(&self) -> Option<&MessageInfo> { fn get_selected_pinned(&self) -> Option<&MessageInfo> {
if let ChatState::PinnedMessages { if let ChatState::PinnedMessages { messages, selected_index } = &self.chat_state {
messages,
selected_index,
} = &self.chat_state
{
messages.get(*selected_index) messages.get(*selected_index)
} else { } else {
None None
@@ -170,10 +159,7 @@ impl<T: TdClientTrait> ModalMethods<T> for App<T> {
} }
fn select_previous_profile_action(&mut self) { fn select_previous_profile_action(&mut self) {
if let ChatState::Profile { if let ChatState::Profile { selected_action, .. } = &mut self.chat_state {
selected_action, ..
} = &mut self.chat_state
{
if *selected_action > 0 { if *selected_action > 0 {
*selected_action -= 1; *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) { fn select_next_profile_action(&mut self, max_actions: usize) {
if let ChatState::Profile { if let ChatState::Profile { selected_action, .. } = &mut self.chat_state {
selected_action, ..
} = &mut self.chat_state
{
if *selected_action < max_actions.saturating_sub(1) { if *selected_action < max_actions.saturating_sub(1) {
*selected_action += 1; *selected_action += 1;
} }
@@ -192,41 +175,25 @@ impl<T: TdClientTrait> ModalMethods<T> for App<T> {
} }
fn show_leave_group_confirmation(&mut self) { fn show_leave_group_confirmation(&mut self) {
if let ChatState::Profile { if let ChatState::Profile { leave_group_confirmation_step, .. } = &mut self.chat_state {
leave_group_confirmation_step,
..
} = &mut self.chat_state
{
*leave_group_confirmation_step = 1; *leave_group_confirmation_step = 1;
} }
} }
fn show_leave_group_final_confirmation(&mut self) { fn show_leave_group_final_confirmation(&mut self) {
if let ChatState::Profile { if let ChatState::Profile { leave_group_confirmation_step, .. } = &mut self.chat_state {
leave_group_confirmation_step,
..
} = &mut self.chat_state
{
*leave_group_confirmation_step = 2; *leave_group_confirmation_step = 2;
} }
} }
fn cancel_leave_group(&mut self) { fn cancel_leave_group(&mut self) {
if let ChatState::Profile { if let ChatState::Profile { leave_group_confirmation_step, .. } = &mut self.chat_state {
leave_group_confirmation_step,
..
} = &mut self.chat_state
{
*leave_group_confirmation_step = 0; *leave_group_confirmation_step = 0;
} }
} }
fn get_leave_group_confirmation_step(&self) -> u8 { fn get_leave_group_confirmation_step(&self) -> u8 {
if let ChatState::Profile { if let ChatState::Profile { leave_group_confirmation_step, .. } = &self.chat_state {
leave_group_confirmation_step,
..
} = &self.chat_state
{
*leave_group_confirmation_step *leave_group_confirmation_step
} else { } else {
0 0
@@ -242,10 +209,7 @@ impl<T: TdClientTrait> ModalMethods<T> for App<T> {
} }
fn get_selected_profile_action(&self) -> Option<usize> { fn get_selected_profile_action(&self) -> Option<usize> {
if let ChatState::Profile { if let ChatState::Profile { selected_action, .. } = &self.chat_state {
selected_action, ..
} = &self.chat_state
{
Some(*selected_action) Some(*selected_action)
} else { } else {
None None
@@ -277,11 +241,8 @@ impl<T: TdClientTrait> ModalMethods<T> for App<T> {
} }
fn select_next_reaction(&mut self) { fn select_next_reaction(&mut self) {
if let ChatState::ReactionPicker { if let ChatState::ReactionPicker { selected_index, available_reactions, .. } =
selected_index, &mut self.chat_state
available_reactions,
..
} = &mut self.chat_state
{ {
if *selected_index + 1 < available_reactions.len() { if *selected_index + 1 < available_reactions.len() {
*selected_index += 1; *selected_index += 1;
@@ -290,11 +251,8 @@ impl<T: TdClientTrait> ModalMethods<T> for App<T> {
} }
fn get_selected_reaction(&self) -> Option<&String> { fn get_selected_reaction(&self) -> Option<&String> {
if let ChatState::ReactionPicker { if let ChatState::ReactionPicker { available_reactions, selected_index, .. } =
available_reactions, &self.chat_state
selected_index,
..
} = &self.chat_state
{ {
available_reactions.get(*selected_index) available_reactions.get(*selected_index)
} else { } else {

View File

@@ -2,8 +2,8 @@
//! //!
//! Handles chat list navigation and selection //! Handles chat list navigation and selection
use crate::app::{App, ChatState, InputMode};
use crate::app::methods::search::SearchMethods; use crate::app::methods::search::SearchMethods;
use crate::app::{App, ChatState, InputMode};
use crate::tdlib::TdClientTrait; use crate::tdlib::TdClientTrait;
/// Navigation methods for chat list /// Navigation methods for chat list
@@ -87,6 +87,7 @@ impl<T: TdClientTrait> NavigationMethods<T> for App<T> {
#[cfg(feature = "images")] #[cfg(feature = "images")]
{ {
self.photo_download_rx = None; self.photo_download_rx = None;
self.pending_image_open = None;
} }
// Сбрасываем состояние чата в нормальный режим // Сбрасываем состояние чата в нормальный режим
self.chat_state = ChatState::Normal; 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); fn update_search_query(&mut self, new_query: String);
/// Get index of selected search result /// Get index of selected search result
#[allow(dead_code)]
fn get_search_selected_index(&self) -> Option<usize>; fn get_search_selected_index(&self) -> Option<usize>;
/// Get all search results /// Get all search results
#[allow(dead_code)]
fn get_search_results(&self) -> Option<&[MessageInfo]>; 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> { fn get_filtered_chats(&self) -> Vec<&ChatInfo> {
// Используем ChatFilter для централизованной фильтрации // Используем ChatFilter для централизованной фильтрации
let mut criteria = ChatFilterCriteria::new() let mut criteria = ChatFilterCriteria::new().with_folder(self.selected_folder_id);
.with_folder(self.selected_folder_id);
if !self.search_query.is_empty() { if !self.search_query.is_empty() {
criteria = criteria.with_search(self.search_query.clone()); 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) { fn select_next_search_result(&mut self) {
if let ChatState::SearchInChat { if let ChatState::SearchInChat { selected_index, results, .. } = &mut self.chat_state {
selected_index,
results,
..
} = &mut self.chat_state
{
if *selected_index + 1 < results.len() { if *selected_index + 1 < results.len() {
*selected_index += 1; *selected_index += 1;
} }
@@ -126,12 +122,7 @@ impl<T: TdClientTrait> SearchMethods<T> for App<T> {
} }
fn get_selected_search_result(&self) -> Option<&MessageInfo> { fn get_selected_search_result(&self) -> Option<&MessageInfo> {
if let ChatState::SearchInChat { if let ChatState::SearchInChat { results, selected_index, .. } = &self.chat_state {
results,
selected_index,
..
} = &self.chat_state
{
results.get(*selected_index) results.get(*selected_index)
} else { } else {
None None

View File

@@ -5,13 +5,14 @@
mod chat_filter; mod chat_filter;
mod chat_state; mod chat_state;
mod state;
pub mod methods; pub mod methods;
mod state;
pub use chat_filter::{ChatFilter, ChatFilterCriteria}; pub use chat_filter::{ChatFilter, ChatFilterCriteria};
pub use chat_state::{ChatState, InputMode}; pub use chat_state::{ChatState, InputMode};
pub use state::AppScreen; #[allow(unused_imports)]
pub use methods::*; pub use methods::*;
pub use state::AppScreen;
use crate::accounts::AccountProfile; use crate::accounts::AccountProfile;
use crate::tdlib::{ChatInfo, TdClient, TdClientTrait}; use crate::tdlib::{ChatInfo, TdClient, TdClientTrait};
@@ -19,6 +20,19 @@ use crate::types::ChatId;
use ratatui::widgets::ListState; use ratatui::widgets::ListState;
use std::path::PathBuf; 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. /// State of the account switcher modal overlay.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum AccountSwitcherState { pub enum AccountSwitcherState {
@@ -107,6 +121,7 @@ pub struct App<T: TdClientTrait = TdClient> {
/// Время последней отправки typing status (для throttling) /// Время последней отправки typing status (для throttling)
pub last_typing_sent: Option<std::time::Instant>, pub last_typing_sent: Option<std::time::Instant>,
// Image support // Image support
#[allow(dead_code)]
#[cfg(feature = "images")] #[cfg(feature = "images")]
pub image_cache: Option<crate::media::cache::ImageCache>, pub image_cache: Option<crate::media::cache::ImageCache>,
/// Renderer для inline preview в чате (Halfblocks - быстро) /// Renderer для inline preview в чате (Halfblocks - быстро)
@@ -121,6 +136,12 @@ pub struct App<T: TdClientTrait = TdClient> {
/// Время последнего рендеринга изображений (для throttling до 15 FPS) /// Время последнего рендеринга изображений (для throttling до 15 FPS)
#[cfg(feature = "images")] #[cfg(feature = "images")]
pub last_image_render_time: Option<std::time::Instant>, 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
/// Account switcher modal state (global overlay) /// Account switcher modal state (global overlay)
pub account_switcher: Option<AccountSwitcherState>, pub account_switcher: Option<AccountSwitcherState>,
@@ -145,6 +166,7 @@ pub struct App<T: TdClientTrait = TdClient> {
pub last_playback_tick: Option<std::time::Instant>, pub last_playback_tick: Option<std::time::Instant>,
} }
#[allow(dead_code)]
impl<T: TdClientTrait> App<T> { impl<T: TdClientTrait> App<T> {
/// Creates a new App instance with the given configuration and client. /// 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; let audio_cache_size_mb = config.audio.cache_size_mb;
#[cfg(feature = "images")] #[cfg(feature = "images")]
let image_cache = Some(crate::media::cache::ImageCache::new( let image_cache = Some(crate::media::cache::ImageCache::new(config.images.cache_size_mb));
config.images.cache_size_mb,
));
#[cfg(feature = "images")] #[cfg(feature = "images")]
let inline_image_renderer = crate::media::image_renderer::ImageRenderer::new_fast(); let inline_image_renderer = crate::media::image_renderer::ImageRenderer::new_fast();
#[cfg(feature = "images")] #[cfg(feature = "images")]
@@ -196,6 +216,8 @@ impl<T: TdClientTrait> App<T> {
search_query: String::new(), search_query: String::new(),
needs_redraw: true, needs_redraw: true,
last_typing_sent: None, last_typing_sent: None,
// Account lock
account_lock: None,
// Account switcher // Account switcher
account_switcher: None, account_switcher: None,
current_account_name: "default".to_string(), current_account_name: "default".to_string(),
@@ -213,6 +235,8 @@ impl<T: TdClientTrait> App<T> {
image_modal: None, image_modal: None,
#[cfg(feature = "images")] #[cfg(feature = "images")]
last_image_render_time: None, last_image_render_time: None,
#[cfg(feature = "images")]
pending_image_open: None,
// Voice playback // Voice playback
audio_player: crate::audio::AudioPlayer::new().ok(), audio_player: crate::audio::AudioPlayer::new().ok(),
voice_cache: crate::audio::VoiceCache::new(audio_cache_size_mb).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. /// Navigate to next item in account switcher list.
pub fn account_switcher_select_next(&mut self) { pub fn account_switcher_select_next(&mut self) {
if let Some(AccountSwitcherState::SelectAccount { if let Some(AccountSwitcherState::SelectAccount { accounts, selected_index, .. }) =
accounts, &mut self.account_switcher
selected_index,
..
}) = &mut self.account_switcher
{ {
// +1 for the "Add account" item at the end // +1 for the "Add account" item at the end
let max_index = accounts.len(); 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)) .and_then(|id| self.chats.iter().find(|c| c.id == id))
} }
// ========== Getter/Setter методы для инкапсуляции ========== // ========== Getter/Setter методы для инкапсуляции ==========
// Config // Config

View File

@@ -97,13 +97,13 @@ impl VoiceCache {
/// Evicts a specific file from cache /// Evicts a specific file from cache
fn evict(&mut self, file_id: &str) -> Result<(), String> { fn evict(&mut self, file_id: &str) -> Result<(), String> {
if let Some((path, _, _)) = self.files.remove(file_id) { if let Some((path, _, _)) = self.files.remove(file_id) {
fs::remove_file(&path) fs::remove_file(&path).map_err(|e| format!("Failed to remove cached file: {}", e))?;
.map_err(|e| format!("Failed to remove cached file: {}", e))?;
} }
Ok(()) Ok(())
} }
/// Clears all cached files /// Clears all cached files
#[allow(dead_code)]
pub fn clear(&mut self) -> Result<(), String> { pub fn clear(&mut self) -> Result<(), String> {
for (path, _, _) in self.files.values() { for (path, _, _) in self.files.values() {
let _ = fs::remove_file(path); // Ignore errors let _ = fs::remove_file(path); // Ignore errors

View File

@@ -58,7 +58,8 @@ impl AudioPlayer {
let mut cmd = Command::new("ffplay"); let mut cmd = Command::new("ffplay");
cmd.arg("-nodisp") cmd.arg("-nodisp")
.arg("-autoexit") .arg("-autoexit")
.arg("-loglevel").arg("quiet"); .arg("-loglevel")
.arg("quiet");
if start_secs > 0.0 { if start_secs > 0.0 {
cmd.arg("-ss").arg(format!("{:.1}", start_secs)); cmd.arg("-ss").arg(format!("{:.1}", start_secs));
@@ -132,19 +133,19 @@ impl AudioPlayer {
.arg("-CONT") .arg("-CONT")
.arg(pid.to_string()) .arg(pid.to_string())
.output(); .output();
let _ = Command::new("kill") let _ = Command::new("kill").arg(pid.to_string()).output();
.arg(pid.to_string())
.output();
} }
*self.paused.lock().unwrap() = false; *self.paused.lock().unwrap() = false;
} }
/// Returns true if a process is active (playing or paused) /// Returns true if a process is active (playing or paused)
#[allow(dead_code)]
pub fn is_playing(&self) -> bool { pub fn is_playing(&self) -> bool {
self.current_pid.lock().unwrap().is_some() && !*self.paused.lock().unwrap() self.current_pid.lock().unwrap().is_some() && !*self.paused.lock().unwrap()
} }
/// Returns true if paused /// Returns true if paused
#[allow(dead_code)]
pub fn is_paused(&self) -> bool { pub fn is_paused(&self) -> bool {
self.current_pid.lock().unwrap().is_some() && *self.paused.lock().unwrap() 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() self.current_pid.lock().unwrap().is_none() && !*self.starting.lock().unwrap()
} }
#[allow(dead_code)]
pub fn set_volume(&self, _volume: f32) {} pub fn set_volume(&self, _volume: f32) {}
#[allow(dead_code)]
pub fn adjust_volume(&self, _delta: f32) {} pub fn adjust_volume(&self, _delta: f32) {}
pub fn volume(&self) -> f32 { pub fn volume(&self) -> f32 {
1.0 1.0
} }
#[allow(dead_code)]
pub fn seek(&self, _delta: Duration) -> Result<(), String> { pub fn seek(&self, _delta: Duration) -> Result<(), String> {
Err("Seeking not supported".to_string()) Err("Seeking not supported".to_string())
} }

View File

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

View File

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

View File

@@ -50,6 +50,7 @@ pub const MAX_IMAGE_HEIGHT: u16 = 15;
pub const MIN_IMAGE_HEIGHT: u16 = 3; pub const MIN_IMAGE_HEIGHT: u16 = 3;
/// Таймаут скачивания файла (в секундах) /// Таймаут скачивания файла (в секундах)
#[allow(dead_code)]
pub const FILE_DOWNLOAD_TIMEOUT_SECS: u64 = 30; 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 start = entity.offset as usize;
let end = (entity.offset + entity.length) as usize; let end = (entity.offset + entity.length) as usize;
for i in start..end.min(chars.len()) { for item in char_styles
.iter_mut()
.take(end.min(chars.len()))
.skip(start)
{
match &entity.r#type { match &entity.r#type {
TextEntityType::Bold => char_styles[i].bold = true, TextEntityType::Bold => item.bold = true,
TextEntityType::Italic => char_styles[i].italic = true, TextEntityType::Italic => item.italic = true,
TextEntityType::Underline => char_styles[i].underline = true, TextEntityType::Underline => item.underline = true,
TextEntityType::Strikethrough => char_styles[i].strikethrough = true, TextEntityType::Strikethrough => item.strikethrough = true,
TextEntityType::Code | TextEntityType::Pre | TextEntityType::PreCode(_) => { TextEntityType::Code | TextEntityType::Pre | TextEntityType::PreCode(_) => {
char_styles[i].code = true item.code = true
} }
TextEntityType::Spoiler => char_styles[i].spoiler = true, TextEntityType::Spoiler => item.spoiler = true,
TextEntityType::Url TextEntityType::Url
| TextEntityType::TextUrl(_) | TextEntityType::TextUrl(_)
| TextEntityType::EmailAddress | TextEntityType::EmailAddress
| TextEntityType::PhoneNumber => char_styles[i].url = true, | TextEntityType::PhoneNumber => item.url = true,
TextEntityType::Mention | TextEntityType::MentionName(_) => { TextEntityType::Mention | TextEntityType::MentionName(_) => item.mention = true,
char_styles[i].mention = true
}
_ => {} _ => {}
} }
} }
@@ -277,11 +279,7 @@ mod tests {
#[test] #[test]
fn test_format_text_with_bold() { fn test_format_text_with_bold() {
let text = "Hello"; let text = "Hello";
let entities = vec![TextEntity { let entities = vec![TextEntity { offset: 0, length: 5, r#type: TextEntityType::Bold }];
offset: 0,
length: 5,
r#type: TextEntityType::Bold,
}];
let spans = format_text_with_entities(text, &entities, Color::White); let spans = format_text_with_entities(text, &entities, Color::White);
assert_eq!(spans.len(), 1); assert_eq!(spans.len(), 1);

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()); app.status_message = Some("Отправка номера...".to_string());
match with_timeout_msg( match with_timeout_msg(
Duration::from_secs(10), Duration::from_secs(10),
app.td_client.send_phone_number(app.phone_input().to_string()), app.td_client
.send_phone_number(app.phone_input().to_string()),
"Таймаут отправки номера", "Таймаут отправки номера",
) )
.await .await
@@ -84,7 +85,8 @@ pub async fn handle<T: TdClientTrait>(app: &mut App<T>, key_code: KeyCode) {
app.status_message = Some("Проверка пароля...".to_string()); app.status_message = Some("Проверка пароля...".to_string());
match with_timeout_msg( match with_timeout_msg(
Duration::from_secs(10), Duration::from_secs(10),
app.td_client.send_password(app.password_input().to_string()), app.td_client
.send_password(app.password_input().to_string()),
"Таймаут проверки пароля", "Таймаут проверки пароля",
) )
.await .await

View File

@@ -6,17 +6,17 @@
//! - Editing and sending messages //! - Editing and sending messages
//! - Loading older messages //! - Loading older messages
use super::chat_loader::{load_older_messages_if_needed, open_chat_and_load_data};
use crate::app::methods::{
compose::ComposeMethods, messages::MessageMethods, modal::ModalMethods,
navigation::NavigationMethods,
};
use crate::app::App; use crate::app::App;
use crate::app::InputMode; 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 crate::input::handlers::{copy_to_clipboard, format_message_for_clipboard};
use super::chat_list::open_chat_and_load_data; use crate::tdlib::{ChatAction, TdClientTrait};
use crate::types::{ChatId, MessageId};
use crate::utils::{is_non_empty, with_timeout_msg};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
@@ -29,7 +29,11 @@ use std::time::{Duration, Instant};
/// - Пересылку сообщения (f/а) /// - Пересылку сообщения (f/а)
/// - Копирование сообщения (y/н) /// - Копирование сообщения (y/н)
/// - Добавление реакции (e/у) /// - Добавление реакции (e/у)
pub async fn handle_message_selection<T: TdClientTrait>(app: &mut App<T>, _key: KeyEvent, command: Option<crate::config::Command>) { pub async fn handle_message_selection<T: TdClientTrait>(
app: &mut App<T>,
_key: KeyEvent,
command: Option<crate::config::Command>,
) {
match command { match command {
Some(crate::config::Command::MoveUp) => { Some(crate::config::Command::MoveUp) => {
app.select_previous_message(); app.select_previous_message();
@@ -44,9 +48,7 @@ pub async fn handle_message_selection<T: TdClientTrait>(app: &mut App<T>, _key:
let can_delete = let can_delete =
msg.can_be_deleted_only_for_self() || msg.can_be_deleted_for_all_users(); msg.can_be_deleted_only_for_self() || msg.can_be_deleted_for_all_users();
if can_delete { if can_delete {
app.chat_state = crate::app::ChatState::DeleteConfirmation { app.chat_state = crate::app::ChatState::DeleteConfirmation { message_id: msg.id() };
message_id: msg.id(),
};
} }
} }
Some(crate::config::Command::EnterInsertMode) => { 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() .iter()
.any(|m| m.id() == msg_id); .any(|m| m.id() == msg_id);
if !msg_exists { if !msg_exists {
app.error_message = Some(format!( app.error_message =
"Сообщение {} не найдено в кэше чата {}", Some(format!("Сообщение {} не найдено в кэше чата {}", msg_id.as_i64(), chat_id));
msg_id.as_i64(), chat_id
));
app.chat_state = crate::app::ChatState::Normal; app.chat_state = crate::app::ChatState::Normal;
app.message_input.clear(); app.message_input.clear();
app.cursor_position = 0; app.cursor_position = 0;
@@ -148,7 +155,8 @@ pub async fn edit_message<T: TdClientTrait>(app: &mut App<T>, chat_id: i64, msg_
match with_timeout_msg( match with_timeout_msg(
Duration::from_secs(5), Duration::from_secs(5),
app.td_client.edit_message(ChatId::new(chat_id), msg_id, text), app.td_client
.edit_message(ChatId::new(chat_id), msg_id, text),
"Таймаут редактирования", "Таймаут редактирования",
) )
.await .await
@@ -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(); let old_reply_to = messages[pos].interactions.reply_to.clone();
// Если в старом сообщении был reply и в новом он "Unknown" - сохраняем старый // Если в старом сообщении был reply и в новом он "Unknown" - сохраняем старый
if let Some(old_reply) = old_reply_to { if let Some(old_reply) = old_reply_to {
if edited_msg.interactions.reply_to.as_ref() if edited_msg
.map_or(true, |r| r.sender_name == "Unknown") { .interactions
.reply_to
.as_ref()
.is_none_or(|r| r.sender_name == "Unknown")
{
edited_msg.interactions.reply_to = Some(old_reply); edited_msg.interactions.reply_to = Some(old_reply);
} }
} }
@@ -189,12 +201,12 @@ pub async fn send_new_message<T: TdClientTrait>(app: &mut App<T>, chat_id: i64,
}; };
// Создаём ReplyInfo ДО отправки, пока сообщение точно доступно // Создаём ReplyInfo ДО отправки, пока сообщение точно доступно
let reply_info = app.get_replying_to_message().map(|m| { let reply_info = app
crate::tdlib::ReplyInfo { .get_replying_to_message()
.map(|m| crate::tdlib::ReplyInfo {
message_id: m.id(), message_id: m.id(),
sender_name: m.sender_name().to_string(), sender_name: m.sender_name().to_string(),
text: m.text().to_string(), text: m.text().to_string(),
}
}); });
app.message_input.clear(); app.message_input.clear();
@@ -206,11 +218,14 @@ pub async fn send_new_message<T: TdClientTrait>(app: &mut App<T>, chat_id: i64,
app.last_typing_sent = None; app.last_typing_sent = None;
// Отменяем typing status // Отменяем typing status
app.td_client.send_chat_action(ChatId::new(chat_id), ChatAction::Cancel).await; app.td_client
.send_chat_action(ChatId::new(chat_id), ChatAction::Cancel)
.await;
match with_timeout_msg( match with_timeout_msg(
Duration::from_secs(5), Duration::from_secs(5),
app.td_client.send_message(ChatId::new(chat_id), text, reply_to_id, reply_info), app.td_client
.send_message(ChatId::new(chat_id), text, reply_to_id, reply_info),
"Таймаут отправки", "Таймаут отправки",
) )
.await .await
@@ -304,7 +319,8 @@ pub async fn send_reaction<T: TdClientTrait>(app: &mut App<T>) {
// Send reaction with timeout // Send reaction with timeout
let result = with_timeout_msg( let result = with_timeout_msg(
Duration::from_secs(5), Duration::from_secs(5),
app.td_client.toggle_reaction(chat_id, message_id, emoji.clone()), app.td_client
.toggle_reaction(chat_id, message_id, emoji.clone()),
"Таймаут отправки реакции", "Таймаут отправки реакции",
) )
.await; .await;
@@ -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/Alt модификаторами (кроме Shift)
// Это позволяет обрабатывать хоткеи типа Ctrl+U для профиля // Это позволяет обрабатывать хоткеи типа Ctrl+U для профиля
if key.modifiers.contains(KeyModifiers::CONTROL) if key.modifiers.contains(KeyModifiers::CONTROL)
|| key.modifiers.contains(KeyModifiers::ALT) { || key.modifiers.contains(KeyModifiers::ALT)
{
return; return;
} }
@@ -434,7 +408,9 @@ pub async fn handle_open_chat_keyboard_input<T: TdClientTrait>(app: &mut App<T>,
.unwrap_or(true); .unwrap_or(true);
if should_send_typing { if should_send_typing {
if let Some(chat_id) = app.get_selected_chat_id() { if let Some(chat_id) = app.get_selected_chat_id() {
app.td_client.send_chat_action(ChatId::new(chat_id), ChatAction::Typing).await; app.td_client
.send_chat_action(ChatId::new(chat_id), ChatAction::Typing)
.await;
app.last_typing_sent = Some(Instant::now()); app.last_typing_sent = Some(Instant::now());
} }
} }
@@ -608,47 +584,44 @@ async fn handle_view_image<T: TdClientTrait>(app: &mut App<T>) {
}); });
app.needs_redraw = true; app.needs_redraw = true;
} }
PhotoDownloadState::Downloading => { PhotoDownloadState::NotDownloaded | PhotoDownloadState::Downloading => {
app.status_message = Some("Загрузка фото...".to_string()); // Запоминаем намерение открыть модалку — откроется когда загрузится
} app.pending_image_open = Some(crate::app::PendingImageOpen {
PhotoDownloadState::NotDownloaded => { file_id,
// Скачиваем фото и открываем
app.status_message = Some("Загрузка фото...".to_string());
app.needs_redraw = true;
match app.td_client.download_file(file_id).await {
Ok(path) => {
// Обновляем состояние загрузки в сообщении
for msg in app.td_client.current_chat_messages_mut() {
if let Some(photo) = msg.photo_info_mut() {
if photo.file_id == file_id {
photo.download_state =
PhotoDownloadState::Downloaded(path.clone());
break;
}
}
}
// Открываем модалку
app.image_modal = Some(ImageModalState {
message_id: msg_id, message_id: msg_id,
photo_path: path,
photo_width, photo_width,
photo_height, photo_height,
}); });
app.status_message = None; app.status_message = Some("Загрузка фото...".to_string());
app.needs_redraw = true;
// Если нет активной фоновой загрузки — запускаем свою
if app.photo_download_rx.is_none() {
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
app.photo_download_rx = Some(rx);
let client_id = app.td_client.client_id();
tokio::spawn(async move {
let result = tokio::time::timeout(Duration::from_secs(30), async {
match tdlib_rs::functions::download_file(
file_id, 1, 0, 0, true, client_id,
)
.await
{
Ok(tdlib_rs::enums::File::File(f))
if f.local.is_downloading_completed
&& !f.local.path.is_empty() =>
{
Ok(f.local.path)
} }
Err(e) => { Ok(_) => Err("Файл не скачан".to_string()),
for msg in app.td_client.current_chat_messages_mut() { Err(e) => Err(format!("{:?}", e)),
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(_) => { 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() { for msg in app.td_client.current_chat_messages_mut() {
if let Some(photo) = msg.photo_info_mut() { if let Some(photo) = msg.photo_info_mut() {
if photo.file_id == file_id { if photo.file_id == file_id {
photo.download_state = photo.download_state = PhotoDownloadState::Downloaded(path.clone());
PhotoDownloadState::Downloaded(path.clone());
break; 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) { if let Ok(entries) = std::fs::read_dir(parent) {
for entry in entries.flatten() { for entry in entries.flatten() {
let entry_name = entry.file_name(); 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(); let found_path = entry.path().to_string_lossy().to_string();
// Кэшируем найденный файл // Кэшируем найденный файл
if let Some(ref mut cache) = app.voice_cache { 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)); 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 => { VoiceDownloadState::Downloading => {
app.status_message = Some("Загрузка голосового...".to_string()); 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(); let cache_key = file_id.to_string();
if let Some(cached_path) = app.voice_cache.as_mut().and_then(|c| c.get(&cache_key)) { 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(); 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; 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)); 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) => { Err(e) => {
app.error_message = Some(format!("Ошибка загрузки: {}", 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 // Закомментировано - будет реализовано в Этапе 4
} }
*/ */

View File

@@ -5,12 +5,10 @@
//! - Folder selection //! - Folder selection
//! - Opening chats //! - Opening chats
use crate::app::methods::navigation::NavigationMethods;
use crate::app::App; use crate::app::App;
use crate::app::InputMode;
use crate::app::methods::{compose::ComposeMethods, messages::MessageMethods, navigation::NavigationMethods};
use crate::tdlib::TdClientTrait; use crate::tdlib::TdClientTrait;
use crate::types::{ChatId, MessageId}; use crate::utils::with_timeout;
use crate::utils::{with_timeout, with_timeout_msg};
use crossterm::event::KeyEvent; use crossterm::event::KeyEvent;
use std::time::Duration; use std::time::Duration;
@@ -19,7 +17,11 @@ use std::time::Duration;
/// Обрабатывает: /// Обрабатывает:
/// - Up/Down/j/k: навигация между чатами /// - Up/Down/j/k: навигация между чатами
/// - Цифры 1-9: переключение папок (1=All, 2-9=папки из TDLib) /// - Цифры 1-9: переключение папок (1=All, 2-9=папки из TDLib)
pub async fn handle_chat_list_navigation<T: TdClientTrait>(app: &mut App<T>, _key: KeyEvent, command: Option<crate::config::Command>) { pub async fn handle_chat_list_navigation<T: TdClientTrait>(
app: &mut App<T>,
_key: KeyEvent,
command: Option<crate::config::Command>,
) {
match command { match command {
Some(crate::config::Command::MoveDown) => { Some(crate::config::Command::MoveDown) => {
app.next_chat(); app.next_chat();
@@ -65,71 +67,11 @@ pub async fn select_folder<T: TdClientTrait>(app: &mut App<T>, folder_idx: usize
let folder_id = folder.id; let folder_id = folder.id;
app.selected_folder_id = Some(folder_id); app.selected_folder_id = Some(folder_id);
app.status_message = Some("Загрузка чатов папки...".to_string()); app.status_message = Some("Загрузка чатов папки...".to_string());
let _ = with_timeout( let _ =
Duration::from_secs(5), with_timeout(Duration::from_secs(5), app.td_client.load_folder_chats(folder_id, 50))
app.td_client.load_folder_chats(folder_id, 50),
)
.await; .await;
app.status_message = None; app.status_message = None;
app.chat_list_state.select(Some(0)); app.chat_list_state.select(Some(0));
} }
} }
/// Открывает чат и загружает последние сообщения (быстро).
///
/// Загружает только 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 //! - Edit mode
//! - Cursor movement and text editing //! - Cursor movement and text editing
use crate::app::App;
use crate::app::methods::{ use crate::app::methods::{
compose::ComposeMethods, navigation::NavigationMethods, search::SearchMethods, compose::ComposeMethods, navigation::NavigationMethods, search::SearchMethods,
}; };
use crate::app::App;
use crate::tdlib::TdClientTrait; use crate::tdlib::TdClientTrait;
use crate::types::ChatId; use crate::types::ChatId;
use crate::utils::with_timeout_msg; use crate::utils::with_timeout_msg;
@@ -22,7 +22,11 @@ use std::time::Duration;
/// - Навигацию по списку чатов (Up/Down) /// - Навигацию по списку чатов (Up/Down)
/// - Пересылку сообщения в выбранный чат (Enter) /// - Пересылку сообщения в выбранный чат (Enter)
/// - Отмену пересылки (Esc) /// - Отмену пересылки (Esc)
pub async fn handle_forward_mode<T: TdClientTrait>(app: &mut App<T>, _key: KeyEvent, command: Option<crate::config::Command>) { pub async fn handle_forward_mode<T: TdClientTrait>(
app: &mut App<T>,
_key: KeyEvent,
command: Option<crate::config::Command>,
) {
match command { match command {
Some(crate::config::Command::Cancel) => { Some(crate::config::Command::Cancel) => {
app.cancel_forward(); app.cancel_forward();
@@ -63,11 +67,8 @@ pub async fn forward_selected_message<T: TdClientTrait>(app: &mut App<T>) {
// Forward the message with timeout // Forward the message with timeout
let result = with_timeout_msg( let result = with_timeout_msg(
Duration::from_secs(5), Duration::from_secs(5),
app.td_client.forward_messages( app.td_client
to_chat_id, .forward_messages(to_chat_id, ChatId::new(from_chat_id), vec![msg_id]),
ChatId::new(from_chat_id),
vec![msg_id],
),
"Таймаут пересылки", "Таймаут пересылки",
) )
.await; .await;

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,35 +3,26 @@
//! Dispatches keyboard events to specialized handlers based on current app mode. //! Dispatches keyboard events to specialized handlers based on current app mode.
//! Priority order: modals → search → compose → chat → chat list. //! Priority order: modals → search → compose → chat → chat list.
use crate::app::methods::{
compose::ComposeMethods, messages::MessageMethods, modal::ModalMethods,
navigation::NavigationMethods, search::SearchMethods,
};
use crate::app::App; use crate::app::App;
use crate::app::InputMode; 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::{ 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, handle_global_commands,
modal::{ modal::{
handle_account_switcher, handle_account_switcher, handle_delete_confirmation, handle_pinned_mode,
handle_profile_mode, handle_profile_open, handle_delete_confirmation, handle_profile_mode, handle_profile_open, handle_reaction_picker_mode,
handle_reaction_picker_mode, handle_pinned_mode,
}, },
search::{handle_chat_search_mode, handle_message_search_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; use crossterm::event::KeyEvent;
/// Обработка клавиши Esc в Normal mode /// Обработка клавиши 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; let _ = app.td_client.set_draft_message(chat_id, draft_text).await;
} else { } else {
// Очищаем черновик если инпут пустой // Очищаем черновик если инпут пустой
let _ = app.td_client.set_draft_message(chat_id, String::new()).await; let _ = app
.td_client
.set_draft_message(chat_id, String::new())
.await;
} }
app.close_chat(); app.close_chat();
@@ -331,4 +325,3 @@ async fn navigate_to_adjacent_photo<T: TdClientTrait>(app: &mut App<T>, directio
}; };
app.status_message = Some(msg.to_string()); 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 app::{App, AppScreen};
use constants::{POLL_TIMEOUT_MS, SHUTDOWN_TIMEOUT_SECS}; use constants::{POLL_TIMEOUT_MS, SHUTDOWN_TIMEOUT_SECS};
use input::{handle_auth_input, handle_main_input}; use input::{handle_auth_input, handle_main_input};
use input::handlers::process_pending_chat_init;
use tdlib::AuthState; use tdlib::AuthState;
use utils::{disable_tdlib_logs, with_timeout_ignore}; use utils::{disable_tdlib_logs, with_timeout_ignore};
@@ -37,11 +38,9 @@ fn parse_account_arg() -> Option<String> {
let args: Vec<String> = std::env::args().collect(); let args: Vec<String> = std::env::args().collect();
let mut i = 1; let mut i = 1;
while i < args.len() { while i < args.len() {
if args[i] == "--account" { if args[i] == "--account" && i + 1 < args.len() {
if i + 1 < args.len() {
return Some(args[i + 1].clone()); return Some(args[i + 1].clone());
} }
}
i += 1; i += 1;
} }
None None
@@ -57,7 +56,7 @@ async fn main() -> Result<(), io::Error> {
tracing_subscriber::fmt() tracing_subscriber::fmt()
.with_env_filter( .with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env() tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn")) .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn")),
) )
.init(); .init();
@@ -70,18 +69,28 @@ async fn main() -> Result<(), io::Error> {
// Резолвим аккаунт из CLI или default // Резолвим аккаунт из CLI или default
let account_arg = parse_account_arg(); let account_arg = parse_account_arg();
let (account_name, db_path) = let (account_name, db_path) =
accounts::resolve_account(&accounts_config, account_arg.as_deref()) accounts::resolve_account(&accounts_config, account_arg.as_deref()).unwrap_or_else(|e| {
.unwrap_or_else(|e| {
eprintln!("Error: {}", e); eprintln!("Error: {}", e);
std::process::exit(1); std::process::exit(1);
}); });
// Создаём директорию аккаунта если её нет // Создаём директорию аккаунта если её нет
let db_path = accounts::ensure_account_dir( 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); .unwrap_or(db_path);
// Acquire per-account lock BEFORE raw mode (so error prints to normal terminal)
let account_lock = accounts::acquire_lock(
account_arg.as_deref().unwrap_or(&accounts_config.default_account),
)
.unwrap_or_else(|e| {
eprintln!("Error: {}", e);
std::process::exit(1);
});
// Отключаем логи TDLib ДО создания клиента // Отключаем логи TDLib ДО создания клиента
disable_tdlib_logs(); disable_tdlib_logs();
@@ -103,6 +112,7 @@ async fn main() -> Result<(), io::Error> {
// Create app state with account-specific db_path // Create app state with account-specific db_path
let mut app = App::new(config, db_path); let mut app = App::new(config, db_path);
app.current_account_name = account_name; app.current_account_name = account_name;
app.account_lock = Some(account_lock);
// Запускаем инициализацию TDLib в фоне (только для реального клиента) // Запускаем инициализацию TDLib в фоне (только для реального клиента)
let client_id = app.td_client.client_id(); let client_id = app.td_client.client_id();
@@ -111,7 +121,7 @@ async fn main() -> Result<(), io::Error> {
let db_path_str = app.td_client.db_path.to_string_lossy().to_string(); let db_path_str = app.td_client.db_path.to_string_lossy().to_string();
tokio::spawn(async move { tokio::spawn(async move {
let _ = tdlib_rs::functions::set_tdlib_parameters( if let Err(e) = tdlib_rs::functions::set_tdlib_parameters(
false, // use_test_dc false, // use_test_dc
db_path_str, // database_directory db_path_str, // database_directory
"".to_string(), // files_directory "".to_string(), // files_directory
@@ -128,7 +138,10 @@ async fn main() -> Result<(), io::Error> {
env!("CARGO_PKG_VERSION").to_string(), // application_version env!("CARGO_PKG_VERSION").to_string(), // application_version
client_id, client_id,
) )
.await; .await
{
tracing::error!("set_tdlib_parameters failed: {:?}", e);
}
}); });
let res = run_app(&mut terminal, &mut app).await; let res = run_app(&mut terminal, &mut app).await;
@@ -160,7 +173,7 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
let polling_handle = tokio::spawn(async move { let polling_handle = tokio::spawn(async move {
while !should_stop_clone.load(Ordering::Relaxed) { while !should_stop_clone.load(Ordering::Relaxed) {
// receive() с таймаутом 0.1 сек чтобы периодически проверять флаг // receive() с таймаутом 0.1 сек чтобы периодически проверять флаг
let result = tokio::task::spawn_blocking(|| tdlib_rs::receive()).await; let result = tokio::task::spawn_blocking(tdlib_rs::receive).await;
if let Ok(Some((update, _client_id))) = result { if let Ok(Some((update, _client_id))) = result {
if update_tx.send(update).is_err() { if update_tx.send(update).is_err() {
break; // Канал закрыт, выходим break; // Канал закрыт, выходим
@@ -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 { if got_photos {
@@ -247,7 +301,7 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
// Проверяем завершение воспроизведения // Проверяем завершение воспроизведения
if playback.position >= playback.duration 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; 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; let _ = tdlib_rs::functions::close(app.td_client.client_id()).await;
// Ждём завершения polling задачи (с таймаутом) // Ждём завершения polling задачи (с таймаутом)
with_timeout_ignore(Duration::from_secs(SHUTDOWN_TIMEOUT_SECS), polling_handle).await; with_timeout_ignore(
Duration::from_secs(SHUTDOWN_TIMEOUT_SECS),
polling_handle,
)
.await;
return Ok(()); return Ok(());
} }
@@ -329,86 +387,26 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
// Process pending chat initialization (reply info, pinned, photos) // Process pending chat initialization (reply info, pinned, photos)
if let Some(chat_id) = app.pending_chat_init.take() { if let Some(chat_id) = app.pending_chat_init.take() {
// Загружаем недостающие reply info (игнорируем ошибки) process_pending_chat_init(app, chat_id).await;
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;
} }
// Check pending account switch // Check pending account switch
if let Some((account_name, new_db_path)) = app.pending_account_switch.take() { 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 // 1. Stop playback
app.stop_playback(); app.stop_playback();

View File

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

View File

@@ -108,6 +108,7 @@ impl ImageRenderer {
} }
/// Удаляет протокол для сообщения /// Удаляет протокол для сообщения
#[allow(dead_code)]
pub fn remove(&mut self, msg_id: &MessageId) { pub fn remove(&mut self, msg_id: &MessageId) {
let msg_id_i64 = msg_id.as_i64(); let msg_id_i64 = msg_id.as_i64();
self.protocols.remove(&msg_id_i64); self.protocols.remove(&msg_id_i64);
@@ -115,6 +116,7 @@ impl ImageRenderer {
} }
/// Очищает все протоколы /// Очищает все протоколы
#[allow(dead_code)]
pub fn clear(&mut self) { pub fn clear(&mut self) {
self.protocols.clear(); self.protocols.clear();
self.access_order.clear(); self.access_order.clear();

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,7 @@
//! This module contains utility functions for managing chats, //! This module contains utility functions for managing chats,
//! including finding, updating, and adding/removing chats. //! including finding, updating, and adding/removing chats.
use crate::constants::{MAX_CHAT_USER_IDS, MAX_CHATS}; use crate::constants::{MAX_CHATS, MAX_CHAT_USER_IDS};
use crate::types::{ChatId, MessageId, UserId}; use crate::types::{ChatId, MessageId, UserId};
use tdlib_rs::enums::{Chat as TdChat, ChatList, ChatType}; use tdlib_rs::enums::{Chat as TdChat, ChatList, ChatType};
@@ -33,7 +33,9 @@ pub fn add_or_update_chat(client: &mut TdClient, td_chat_enum: &TdChat) {
// Пропускаем удалённые аккаунты // Пропускаем удалённые аккаунты
if td_chat.title == "Deleted Account" || td_chat.title.is_empty() { if td_chat.title == "Deleted Account" || td_chat.title.is_empty() {
// Удаляем из списка если уже был добавлен // Удаляем из списка если уже был добавлен
client.chats_mut().retain(|c| c.id != ChatId::new(td_chat.id)); client
.chats_mut()
.retain(|c| c.id != ChatId::new(td_chat.id));
return; return;
} }
@@ -70,7 +72,9 @@ pub fn add_or_update_chat(client: &mut TdClient, td_chat_enum: &TdChat) {
let user_id = UserId::new(private.user_id); let user_id = UserId::new(private.user_id);
client.user_cache.chat_user_ids.insert(chat_id, user_id); client.user_cache.chat_user_ids.insert(chat_id, user_id);
// Проверяем, есть ли уже username в кэше (peek не обновляет LRU) // Проверяем, есть ли уже username в кэше (peek не обновляет LRU)
client.user_cache.user_usernames client
.user_cache
.user_usernames
.peek(&user_id) .peek(&user_id)
.map(|u| format!("@{}", u)) .map(|u| format!("@{}", u))
} }

View File

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

View File

@@ -1,20 +1,17 @@
use crate::types::{ChatId, MessageId, UserId}; use crate::types::{ChatId, MessageId, UserId};
use std::env; use std::env;
use std::path::PathBuf; use std::path::PathBuf;
use tdlib_rs::enums::{ use tdlib_rs::enums::{Chat as TdChat, ChatList, ConnectionState, Update, UserStatus};
ChatList, ConnectionState, Update, UserStatus,
Chat as TdChat
};
use tdlib_rs::types::Message as TdMessage;
use tdlib_rs::functions; use tdlib_rs::functions;
use tdlib_rs::types::Message as TdMessage;
use super::auth::{AuthManager, AuthState}; use super::auth::{AuthManager, AuthState};
use super::chats::ChatManager; use super::chats::ChatManager;
use super::messages::MessageManager; use super::messages::MessageManager;
use super::reactions::ReactionManager; use super::reactions::ReactionManager;
use super::types::{ChatInfo, FolderInfo, MessageInfo, NetworkState, ProfileInfo, UserOnlineStatus}; use super::types::{
ChatInfo, FolderInfo, MessageInfo, NetworkState, ProfileInfo, UserOnlineStatus,
};
use super::users::UserCache; use super::users::UserCache;
use crate::notifications::NotificationManager; use crate::notifications::NotificationManager;
@@ -61,6 +58,7 @@ pub struct TdClient {
pub network_state: NetworkState, pub network_state: NetworkState,
} }
#[allow(dead_code)]
impl TdClient { impl TdClient {
/// Creates a new TDLib client instance. /// Creates a new TDLib client instance.
/// ///
@@ -75,8 +73,7 @@ impl TdClient {
/// A new `TdClient` instance ready for authentication. /// A new `TdClient` instance ready for authentication.
pub fn new(db_path: PathBuf) -> Self { pub fn new(db_path: PathBuf) -> Self {
// Пробуем загрузить credentials из Config (файл или env) // Пробуем загрузить credentials из Config (файл или env)
let (api_id, api_hash) = crate::config::Config::load_credentials() let (api_id, api_hash) = crate::config::Config::load_credentials().unwrap_or_else(|_| {
.unwrap_or_else(|_| {
// Fallback на прямое чтение из env (старое поведение) // Fallback на прямое чтение из env (старое поведение)
let api_id = env::var("API_ID") let api_id = env::var("API_ID")
.unwrap_or_else(|_| "0".to_string()) .unwrap_or_else(|_| "0".to_string())
@@ -106,9 +103,11 @@ impl TdClient {
/// Configures notification manager from app config /// Configures notification manager from app config
pub fn configure_notifications(&mut self, config: &crate::config::NotificationsConfig) { pub fn configure_notifications(&mut self, config: &crate::config::NotificationsConfig) {
self.notification_manager.set_enabled(config.enabled); self.notification_manager.set_enabled(config.enabled);
self.notification_manager.set_only_mentions(config.only_mentions); self.notification_manager
.set_only_mentions(config.only_mentions);
self.notification_manager.set_timeout(config.timeout_ms); self.notification_manager.set_timeout(config.timeout_ms);
self.notification_manager.set_urgency(config.urgency.clone()); self.notification_manager
.set_urgency(config.urgency.clone());
// Note: show_preview is used when formatting notification body // Note: show_preview is used when formatting notification body
} }
@@ -116,7 +115,8 @@ impl TdClient {
/// ///
/// Should be called after chats are loaded to ensure muted chats don't trigger notifications. /// Should be called after chats are loaded to ensure muted chats don't trigger notifications.
pub fn sync_notification_muted_chats(&mut self) { pub fn sync_notification_muted_chats(&mut self) {
self.notification_manager.sync_muted_chats(&self.chat_manager.chats); self.notification_manager
.sync_muted_chats(&self.chat_manager.chats);
} }
// Делегирование к auth // Делегирование к auth
@@ -257,12 +257,17 @@ impl TdClient {
.await .await
} }
pub async fn get_pinned_messages(&mut self, chat_id: ChatId) -> Result<Vec<MessageInfo>, String> { pub async fn get_pinned_messages(
&mut self,
chat_id: ChatId,
) -> Result<Vec<MessageInfo>, String> {
self.message_manager.get_pinned_messages(chat_id).await self.message_manager.get_pinned_messages(chat_id).await
} }
pub async fn load_current_pinned_message(&mut self, chat_id: ChatId) { pub async fn load_current_pinned_message(&mut self, chat_id: ChatId) {
self.message_manager.load_current_pinned_message(chat_id).await self.message_manager
.load_current_pinned_message(chat_id)
.await
} }
pub async fn search_messages( pub async fn search_messages(
@@ -442,7 +447,10 @@ impl TdClient {
self.chat_manager.typing_status.as_ref() self.chat_manager.typing_status.as_ref()
} }
pub fn set_typing_status(&mut self, status: Option<(crate::types::UserId, String, std::time::Instant)>) { pub fn set_typing_status(
&mut self,
status: Option<(crate::types::UserId, String, std::time::Instant)>,
) {
self.chat_manager.typing_status = status; self.chat_manager.typing_status = status;
} }
@@ -450,7 +458,9 @@ impl TdClient {
&self.message_manager.pending_view_messages &self.message_manager.pending_view_messages
} }
pub fn pending_view_messages_mut(&mut self) -> &mut Vec<(crate::types::ChatId, Vec<crate::types::MessageId>)> { pub fn pending_view_messages_mut(
&mut self,
) -> &mut Vec<(crate::types::ChatId, Vec<crate::types::MessageId>)> {
&mut self.message_manager.pending_view_messages &mut self.message_manager.pending_view_messages
} }
@@ -481,19 +491,6 @@ impl TdClient {
// ==================== Helper методы для упрощения обработки updates ==================== // ==================== Helper методы для упрощения обработки updates ====================
/// Находит мутабельную ссылку на чат по ID.
///
/// Упрощает повторяющийся паттерн `self.chats_mut().iter_mut().find(...)`.
///
/// # Arguments
///
/// * `chat_id` - ID чата для поиска
///
/// # Returns
///
/// * `Some(&mut ChatInfo)` - если чат найден
/// * `None` - если чат не найден
/// Обрабатываем одно обновление от TDLib /// Обрабатываем одно обновление от TDLib
pub fn handle_update(&mut self, update: Update) { pub fn handle_update(&mut self, update: Update) {
match update { match update {
@@ -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| { crate::tdlib::chat_helpers::update_chat(self, chat_id, |chat| {
chat.order = pos.order; chat.order = pos.order;
chat.is_pinned = pos.is_pinned; chat.is_pinned = pos.is_pinned;
@@ -530,27 +531,43 @@ impl TdClient {
self.chats_mut().sort_by(|a, b| b.order.cmp(&a.order)); self.chats_mut().sort_by(|a, b| b.order.cmp(&a.order));
} }
Update::ChatReadInbox(update) => { Update::ChatReadInbox(update) => {
crate::tdlib::chat_helpers::update_chat(self, ChatId::new(update.chat_id), |chat| { crate::tdlib::chat_helpers::update_chat(
self,
ChatId::new(update.chat_id),
|chat| {
chat.unread_count = update.unread_count; chat.unread_count = update.unread_count;
}); },
);
} }
Update::ChatUnreadMentionCount(update) => { Update::ChatUnreadMentionCount(update) => {
crate::tdlib::chat_helpers::update_chat(self, ChatId::new(update.chat_id), |chat| { crate::tdlib::chat_helpers::update_chat(
self,
ChatId::new(update.chat_id),
|chat| {
chat.unread_mention_count = update.unread_mention_count; chat.unread_mention_count = update.unread_mention_count;
}); },
);
} }
Update::ChatNotificationSettings(update) => { Update::ChatNotificationSettings(update) => {
crate::tdlib::chat_helpers::update_chat(self, ChatId::new(update.chat_id), |chat| { crate::tdlib::chat_helpers::update_chat(
self,
ChatId::new(update.chat_id),
|chat| {
// mute_for > 0 означает что чат замьючен // mute_for > 0 означает что чат замьючен
chat.is_muted = update.notification_settings.mute_for > 0; chat.is_muted = update.notification_settings.mute_for > 0;
}); },
);
} }
Update::ChatReadOutbox(update) => { Update::ChatReadOutbox(update) => {
// Обновляем last_read_outbox_message_id когда собеседник прочитал сообщения // Обновляем last_read_outbox_message_id когда собеседник прочитал сообщения
let last_read_msg_id = MessageId::new(update.last_read_outbox_message_id); let last_read_msg_id = MessageId::new(update.last_read_outbox_message_id);
crate::tdlib::chat_helpers::update_chat(self, ChatId::new(update.chat_id), |chat| { crate::tdlib::chat_helpers::update_chat(
self,
ChatId::new(update.chat_id),
|chat| {
chat.last_read_outbox_message_id = last_read_msg_id; chat.last_read_outbox_message_id = last_read_msg_id;
}); },
);
// Если это текущий открытый чат — обновляем is_read у сообщений // Если это текущий открытый чат — обновляем is_read у сообщений
if Some(ChatId::new(update.chat_id)) == self.current_chat_id() { if Some(ChatId::new(update.chat_id)) == self.current_chat_id() {
for msg in self.current_chat_messages_mut().iter_mut() { for msg in self.current_chat_messages_mut().iter_mut() {
@@ -588,7 +605,9 @@ impl TdClient {
UserStatus::LastMonth(_) => UserOnlineStatus::LastMonth, UserStatus::LastMonth(_) => UserOnlineStatus::LastMonth,
UserStatus::Empty => UserOnlineStatus::LongTimeAgo, UserStatus::Empty => UserOnlineStatus::LongTimeAgo,
}; };
self.user_cache.user_statuses.insert(UserId::new(update.user_id), status); self.user_cache
.user_statuses
.insert(UserId::new(update.user_id), status);
} }
Update::ConnectionState(update) => { Update::ConnectionState(update) => {
// Обновляем состояние сетевого соединения // Обновляем состояние сетевого соединения
@@ -616,13 +635,15 @@ impl TdClient {
} }
} }
// Helper functions // Helper functions
pub fn extract_message_text_static(message: &TdMessage) -> (String, Vec<tdlib_rs::types::TextEntity>) { pub fn extract_message_text_static(
message: &TdMessage,
) -> (String, Vec<tdlib_rs::types::TextEntity>) {
use tdlib_rs::enums::MessageContent; use tdlib_rs::enums::MessageContent;
match &message.content { match &message.content {
MessageContent::MessageText(text) => (text.text.text.clone(), text.text.entities.clone()), MessageContent::MessageText(text) => {
(text.text.text.clone(), text.text.entities.clone())
}
_ => (String::new(), Vec::new()), _ => (String::new(), Vec::new()),
} }
} }
@@ -644,7 +665,7 @@ impl TdClient {
let db_path_str = new_client.db_path.to_string_lossy().to_string(); let db_path_str = new_client.db_path.to_string_lossy().to_string();
tokio::spawn(async move { tokio::spawn(async move {
let _ = functions::set_tdlib_parameters( if let Err(e) = functions::set_tdlib_parameters(
false, false,
db_path_str, db_path_str,
"".to_string(), "".to_string(),
@@ -661,7 +682,10 @@ impl TdClient {
env!("CARGO_PKG_VERSION").to_string(), env!("CARGO_PKG_VERSION").to_string(),
new_client_id, new_client_id,
) )
.await; .await
{
tracing::error!("set_tdlib_parameters failed on recreate: {:?}", e);
}
}); });
// 4. Replace self // 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::client::TdClient;
use super::r#trait::TdClientTrait; use super::r#trait::TdClientTrait;
use super::{AuthState, ChatInfo, FolderInfo, MessageInfo, ProfileInfo, ReplyInfo, UserCache, UserOnlineStatus}; use super::{
AuthState, ChatInfo, FolderInfo, MessageInfo, ProfileInfo, ReplyInfo, UserCache,
UserOnlineStatus,
};
use crate::types::{ChatId, MessageId, UserId}; use crate::types::{ChatId, MessageId, UserId};
use async_trait::async_trait; use async_trait::async_trait;
use std::path::PathBuf; use std::path::PathBuf;
@@ -52,11 +55,19 @@ impl TdClientTrait for TdClient {
} }
// ============ Message methods ============ // ============ Message methods ============
async fn get_chat_history(&mut self, chat_id: ChatId, limit: i32) -> Result<Vec<MessageInfo>, String> { async fn get_chat_history(
&mut self,
chat_id: ChatId,
limit: i32,
) -> Result<Vec<MessageInfo>, String> {
self.get_chat_history(chat_id, limit).await self.get_chat_history(chat_id, limit).await
} }
async fn load_older_messages(&mut self, chat_id: ChatId, from_message_id: MessageId) -> Result<Vec<MessageInfo>, String> { async fn load_older_messages(
&mut self,
chat_id: ChatId,
from_message_id: MessageId,
) -> Result<Vec<MessageInfo>, String> {
self.load_older_messages(chat_id, from_message_id).await self.load_older_messages(chat_id, from_message_id).await
} }
@@ -68,7 +79,11 @@ impl TdClientTrait for TdClient {
self.load_current_pinned_message(chat_id).await self.load_current_pinned_message(chat_id).await
} }
async fn search_messages(&self, chat_id: ChatId, query: &str) -> Result<Vec<MessageInfo>, String> { async fn search_messages(
&self,
chat_id: ChatId,
query: &str,
) -> Result<Vec<MessageInfo>, String> {
self.search_messages(chat_id, query).await self.search_messages(chat_id, query).await
} }
@@ -148,7 +163,8 @@ impl TdClientTrait for TdClient {
chat_id: ChatId, chat_id: ChatId,
message_id: MessageId, message_id: MessageId,
) -> Result<Vec<String>, String> { ) -> Result<Vec<String>, String> {
self.get_message_available_reactions(chat_id, message_id).await self.get_message_available_reactions(chat_id, message_id)
.await
} }
async fn toggle_reaction( async fn toggle_reaction(
@@ -276,7 +292,8 @@ impl TdClientTrait for TdClient {
// ============ Notification methods ============ // ============ Notification methods ============
fn sync_notification_muted_chats(&mut self) { fn sync_notification_muted_chats(&mut self) {
self.notification_manager.sync_muted_chats(&self.chat_manager.chats); self.notification_manager
.sync_muted_chats(&self.chat_manager.chats);
} }
// ============ Account switching ============ // ============ Account switching ============

View File

@@ -7,7 +7,10 @@ use crate::types::MessageId;
use tdlib_rs::enums::{MessageContent, MessageSender}; use tdlib_rs::enums::{MessageContent, MessageSender};
use tdlib_rs::types::Message as TdMessage; use tdlib_rs::types::Message as TdMessage;
use super::types::{ForwardInfo, MediaInfo, PhotoDownloadState, PhotoInfo, ReactionInfo, ReplyInfo, VoiceDownloadState, VoiceInfo}; use super::types::{
ForwardInfo, MediaInfo, PhotoDownloadState, PhotoInfo, ReactionInfo, ReplyInfo,
VoiceDownloadState, VoiceInfo,
};
/// Извлекает текст контента из TDLib Message /// Извлекает текст контента из TDLib Message
/// ///
@@ -95,9 +98,9 @@ pub async fn extract_sender_name(msg: &TdMessage, client_id: i32) -> String {
match &msg.sender_id { match &msg.sender_id {
MessageSender::User(user) => { MessageSender::User(user) => {
match tdlib_rs::functions::get_user(user.user_id, client_id).await { match tdlib_rs::functions::get_user(user.user_id, client_id).await {
Ok(tdlib_rs::enums::User::User(u)) => { Ok(tdlib_rs::enums::User::User(u)) => format!("{} {}", u.first_name, u.last_name)
format!("{} {}", u.first_name, u.last_name).trim().to_string() .trim()
} .to_string(),
_ => format!("User {}", user.user_id), _ => format!("User {}", user.user_id),
} }
} }
@@ -155,12 +158,7 @@ pub fn extract_media_info(msg: &TdMessage) -> Option<MediaInfo> {
PhotoDownloadState::NotDownloaded PhotoDownloadState::NotDownloaded
}; };
Some(MediaInfo::Photo(PhotoInfo { Some(MediaInfo::Photo(PhotoInfo { file_id, width, height, download_state }))
file_id,
width,
height,
download_state,
}))
} }
MessageContent::MessageVoiceNote(v) => { MessageContent::MessageVoiceNote(v) => {
let file_id = v.voice_note.voice.id; 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}; use super::types::{ForwardInfo, MessageInfo, ReactionInfo, ReplyInfo};
/// Конвертирует TDLib сообщение в MessageInfo /// Конвертирует TDLib сообщение в MessageInfo
pub fn convert_message( pub fn convert_message(client: &mut TdClient, message: &TdMessage, chat_id: ChatId) -> MessageInfo {
client: &mut TdClient,
message: &TdMessage,
chat_id: ChatId,
) -> MessageInfo {
let sender_name = match &message.sender_id { let sender_name = match &message.sender_id {
tdlib_rs::enums::MessageSender::User(user) => { tdlib_rs::enums::MessageSender::User(user) => {
// Пробуем получить имя из кеша (get обновляет LRU порядок) // Пробуем получить имя из кеша (get обновляет LRU порядок)
@@ -120,7 +116,7 @@ pub fn extract_reply_info(client: &TdClient, message: &TdMessage) -> Option<Repl
let sender_name = reply let sender_name = reply
.origin .origin
.as_ref() .as_ref()
.map(|origin| get_origin_sender_name(origin)) .map(get_origin_sender_name)
.unwrap_or_else(|| { .unwrap_or_else(|| {
// Пробуем найти оригинальное сообщение в текущем списке // Пробуем найти оригинальное сообщение в текущем списке
let reply_msg_id = MessageId::new(reply.message_id); let reply_msg_id = MessageId::new(reply.message_id);
@@ -138,12 +134,7 @@ pub fn extract_reply_info(client: &TdClient, message: &TdMessage) -> Option<Repl
.quote .quote
.as_ref() .as_ref()
.map(|q| q.text.text.clone()) .map(|q| q.text.text.clone())
.or_else(|| { .or_else(|| reply.content.as_ref().map(TdClient::extract_content_text))
reply
.content
.as_ref()
.map(TdClient::extract_content_text)
})
.unwrap_or_else(|| { .unwrap_or_else(|| {
// Пробуем найти в текущих сообщениях // Пробуем найти в текущих сообщениях
client client
@@ -154,11 +145,7 @@ pub fn extract_reply_info(client: &TdClient, message: &TdMessage) -> Option<Repl
.unwrap_or_default() .unwrap_or_default()
}); });
Some(ReplyInfo { Some(ReplyInfo { message_id: reply_msg_id, sender_name, text })
message_id: reply_msg_id,
sender_name,
text,
})
} }
_ => None, _ => None,
} }
@@ -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 let msg_data: std::collections::HashMap<i64, (String, String)> = client
.current_chat_messages() .current_chat_messages()
.iter() .iter()
.map(|m| { .map(|m| (m.id().as_i64(), (m.sender_name().to_string(), m.text().to_string())))
(
m.id().as_i64(),
(m.sender_name().to_string(), m.text().to_string()),
)
})
.collect(); .collect();
// Обновляем reply_to для сообщений с неполными данными // Обновляем reply_to для сообщений с неполными данными

View File

@@ -12,8 +12,8 @@ impl MessageManager {
/// Конвертировать TdMessage в MessageInfo /// Конвертировать TdMessage в MessageInfo
pub(crate) async fn convert_message(&self, msg: &TdMessage) -> Option<MessageInfo> { pub(crate) async fn convert_message(&self, msg: &TdMessage) -> Option<MessageInfo> {
use crate::tdlib::message_conversion::{ use crate::tdlib::message_conversion::{
extract_content_text, extract_entities, extract_forward_info, extract_content_text, extract_entities, extract_forward_info, extract_media_info,
extract_media_info, extract_reactions, extract_reply_info, extract_sender_name, extract_reactions, extract_reply_info, extract_sender_name,
}; };
// Извлекаем все части сообщения используя вспомогательные функции // Извлекаем все части сообщения используя вспомогательные функции
@@ -122,12 +122,7 @@ impl MessageManager {
}; };
// Extract text preview (first 50 chars) // Extract text preview (first 50 chars)
let text_preview: String = orig_info let text_preview: String = orig_info.content.text.chars().take(50).collect();
.content
.text
.chars()
.take(50)
.collect();
// Update reply info in all messages that reference this message // Update reply info in all messages that reference this message
self.current_chat_messages self.current_chat_messages

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

@@ -1,7 +1,7 @@
//! Chat list panel: search box, chat items, and user online status. //! 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::methods::{compose::ComposeMethods, search::SearchMethods};
use crate::app::App;
use crate::tdlib::TdClientTrait; use crate::tdlib::TdClientTrait;
use crate::tdlib::UserOnlineStatus; use crate::tdlib::UserOnlineStatus;
use crate::ui::components; 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 { let search_style = if app.is_searching {
Style::default().fg(Color::Yellow) Style::default().fg(Color::Yellow)
} else { } else {
Style::default().fg(Color::DarkGray) Style::default().fg(Color::Rgb(160, 160, 160))
}; };
let search = Paragraph::new(search_text) let search = Paragraph::new(search_text)
.block(Block::default().borders(Borders::ALL)) .block(Block::default().borders(Borders::ALL))
@@ -76,7 +76,9 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &mut App<T>) {
app.selected_chat_id app.selected_chat_id
} else { } else {
let filtered = app.get_filtered_chats(); 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 { 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)), 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 emojis_per_row = 8;
let rows = (available_reactions.len() + emojis_per_row - 1) / emojis_per_row; let rows = available_reactions.len().div_ceil(emojis_per_row);
let modal_width = 50u16; let modal_width = 50u16;
let modal_height = (rows + 4) as u16; // +4 для заголовка, отступов и подсказки let modal_height = (rows + 4) as u16; // +4 для заголовка, отступов и подсказки
@@ -29,12 +29,7 @@ pub fn render_emoji_picker(
let x = area.x + (area.width.saturating_sub(modal_width)) / 2; let x = area.x + (area.width.saturating_sub(modal_width)) / 2;
let y = area.y + (area.height.saturating_sub(modal_height)) / 2; let y = area.y + (area.height.saturating_sub(modal_height)) / 2;
let modal_area = Rect::new( let modal_area = Rect::new(x, y, modal_width.min(area.width), modal_height.min(area.height));
x,
y,
modal_width.min(area.width),
modal_height.min(area.height),
);
// Очищаем область под модалкой // Очищаем область под модалкой
f.render_widget(Clear, modal_area); f.render_widget(Clear, modal_area);
@@ -87,10 +82,7 @@ pub fn render_emoji_picker(
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
), ),
Span::raw("Добавить "), Span::raw("Добавить "),
Span::styled( Span::styled(" [Esc] ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
" [Esc] ",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
),
Span::raw("Отмена"), Span::raw("Отмена"),
])); ]));

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,7 @@
use crate::app::App; use crate::app::App;
use crate::tdlib::TdClientTrait; 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::{ use ratatui::{
layout::{Constraint, Direction, Layout, Rect}, layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style}, style::{Color, Modifier, Style},
@@ -14,10 +14,8 @@ use ratatui::{
/// Renders pinned messages mode /// Renders pinned messages mode
pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) { pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
// Извлекаем данные из ChatState // Извлекаем данные из ChatState
let (messages, selected_index) = if let crate::app::ChatState::PinnedMessages { let (messages, selected_index) =
messages, if let crate::app::ChatState::PinnedMessages { messages, selected_index } = &app.chat_state
selected_index,
} = &app.chat_state
{ {
(messages.as_slice(), *selected_index) (messages.as_slice(), *selected_index)
} else { } else {

View File

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

View File

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

View File

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

View File

@@ -6,6 +6,6 @@ pub mod validation;
pub use formatting::*; pub use formatting::*;
// pub use modal_handler::*; // Используется через явный import // 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 tdlib::*;
pub use validation::*; pub use validation::*;

View File

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

View File

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

View File

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

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