Compare commits

...

56 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
Mikhail Kilin
2442a90e23 ci: add Woodpecker CI pipeline for PR checks (fmt, clippy, test)
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 16:53:15 +03:00
Mikhail Kilin
48d883a746 Merge branch 'refactor' 2026-02-22 16:52:31 +03:00
Mikhail Kilin
df19bc742c fix: add photo_download_rx channel and fix account switcher nav tests
Some checks 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
Add UnboundedReceiver for background photo downloads to App state,
reset it on close_chat. Fix account_switcher tests to navigate past
all accounts dynamically instead of assuming single account.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 16:19:04 +03:00
Mikhail Kilin
78fe09bf11 feat: implement photo albums (media groups) and persist account selection
Group photos with shared media_album_id into single album bubbles with
grid layout (up to 3x cols). Album navigation treats grouped photos as
one unit (j/k skip entire album). Persist selected account to
accounts.toml so it survives app restart.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 16:18:04 +03:00
Mikhail Kilin
8bd08318bb fixes
Some checks 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-14 17:57:37 +03:00
Mikhail Kilin
6639dc876c fixes 2026-02-13 19:52:53 +03:00
Mikhail Kilin
6d08300daa feat: implement audio seeking with arrow keys via ffplay restart
Seek now works by restarting ffplay with -ss offset instead of the
broken player.seek() stub. MoveLeft/MoveRight added as aliases for
SeekBackward/SeekForward to fix HashMap non-deterministic iteration
order causing Left arrow to resolve to MoveLeft instead of SeekBackward.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 18:51:45 +03:00
Mikhail Kilin
8a467b6418 feat: complete Phase 12 — voice playback ticker, cache, config, and UI
Add playback position ticker in event loop with 1s UI refresh rate,
integrate VoiceCache for downloaded voice files, add [audio] config
section (cache_size_mb, auto_download_voice), and render progress bar
with waveform visualization in message bubbles.

Fix race conditions in AudioPlayer: add `starting` flag to prevent
false `is_stopped()` during ffplay startup, guard pid cleanup so old
threads don't overwrite newer process pids. Implement `resume_from()`
with ffplay `-ss` for real audio seek on unpause (-1s rewind).

Kill ffplay on app exit via `stop_playback()` in shutdown + Drop impl.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 16:37:02 +03:00
Mikhail Kilin
7bc264198f feat: implement Phase 12 — voice message playback with ffplay
Add voice message playback infrastructure:
- AudioPlayer using ffplay subprocess with SIGSTOP/SIGCONT for pause/resume
- VoiceCache with LRU eviction (100 MB limit)
- TDLib integration: VoiceInfo, VoiceDownloadState, PlaybackState types
- download_voice_note() in TdClientTrait
- Keybindings: Space (play/pause), ←/→ (seek ±5s)
- Auto-stop playback on message navigation
- Remove debug_log module
2026-02-09 02:35:49 +03:00
Mikhail Kilin
2a5fd6aa35 perf: optimize Phase 11 image rendering with dual-protocol architecture
Redesigned UX and performance for inline photo viewing:

UX changes:
- Always-show inline preview (fixed 50 chars width)
- Fullscreen modal on 'v' key with ←/→ navigation between photos
- Loading indicator " Загрузка..." in modal for first view
- ImageModalState type for modal state management

Performance optimizations:
- Dual renderer architecture:
  * inline_image_renderer: Halfblocks protocol (fast, Unicode blocks)
  * modal_image_renderer: iTerm2/Sixel protocol (high quality)
- Frame throttling: inline images 15 FPS (66ms), text remains 60 FPS
- Lazy loading: only visible images loaded (was: all images)
- LRU cache: max 100 protocols with eviction
- Skip partial rendering to prevent image shrinking/flickering

Technical changes:
- App: added inline_image_renderer, modal_image_renderer, last_image_render_time
- ImageRenderer: new() for modal (auto-detect), new_fast() for inline (Halfblocks)
- messages.rs: throttled second-pass rendering, visible-only loading
- modals/image_viewer.rs: NEW fullscreen modal with loading state
- ImagesConfig: added inline_image_max_width, auto_download_images

Result: 10x faster navigation, smooth 60 FPS text, quality modal viewing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 01:36:36 +03:00
Mikhail Kilin
b0f1f9fdc2 feat: implement Phase 11 — inline photo viewing with ratatui-image
Add feature-gated (`images`) inline photo support:
- New types: MediaInfo, PhotoInfo, PhotoDownloadState, ImagesConfig
- Media module: ImageCache (LRU filesystem cache), ImageRenderer (terminal protocol detection)
- Photo metadata extraction from TDLib MessagePhoto with download_file() API
- ViewImage command (v/м) to toggle photo expand/collapse in message selection
- Two-pass UI rendering: placeholder lines in message bubbles + StatefulImage overlay
- Collapse all expanded photos on Esc (exit selection mode)

Dependencies: ratatui-image 8.1, image 0.25 (optional, behind `images` feature flag)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 21:25:17 +03:00
Mikhail Kilin
6845ee69bf docs: trim CONTEXT.md and ROADMAP.md (3006→246 lines, -92%)
Completed phases condensed to summary tables, detailed history
removed (available in git log). Detailed plans kept only for
upcoming phases 11 (images) and 12 (voice messages).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 16:57:27 +03:00
Mikhail Kilin
ffd52d2384 refactor: complete Phase 13 deep architecture refactoring (etaps 3-7)
Split monolithic files into modular architecture:
- ui/messages.rs (893→365 lines): extract modals/, compose_bar.rs
- tdlib/messages.rs (836→3 files): split into messages/mod, convert, operations
- config/mod.rs (642→3 files): extract validation.rs, loader.rs
- Code duplication cleanup: shared components, ~220 lines removed
- Documentation: PROJECT_STRUCTURE.md rewrite, 16 files got //! docs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 15:28:11 +03:00
Mikhail Kilin
931954d829 refactor: split app/mod.rs into trait-based architecture (1015→371 lines)
Split monolithic App impl into 5 specialized trait modules:
- methods/navigation.rs (NavigationMethods) - 7 methods for chat navigation
- methods/messages.rs (MessageMethods) - 8 methods for message operations
- methods/compose.rs (ComposeMethods) - 10 methods for reply/forward/draft
- methods/search.rs (SearchMethods) - 15 methods for search functionality
- methods/modal.rs (ModalMethods) - 27 methods for modal dialogs

Changes:
- app/mod.rs: 1015→371 lines (removed 644 lines, -63%)
- Created app/methods/ with 5 trait impl blocks
- Left in app/mod.rs: constructors, get_command, get_selected_chat_id/chat, getters/setters
- 116 functions → 5 trait impl blocks (67 in traits + 48 in core)
- Single Responsibility Principle achieved
- Updated CONTEXT.md with refactoring metrics
- Updated ROADMAP.md: Phase 13 Etap 2 marked as DONE

Phase 13 Etap 2: COMPLETED (100%)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-06 00:59:14 +03:00
Mikhail Kilin
1d0bfb53e0 refactor: split main_input.rs into modular handlers (1199→164 lines)
Split monolithic input handler into 5 specialized modules:
- handlers/chat.rs (452 lines) - chat keyboard input
- handlers/modal.rs (316 lines) - modal dialogs
- handlers/chat_list.rs (142 lines) - chat list navigation
- handlers/search.rs (140 lines) - search functionality
- handlers/compose.rs (80 lines) - forward/reply/edit modes

Changes:
- main_input.rs: 1199→164 lines (removed 1035 lines, -86%)
- Preserved existing handlers: clipboard, global, profile
- Created clean router pattern in main_input.rs
- Fixed keybinding conflict: Ctrl+I→Ctrl+U for profile
- Fixed modifier handling in chat input (ignore Ctrl/Alt chars)
- Updated CONTEXT.md with refactoring metrics
- Updated ROADMAP.md: Phase 13 Etap 1 marked as DONE

Phase 13 Etap 1: COMPLETED (100%)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-06 00:43:52 +03:00
Mikhail Kilin
c5235de6e2 fix: disable notifications in config defaults
The previous commit only changed NotificationManager::new() but the config
layer was overriding it with default_notifications_enabled() = true.

Changed default_notifications_enabled() to return false, which is the
authoritative source for notification settings.

Modified:
- src/config/mod.rs - default_notifications_enabled: true -> false

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-05 22:01:25 +03:00
7ca9ea29ea Merge pull request 'refactor' (#19) from refactor into main
Some checks failed
CI / Check (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Build (macos-latest) (push) Has been cancelled
CI / Build (ubuntu-latest) (push) Has been cancelled
CI / Build (windows-latest) (push) Has been cancelled
Reviewed-on: #19
2026-02-05 18:58:39 +00:00
d10dc6599a Merge pull request 'refactor' (#18) from refactor into main
Some checks failed
CI / Check (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Build (macos-latest) (push) Has been cancelled
CI / Build (ubuntu-latest) (push) Has been cancelled
CI / Build (windows-latest) (push) Has been cancelled
Reviewed-on: #18
2026-02-04 17:08:43 +00:00
0cd477f294 Merge pull request 'refactor: complete UI components - implement message_bubble.rs' (#17) from add_tests into main
Some checks failed
CI / Check (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Build (macos-latest) (push) Has been cancelled
CI / Build (ubuntu-latest) (push) Has been cancelled
CI / Build (windows-latest) (push) Has been cancelled
Reviewed-on: #17
2026-02-02 00:45:42 +00:00
8855a07ccd Merge pull request 'add_tests' (#16) from add_tests into main
Some checks failed
CI / Check (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Build (macos-latest) (push) Has been cancelled
CI / Build (ubuntu-latest) (push) Has been cancelled
CI / Build (windows-latest) (push) Has been cancelled
Reviewed-on: #16
2026-02-02 00:19:58 +00:00
9cc63952f4 Merge pull request 'add_tests' (#15) from add_tests into main
Some checks failed
CI / Check (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Build (macos-latest) (push) Has been cancelled
CI / Build (ubuntu-latest) (push) Has been cancelled
CI / Build (windows-latest) (push) Has been cancelled
Reviewed-on: #15
2026-02-01 15:59:43 +00:00
0a4ab1b40d Merge pull request 'add_tests' (#14) from add_tests into main
Some checks failed
CI / Check (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Build (macos-latest) (push) Has been cancelled
CI / Build (ubuntu-latest) (push) Has been cancelled
CI / Build (windows-latest) (push) Has been cancelled
Reviewed-on: #14
2026-01-31 23:42:29 +00:00
20f1c470c4 Merge pull request 'add_tests' (#13) from add_tests into main
Some checks failed
CI / Format (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Build (macos-latest) (push) Has been cancelled
CI / Build (ubuntu-latest) (push) Has been cancelled
CI / Build (windows-latest) (push) Has been cancelled
CI / Check (push) Has been cancelled
Reviewed-on: #13
2026-01-31 23:06:22 +00:00
c2ddb0a449 Merge pull request 'add_tests' (#11) from add_tests into main
Some checks failed
CI / Check (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Build (macos-latest) (push) Has been cancelled
CI / Build (ubuntu-latest) (push) Has been cancelled
CI / Build (windows-latest) (push) Has been cancelled
Reviewed-on: #11
2026-01-28 22:23:58 +00:00
72a8f3e6b1 Merge pull request 'yet-another-changes' (#10) from yet-another-changes into main
Some checks failed
CI / Check (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Build (macos-latest) (push) Has been cancelled
CI / Build (ubuntu-latest) (push) Has been cancelled
CI / Build (windows-latest) (push) Has been cancelled
Reviewed-on: #10
2026-01-27 22:43:12 +00:00
61dc09fd50 Merge pull request 'yet-another-changes' (#9) from yet-another-changes into main
Reviewed-on: #9
2026-01-27 09:12:57 +00:00
86e2b4c804 Merge pull request 'yet-another-changes' (#8) from yet-another-changes into main
Reviewed-on: #8
2026-01-24 22:49:27 +00:00
6c297758a0 Merge pull request 'fixes' (#5) from yet-another-changes into main
Reviewed-on: #5
2026-01-23 23:23:49 +00:00
65a73f35de Merge pull request 'yet-another-changes' (#4) from yet-another-changes into main
Reviewed-on: #4
2026-01-22 12:27:08 +00:00
0f379dc240 Merge pull request 'fixes' (#3) from yet-another-changes into main
Reviewed-on: #3
2026-01-20 23:59:28 +00:00
b81eec55d6 Merge pull request 'fixes' (#2) from yet-another-changes into main
Reviewed-on: #2
2026-01-20 23:29:12 +00:00
652b101571 Merge pull request 'yet-another-changes' (#1) from yet-another-changes into main
Reviewed-on: #1
2026-01-20 11:55:25 +00:00
163 changed files with 11436 additions and 16326 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:

26
.woodpecker/check.yml Normal file
View File

@@ -0,0 +1,26 @@
when:
- event: pull_request
steps:
- name: fmt
image: rust:latest
commands:
- rustup component add rustfmt
- cargo fmt -- --check
- name: clippy
image: rust:latest
environment:
CARGO_HOME: /tmp/cargo
commands:
- apt-get update -qq && apt-get install -y -qq pkg-config libssl-dev libdbus-1-dev zlib1g-dev > /dev/null 2>&1
- rustup component add clippy
- cargo clippy -- -D warnings
- name: test
image: rust:latest
environment:
CARGO_HOME: /tmp/cargo
commands:
- apt-get update -qq && apt-get install -y -qq pkg-config libssl-dev libdbus-1-dev zlib1g-dev > /dev/null 2>&1
- cargo test

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

2345
CONTEXT.md

File diff suppressed because it is too large Load Diff

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

661
Cargo.lock generated
View File

@@ -28,6 +28,24 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "aligned"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685"
dependencies = [
"as-slice",
]
[[package]]
name = "aligned-vec"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b"
dependencies = [
"equator",
]
[[package]] [[package]]
name = "allocator-api2" name = "allocator-api2"
version = "0.2.21" version = "0.2.21"
@@ -55,6 +73,12 @@ version = "1.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
[[package]]
name = "anyhow"
version = "1.0.101"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea"
[[package]] [[package]]
name = "arbitrary" name = "arbitrary"
version = "1.4.2" version = "1.4.2"
@@ -84,6 +108,32 @@ dependencies = [
"x11rb", "x11rb",
] ]
[[package]]
name = "arg_enum_proc_macro"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "arrayvec"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "as-slice"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516"
dependencies = [
"stable_deref_trait",
]
[[package]] [[package]]
name = "async-broadcast" name = "async-broadcast"
version = "0.7.2" version = "0.7.2"
@@ -227,18 +277,86 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "av-scenechange"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394"
dependencies = [
"aligned",
"anyhow",
"arg_enum_proc_macro",
"arrayvec",
"log",
"num-rational",
"num-traits",
"pastey",
"rayon",
"thiserror 2.0.18",
"v_frame",
"y4m",
]
[[package]]
name = "av1-grain"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8"
dependencies = [
"anyhow",
"arrayvec",
"log",
"nom",
"num-rational",
"v_frame",
]
[[package]]
name = "avif-serialize"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47c8fbc0f831f4519fe8b810b6a7a91410ec83031b8233f730a0480029f6a23f"
dependencies = [
"arrayvec",
]
[[package]] [[package]]
name = "base64" name = "base64"
version = "0.22.1" version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64-simd"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195"
dependencies = [
"outref",
"vsimd",
]
[[package]]
name = "bit_field"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6"
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "2.10.0" version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
[[package]]
name = "bitstream-io"
version = "4.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60d4bd9d1db2c6bdf285e223a7fa369d5ce98ec767dec949c6ca62863ce61757"
dependencies = [
"core2",
]
[[package]] [[package]]
name = "block-buffer" name = "block-buffer"
version = "0.10.4" version = "0.10.4"
@@ -270,6 +388,12 @@ dependencies = [
"piper", "piper",
] ]
[[package]]
name = "built"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f4ad8f11f288f48ca24471bbd51ac257aaeaaa07adae295591266b792902ae64"
[[package]] [[package]]
name = "bumpalo" name = "bumpalo"
version = "3.19.1" version = "3.19.1"
@@ -443,6 +567,12 @@ dependencies = [
"error-code", "error-code",
] ]
[[package]]
name = "color_quant"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]] [[package]]
name = "compact_str" name = "compact_str"
version = "0.8.1" version = "0.8.1"
@@ -500,6 +630,15 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "core2"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "cpufeatures" name = "cpufeatures"
version = "0.2.17" version = "0.2.17"
@@ -865,6 +1004,26 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "equator"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc"
dependencies = [
"equator-macro",
]
[[package]]
name = "equator-macro"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "equivalent" name = "equivalent"
version = "1.0.2" version = "1.0.2"
@@ -908,6 +1067,21 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
] ]
[[package]]
name = "exr"
version = "1.74.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be"
dependencies = [
"bit_field",
"half",
"lebe",
"miniz_oxide",
"rayon-core",
"smallvec",
"zune-inflate",
]
[[package]] [[package]]
name = "fastrand" name = "fastrand"
version = "2.3.0" version = "2.3.0"
@@ -995,6 +1169,16 @@ dependencies = [
"percent-encoding", "percent-encoding",
] ]
[[package]]
name = "fs2"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213"
dependencies = [
"libc",
"winapi",
]
[[package]] [[package]]
name = "futures-channel" name = "futures-channel"
version = "0.3.31" version = "0.3.31"
@@ -1103,6 +1287,16 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "gif"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f5df2ba84018d80c213569363bdcd0c64e6933c67fe4c1d60ecf822971a3c35e"
dependencies = [
"color_quant",
"weezl",
]
[[package]] [[package]]
name = "h2" name = "h2"
version = "0.4.13" version = "0.4.13"
@@ -1407,6 +1601,12 @@ dependencies = [
"zerovec", "zerovec",
] ]
[[package]]
name = "icy_sixel"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccc0a9c4770bc47b0a933256a496cfb8b6531f753ea9bccb19c6dff0ff7273fc"
[[package]] [[package]]
name = "ident_case" name = "ident_case"
version = "1.0.1" version = "1.0.1"
@@ -1442,12 +1642,38 @@ checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a"
dependencies = [ dependencies = [
"bytemuck", "bytemuck",
"byteorder-lite", "byteorder-lite",
"color_quant",
"exr",
"gif",
"image-webp",
"moxcms", "moxcms",
"num-traits", "num-traits",
"png", "png",
"qoi",
"ravif",
"rayon",
"rgb",
"tiff", "tiff",
"zune-core 0.5.1",
"zune-jpeg 0.5.12",
] ]
[[package]]
name = "image-webp"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
dependencies = [
"byteorder-lite",
"quick-error",
]
[[package]]
name = "imgref"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8"
[[package]] [[package]]
name = "indexmap" name = "indexmap"
version = "1.9.3" version = "1.9.3"
@@ -1514,6 +1740,17 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "interpolate_name"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "ipnet" name = "ipnet"
version = "2.11.0" version = "2.11.0"
@@ -1578,6 +1815,15 @@ dependencies = [
"either", "either",
] ]
[[package]]
name = "itertools"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
dependencies = [
"either",
]
[[package]] [[package]]
name = "itoa" name = "itoa"
version = "1.0.17" version = "1.0.17"
@@ -1610,12 +1856,28 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "lebe"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8"
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.180" version = "0.2.180"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
[[package]]
name = "libfuzzer-sys"
version = "0.4.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5037190e1f70cbeef565bd267599242926f724d3b8a9f510fd7e0b540cfa4404"
dependencies = [
"arbitrary",
"cc",
]
[[package]] [[package]]
name = "libredox" name = "libredox"
version = "0.1.12" version = "0.1.12"
@@ -1659,6 +1921,15 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "loop9"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062"
dependencies = [
"imgref",
]
[[package]] [[package]]
name = "lru" name = "lru"
version = "0.12.5" version = "0.12.5"
@@ -1710,6 +1981,16 @@ dependencies = [
"regex-automata", "regex-automata",
] ]
[[package]]
name = "maybe-rayon"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519"
dependencies = [
"cfg-if",
"rayon",
]
[[package]] [[package]]
name = "memchr" name = "memchr"
version = "2.7.6" version = "2.7.6"
@@ -1780,6 +2061,27 @@ dependencies = [
"tempfile", "tempfile",
] ]
[[package]]
name = "new_debug_unreachable"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "nom"
version = "8.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
dependencies = [
"memchr",
]
[[package]]
name = "noop_proc_macro"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8"
[[package]] [[package]]
name = "notify-rust" name = "notify-rust"
version = "4.12.0" version = "4.12.0"
@@ -1803,12 +2105,53 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "num-bigint"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
dependencies = [
"num-integer",
"num-traits",
]
[[package]] [[package]]
name = "num-conv" name = "num-conv"
version = "0.2.0" version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050"
[[package]]
name = "num-derive"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "num-integer"
version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
dependencies = [
"num-traits",
]
[[package]]
name = "num-rational"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
dependencies = [
"num-bigint",
"num-integer",
"num-traits",
]
[[package]] [[package]]
name = "num-traits" name = "num-traits"
version = "0.2.19" version = "0.2.19"
@@ -1976,6 +2319,12 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
] ]
[[package]]
name = "outref"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e"
[[package]] [[package]]
name = "parking" name = "parking"
version = "2.2.1" version = "2.2.1"
@@ -2011,6 +2360,12 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "pastey"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
[[package]] [[package]]
name = "pathdiff" name = "pathdiff"
version = "0.2.3" version = "0.2.3"
@@ -2132,6 +2487,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]] [[package]]
name = "proc-macro-crate" name = "proc-macro-crate"
version = "3.4.0" version = "3.4.0"
@@ -2150,6 +2514,25 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "profiling"
version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773"
dependencies = [
"profiling-procmacros",
]
[[package]]
name = "profiling-procmacros"
version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b"
dependencies = [
"quote",
"syn",
]
[[package]] [[package]]
name = "pxfm" name = "pxfm"
version = "0.1.27" version = "0.1.27"
@@ -2159,6 +2542,15 @@ dependencies = [
"num-traits", "num-traits",
] ]
[[package]]
name = "qoi"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001"
dependencies = [
"bytemuck",
]
[[package]] [[package]]
name = "quick-error" name = "quick-error"
version = "2.0.1" version = "2.0.1"
@@ -2189,6 +2581,65 @@ version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "rand"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [
"libc",
"rand_chacha 0.3.1",
"rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
dependencies = [
"rand_chacha 0.9.0",
"rand_core 0.9.5",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core 0.6.4",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.5",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom 0.2.17",
]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
[[package]] [[package]]
name = "ratatui" name = "ratatui"
version = "0.29.0" version = "0.29.0"
@@ -2210,6 +2661,72 @@ dependencies = [
"unicode-width 0.2.0", "unicode-width 0.2.0",
] ]
[[package]]
name = "ratatui-image"
version = "8.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ecc67e9f7d0ac69e0f712f58b1a9d5a04d8daeeb3628f4d6b67580abb88b7cb"
dependencies = [
"base64-simd",
"icy_sixel",
"image",
"rand 0.8.5",
"ratatui",
"rustix 0.38.44",
"thiserror 1.0.69",
"windows 0.58.0",
]
[[package]]
name = "rav1e"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b"
dependencies = [
"aligned-vec",
"arbitrary",
"arg_enum_proc_macro",
"arrayvec",
"av-scenechange",
"av1-grain",
"bitstream-io",
"built",
"cfg-if",
"interpolate_name",
"itertools 0.14.0",
"libc",
"libfuzzer-sys",
"log",
"maybe-rayon",
"new_debug_unreachable",
"noop_proc_macro",
"num-derive",
"num-traits",
"paste",
"profiling",
"rand 0.9.2",
"rand_chacha 0.9.0",
"simd_helpers",
"thiserror 2.0.18",
"v_frame",
"wasm-bindgen",
]
[[package]]
name = "ravif"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef69c1990ceef18a116855938e74793a5f7496ee907562bd0857b6ac734ab285"
dependencies = [
"avif-serialize",
"imgref",
"loop9",
"quick-error",
"rav1e",
"rayon",
"rgb",
]
[[package]] [[package]]
name = "rayon" name = "rayon"
version = "1.11.0" version = "1.11.0"
@@ -2352,6 +2869,12 @@ dependencies = [
"web-sys", "web-sys",
] ]
[[package]]
name = "rgb"
version = "0.8.52"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce"
[[package]] [[package]]
name = "ring" name = "ring"
version = "0.17.14" version = "0.17.14"
@@ -2677,6 +3200,15 @@ version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
[[package]]
name = "simd_helpers"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6"
dependencies = [
"quote",
]
[[package]] [[package]]
name = "similar" name = "similar"
version = "2.7.0" version = "2.7.0"
@@ -2811,7 +3343,7 @@ checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9"
dependencies = [ dependencies = [
"quick-xml", "quick-xml",
"thiserror 2.0.18", "thiserror 2.0.18",
"windows", "windows 0.61.3",
"windows-version", "windows-version",
] ]
@@ -2855,15 +3387,19 @@ version = "0.1.0"
dependencies = [ dependencies = [
"arboard", "arboard",
"async-trait", "async-trait",
"base64",
"chrono", "chrono",
"criterion", "criterion",
"crossterm", "crossterm",
"dirs 5.0.1", "dirs 5.0.1",
"dotenvy", "dotenvy",
"fs2",
"image",
"insta", "insta",
"notify-rust", "notify-rust",
"open", "open",
"ratatui", "ratatui",
"ratatui-image",
"serde", "serde",
"serde_json", "serde_json",
"tdlib-rs", "tdlib-rs",
@@ -2948,7 +3484,7 @@ dependencies = [
"half", "half",
"quick-error", "quick-error",
"weezl", "weezl",
"zune-jpeg", "zune-jpeg 0.4.21",
] ]
[[package]] [[package]]
@@ -3355,6 +3891,17 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "v_frame"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2"
dependencies = [
"aligned-vec",
"num-traits",
"wasm-bindgen",
]
[[package]] [[package]]
name = "valuable" name = "valuable"
version = "0.1.1" version = "0.1.1"
@@ -3373,6 +3920,12 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "vsimd"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64"
[[package]] [[package]]
name = "walkdir" name = "walkdir"
version = "2.5.0" version = "2.5.0"
@@ -3513,6 +4066,16 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows"
version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6"
dependencies = [
"windows-core 0.58.0",
"windows-targets 0.52.6",
]
[[package]] [[package]]
name = "windows" name = "windows"
version = "0.61.3" version = "0.61.3"
@@ -3535,14 +4098,27 @@ dependencies = [
"windows-core 0.61.2", "windows-core 0.61.2",
] ]
[[package]]
name = "windows-core"
version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99"
dependencies = [
"windows-implement 0.58.0",
"windows-interface 0.58.0",
"windows-result 0.2.0",
"windows-strings 0.1.0",
"windows-targets 0.52.6",
]
[[package]] [[package]]
name = "windows-core" name = "windows-core"
version = "0.61.2" version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
dependencies = [ dependencies = [
"windows-implement", "windows-implement 0.60.2",
"windows-interface", "windows-interface 0.59.3",
"windows-link 0.1.3", "windows-link 0.1.3",
"windows-result 0.3.4", "windows-result 0.3.4",
"windows-strings 0.4.2", "windows-strings 0.4.2",
@@ -3554,8 +4130,8 @@ version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [ dependencies = [
"windows-implement", "windows-implement 0.60.2",
"windows-interface", "windows-interface 0.59.3",
"windows-link 0.2.1", "windows-link 0.2.1",
"windows-result 0.4.1", "windows-result 0.4.1",
"windows-strings 0.5.1", "windows-strings 0.5.1",
@@ -3572,6 +4148,17 @@ dependencies = [
"windows-threading", "windows-threading",
] ]
[[package]]
name = "windows-implement"
version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "windows-implement" name = "windows-implement"
version = "0.60.2" version = "0.60.2"
@@ -3583,6 +4170,17 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "windows-interface"
version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "windows-interface" name = "windows-interface"
version = "0.59.3" version = "0.59.3"
@@ -3627,6 +4225,15 @@ dependencies = [
"windows-strings 0.5.1", "windows-strings 0.5.1",
] ]
[[package]]
name = "windows-result"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e"
dependencies = [
"windows-targets 0.52.6",
]
[[package]] [[package]]
name = "windows-result" name = "windows-result"
version = "0.3.4" version = "0.3.4"
@@ -3645,6 +4252,16 @@ dependencies = [
"windows-link 0.2.1", "windows-link 0.2.1",
] ]
[[package]]
name = "windows-strings"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10"
dependencies = [
"windows-result 0.2.0",
"windows-targets 0.52.6",
]
[[package]] [[package]]
name = "windows-strings" name = "windows-strings"
version = "0.4.2" version = "0.4.2"
@@ -3959,6 +4576,12 @@ dependencies = [
"lzma-sys", "lzma-sys",
] ]
[[package]]
name = "y4m"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448"
[[package]] [[package]]
name = "yoke" name = "yoke"
version = "0.8.1" version = "0.8.1"
@@ -4219,13 +4842,37 @@ version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a"
[[package]]
name = "zune-core"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9"
[[package]]
name = "zune-inflate"
version = "0.2.54"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02"
dependencies = [
"simd-adler32",
]
[[package]] [[package]]
name = "zune-jpeg" name = "zune-jpeg"
version = "0.4.21" version = "0.4.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713"
dependencies = [ dependencies = [
"zune-core", "zune-core 0.4.12",
]
[[package]]
name = "zune-jpeg"
version = "0.5.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "410e9ecef634c709e3831c2cfdb8d9c32164fae1c67496d5b68fff728eec37fe"
dependencies = [
"zune-core 0.5.1",
] ]
[[package]] [[package]]

View File

@@ -10,10 +10,11 @@ keywords = ["telegram", "tui", "terminal", "cli"]
categories = ["command-line-utilities"] categories = ["command-line-utilities"]
[features] [features]
default = ["clipboard", "url-open", "notifications"] default = ["clipboard", "url-open", "notifications", "images"]
clipboard = ["dep:arboard"] clipboard = ["dep:arboard"]
url-open = ["dep:open"] url-open = ["dep:open"]
notifications = ["dep:notify-rust"] notifications = ["dep:notify-rust"]
images = ["dep:ratatui-image", "dep:image"]
[dependencies] [dependencies]
ratatui = "0.29" ratatui = "0.29"
@@ -28,11 +29,15 @@ chrono = "0.4"
open = { version = "5.0", optional = true } open = { version = "5.0", optional = true }
arboard = { version = "3.4", optional = true } arboard = { version = "3.4", optional = true }
notify-rust = { version = "4.11", optional = true } notify-rust = { version = "4.11", optional = true }
ratatui-image = { version = "8.1", optional = true, features = ["image-defaults"] }
image = { version = "0.25", optional = true }
toml = "0.8" toml = "0.8"
dirs = "5.0" dirs = "5.0"
thiserror = "1.0" thiserror = "1.0"
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
base64 = "0.22.1"
fs2 = "0.4"
[dev-dependencies] [dev-dependencies]
insta = "1.34" insta = "1.34"

227
FAQ.md
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,453 +1,328 @@
# Структура проекта # Структура проекта
## Архитектура (ASCII)
```
┌─────────────┐
│ main.rs │ Event loop (60 FPS)
└──────┬──────┘
┌────────────┼────────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ input/ │ │ app/ │ │ ui/ │
│ handlers │ │ state │ │ render │
└────┬─────┘ └────┬─────┘ └────┬─────┘
│ │ │
│ ┌──────┴──────┐ │
│ │ methods/ │ │
│ │ (5 traits) │ │
│ └──────┬──────┘ │
│ │ │
▼ ▼ ▼
┌─────────────────────────────────┐
│ tdlib/ │
│ TdClientTrait → TdClient │
│ messages/ | auth | chats │
└──────────────┬──────────────────┘
┌─────▼─────┐
│ TDLib C │
│ library │
└───────────┘
```
### Data Flow
```
TDLib Updates → mpsc channel → App state → UI rendering
User Input → handlers → App methods (traits) → TdClient → TDLib API
```
## Обзор директорий ## Обзор директорий
``` ```
tele-tui/ tele-tui/
├── .github/ # GitHub конфигурация ├── src/
│ ├── ISSUE_TEMPLATE/ # Шаблоны для issue │ ├── main.rs # Точка входа, event loop
│ ├── bug_report.md │ ├── lib.rs # Экспорт модулей для тестов
│ └── feature_request.md ├── types.rs # ChatId, MessageId (newtype wrappers)
│ ├── workflows/ # GitHub Actions CI/CD │ ├── constants.rs # MAX_MESSAGES_IN_CHAT, etc.
│ └── ci.yml ├── formatting.rs # Markdown entity форматирование
│ ├── message_grouping.rs # Группировка сообщений по дате/отправителю
│ ├── notifications.rs # Desktop уведомления (NotificationManager)
│ │
│ ├── app/ # Состояние приложения
│ │ ├── mod.rs # App<T> struct, конструкторы, getters (372 loc)
│ │ ├── state.rs # AppScreen enum
│ │ ├── chat_state.rs # ChatState enum (state machine)
│ │ ├── chat_filter.rs # ChatFilter, ChatFilterCriteria
│ │ ├── chat_list_state.rs # Состояние списка чатов
│ │ ├── auth_state.rs # Состояние авторизации
│ │ ├── compose_state.rs # Состояние compose bar
│ │ ├── ui_state.rs # UI-related state
│ │ ├── message_service.rs # Сервис сообщений
│ │ ├── message_view_state.rs # Состояние просмотра сообщений
│ │ └── methods/ # Trait-based методы App (Этап 2)
│ │ ├── mod.rs # Re-exports 5 trait модулей
│ │ ├── navigation.rs # NavigationMethods (7 методов)
│ │ ├── messages.rs # MessageMethods (8 методов)
│ │ ├── compose.rs # ComposeMethods (10 методов)
│ │ ├── search.rs # SearchMethods (15 методов)
│ │ └── modal.rs # ModalMethods (27 методов)
│ │
│ ├── config/ # Конфигурация (Этап 5)
│ │ ├── mod.rs # Config struct, defaults (350 loc)
│ │ ├── keybindings.rs # Command enum, Keybindings
│ │ ├── validation.rs # validate(), parse_color()
│ │ └── loader.rs # load(), save(), credentials
│ │
│ ├── input/ # Обработка пользовательского ввода
│ │ ├── mod.rs # Роутинг по экранам
│ │ ├── auth.rs # Ввод на экране авторизации
│ │ ├── main_input.rs # Роутер главного экрана (159 loc, Этап 1)
│ │ ├── key_handler.rs # Trait-based обработка клавиш
│ │ └── handlers/ # Специализированные обработчики (Этап 1)
│ │ ├── mod.rs # Exports + scroll_to_message()
│ │ ├── global.rs # Ctrl+R/S/P/F глобальные команды
│ │ ├── chat.rs # Открытый чат: ввод, скролл, selection
│ │ ├── chat_list.rs # Навигация по списку чатов, папки
│ │ ├── compose.rs # Forward mode
│ │ ├── modal.rs # Profile, reactions, pinned, delete
│ │ ├── search.rs # Поиск чатов и сообщений
│ │ ├── clipboard.rs # Копирование в буфер обмена
│ │ └── profile.rs # Хелперы профиля
│ │
│ ├── tdlib/ # TDLib интеграция
│ │ ├── mod.rs # Экспорт публичных типов
│ │ ├── types.rs # MessageInfo, ChatInfo, ProfileInfo, etc.
│ │ ├── trait.rs # TdClientTrait (DI для тестов)
│ │ ├── client.rs # TdClient struct, конструктор
│ │ ├── client_impl.rs # impl TdClientTrait for TdClient
│ │ ├── auth.rs # Авторизация (phone, code, 2FA)
│ │ ├── chats.rs # Загрузка чатов, папок
│ │ ├── users.rs # Кеш пользователей, статусы
│ │ ├── reactions.rs # ReactionInfo, toggle_reaction
│ │ ├── chat_helpers.rs # Вспомогательные функции чатов
│ │ ├── update_handlers.rs # Обработка TDLib update events
│ │ ├── message_converter.rs # Конвертация TDLib → MessageInfo
│ │ ├── message_conversion.rs # Доп. функции конвертации
│ │ └── messages/ # Менеджер сообщений (Этап 4)
│ │ ├── mod.rs # MessageManager struct (99 loc)
│ │ ├── convert.rs # convert_message, fetch_reply_info
│ │ └── operations.rs # 11 TDLib API операций (616 loc)
│ │
│ ├── ui/ # Рендеринг интерфейса
│ │ ├── mod.rs # render() — роутинг по экранам
│ │ ├── loading.rs # Экран загрузки
│ │ ├── auth.rs # Экран авторизации
│ │ ├── main_screen.rs # Главный экран + папки
│ │ ├── footer.rs # Футер с командами и статусом сети
│ │ ├── chat_list.rs # Список чатов + онлайн-статус
│ │ ├── messages.rs # Область сообщений (364 loc, Этап 3)
│ │ ├── compose_bar.rs # Multi-mode input box (Этап 3)
│ │ ├── profile.rs # Профиль пользователя/чата
│ │ ├── modals/ # Модальные окна (Этап 3)
│ │ │ ├── mod.rs # Re-exports
│ │ │ ├── delete_confirm.rs # Подтверждение удаления
│ │ │ ├── reaction_picker.rs # Выбор реакции
│ │ │ ├── search.rs # Поиск по сообщениям
│ │ │ └── pinned.rs # Закреплённые сообщения
│ │ └── components/ # Переиспользуемые UI компоненты (Этап 6)
│ │ ├── mod.rs # Re-exports
│ │ ├── modal.rs # render_modal(), render_delete_confirm
│ │ ├── input_field.rs # render_input_field()
│ │ ├── message_bubble.rs # render_message_bubble(), sender, date
│ │ ├── message_list.rs # render_message_item(), help_bar, scroll
│ │ ├── chat_list_item.rs # render_chat_list_item()
│ │ └── emoji_picker.rs # render_emoji_picker()
│ │
│ └── utils/ # Утилиты
│ ├── mod.rs # Exports, with_timeout helpers
│ ├── formatting.rs # format_timestamp, format_date, etc.
│ ├── tdlib.rs # disable_tdlib_logs (FFI)
│ ├── validation.rs # is_non_empty и др.
│ ├── modal_handler.rs # handle_yes_no для Y/N модалок
│ └── retry.rs # Retry утилиты
├── tests/ # Интеграционные тесты
│ ├── helpers/ # Тестовая инфраструктура
│ │ ├── mod.rs
│ │ ├── app_builder.rs # TestAppBuilder (fluent API)
│ │ ├── fake_tdclient.rs # FakeTdClient (мок TDLib)
│ │ ├── fake_tdclient_impl.rs # impl TdClientTrait for FakeTdClient
│ │ ├── test_data.rs # create_test_chat, TestMessageBuilder
│ │ └── snapshot_utils.rs # Snapshot testing хелперы
│ ├── input_navigation.rs # Тесты навигации клавиатурой
│ ├── chat_list.rs # Тесты списка чатов
│ ├── messages.rs # Тесты сообщений
│ ├── send_message.rs # Тесты отправки
│ ├── edit_message.rs # Тесты редактирования
│ ├── delete_message.rs # Тесты удаления
│ ├── reply_forward.rs # Тесты reply/forward
│ ├── reactions.rs # Тесты реакций
│ ├── search.rs # Тесты поиска
│ ├── modals.rs # Тесты модальных окон
│ ├── profile.rs # Тесты профиля
│ ├── navigation.rs # Тесты навигации
│ ├── drafts.rs # Тесты черновиков
│ ├── copy.rs # Тесты копирования
│ ├── screens.rs # Тесты экранов
│ ├── footer.rs # Тесты футера
│ ├── input_field.rs # Тесты поля ввода
│ ├── config.rs # Тесты конфигурации
│ ├── network_typing.rs # Тесты typing status
│ ├── e2e_smoke.rs # Smoke тесты
│ └── e2e_user_journey.rs # E2E user journey тесты
├── .github/ # GitHub конфигурация
│ ├── ISSUE_TEMPLATE/
│ ├── workflows/ci.yml
│ └── pull_request_template.md │ └── pull_request_template.md
├── docs/ # Дополнительная документация ├── Cargo.toml # Манифест проекта
│ └── TDLIB_INTEGRATION.md ├── Cargo.lock # Точные версии зависимостей
├── build.rs # Build script (TDLib)
├── rustfmt.toml # cargo fmt конфигурация
├── .editorconfig # Настройки IDE
├── .gitignore # Git ignore
├── src/ # Исходный код ├── config.toml.example # Пример конфигурации
│ ├── app/ # Состояние приложения ├── credentials.example # Пример credentials
│ │ ├── mod.rs
│ │ └── state.rs
│ ├── input/ # Обработка пользовательского ввода
│ │ ├── mod.rs
│ │ ├── auth.rs
│ │ └── main_input.rs
│ ├── audio/ # Прослушивание голосовых (PLANNED)
│ │ ├── mod.rs # Экспорт публичных типов
│ │ ├── player.rs # AudioPlayer на rodio
│ │ ├── cache.rs # VoiceCache для OGG файлов
│ │ └── state.rs # PlaybackState
│ ├── media/ # Работа с изображениями (PLANNED)
│ │ ├── mod.rs # Экспорт публичных типов
│ │ ├── image_cache.rs # LRU кэш для загруженных изображений
│ │ ├── image_loader.rs # Асинхронная загрузка через TDLib
│ │ └── image_renderer.rs # Рендеринг изображений в ratatui
│ ├── notifications.rs # Desktop уведомления
│ ├── tdlib/ # TDLib интеграция
│ │ ├── mod.rs
│ │ └── client.rs
│ ├── ui/ # Рендеринг интерфейса
│ │ ├── mod.rs
│ │ ├── auth.rs
│ │ ├── chat_list.rs
│ │ ├── footer.rs
│ │ ├── loading.rs
│ │ ├── main_screen.rs
│ │ └── messages.rs
│ ├── config.rs # Конфигурация приложения
│ ├── main.rs # Точка входа
│ └── utils.rs # Утилиты
├── tdlib_data/ # TDLib сессия (НЕ коммитится) ├── CLAUDE.md # Инструкции для AI
├── target/ # Артефакты сборки (НЕ коммитится) ├── CONTEXT.md # Текущий статус
├── ROADMAP.md # План развития
├── .editorconfig # EditorConfig для IDE ├── DEVELOPMENT.md # Правила разработки
├── .gitignore # Git ignore правила ├── REQUIREMENTS.md # Требования
├── Cargo.lock # Зависимости (точные версии) ├── ARCHITECTURE.md # C4, sequence diagrams
├── Cargo.toml # Манифест проекта ├── PROJECT_STRUCTURE.md # Этот файл
├── rustfmt.toml # Конфигурация форматирования ├── E2E_TESTING.md # Гайд по тестированию
├── HOTKEYS.md # Горячие клавиши
├── config.toml.example # Пример конфигурации ├── CHANGELOG.md # История изменений
├── credentials.example # Пример credentials ├── README.md # Главная документация
├── INSTALL.md # Установка
├── CHANGELOG.md # История изменений ├── FAQ.md # FAQ
├── CLAUDE.md # Инструкции для Claude AI ├── CONTRIBUTING.md # Гайд по контрибуции
├── CONTRIBUTING.md # Гайд по контрибуции ├── SECURITY.md # Безопасность
── CONTEXT.md # Текущий статус разработки ── LICENSE # MIT лицензия
├── DEVELOPMENT.md # Правила разработки
├── FAQ.md # Часто задаваемые вопросы
├── HOTKEYS.md # Список горячих клавиш
├── INSTALL.md # Инструкция по установке
├── LICENSE # MIT лицензия
├── PROJECT_STRUCTURE.md # Этот файл
├── README.md # Главная документация
├── REQUIREMENTS.md # Функциональные требования
├── ROADMAP.md # План развития
└── SECURITY.md # Политика безопасности
``` ```
## Исходный код (src/) ## Ключевые модули
### main.rs
**Точка входа приложения**
- Инициализация TDLib клиента
- Event loop (60 FPS)
- Обработка Ctrl+C (graceful shutdown)
- Координация между UI, input и TDLib
### config.rs
**Конфигурация приложения**
- Загрузка/сохранение TOML конфига
- Парсинг timezone и цветов
- Загрузка credentials (приоритетная система)
- XDG directory support
### utils.rs
**Утилитарные функции**
- `disable_tdlib_logs()` — отключение TDLib логов через FFI
- `format_timestamp_with_tz()` — форматирование времени с учётом timezone
- `format_date()` — форматирование дат для разделителей
- `format_datetime()` — полное форматирование даты и времени
- `format_was_online()` — "был(а) X мин. назад"
### app/ — Состояние приложения ### app/ — Состояние приложения
#### mod.rs `App<T: TdClientTrait>` — главная структура, параметризована trait'ом для DI.
- `App` struct — главная структура состояния
- `needs_redraw` — флаг для оптимизации рендеринга
- Состояние модалок (delete confirm, reaction picker, profile)
- Состояние поиска и черновиков
- Методы для работы с UI state
#### state.rs **State machine** (`ChatState` enum):
- `AppScreen` enum — текущий экран (Loading, Auth, Main) ```
Normal → MessageSelection → Editing
→ Reply
→ Forward
→ DeleteConfirmation
→ ReactionPicker
→ Profile
→ SearchInChat
→ PinnedMessages
```
### audio/ — Прослушивание голосовых сообщений (PLANNED - Фаза 12) **Trait-based methods** (5 traits на `App<T>`):
| Trait | Методы | Описание |
#### player.rs |-------|--------|----------|
- `AudioPlayer` — управление воспроизведением голосовых сообщений | NavigationMethods | 7 | next/previous_chat, close_chat, select_current_chat |
- Использует rodio для кроссплатформенного аудио | MessageMethods | 8 | is_editing, is_replying, get_selected_message, etc. |
- API методы: play(), pause(), resume(), stop(), seek(), set_volume() | ComposeMethods | 10 | start_reply, cancel_editing, load_draft, etc. |
- Обработка OGG Opus файлов (формат голосовых в Telegram) | SearchMethods | 15 | start_search, enter_message_search_mode, etc. |
- Отдельный поток для воспроизведения (через rodio Sink) | ModalMethods | 27 | enter_profile_mode, exit_pinned_mode, etc. |
#### cache.rs
- `VoiceCache` — LRU кэш для загруженных голосовых файлов
- Хранение в ~/.cache/tele-tui/voice/
- Лимит по размеру (MB) с автоматической очисткой
- MAX_VOICE_CACHE_SIZE = 100 MB (настраивается в config)
- Проверка существования файла перед воспроизведением
#### state.rs
- `PlaybackState` — текущее состояние воспроизведения
- Поля: message_id, status, position, duration, volume
- `PlaybackStatus` enum — Stopped, Playing, Paused, Loading
- Ticker для обновления позиции (каждые 100ms)
#### mod.rs
- Экспорт публичных типов
- `VoiceNoteInfo` struct — метаданные голосового (file_id, duration, waveform)
- `AudioConfig` — конфигурация из config.toml
- Fallback на системный плеер (mpv, ffplay)
### media/ — Работа с изображениями (PLANNED - Фаза 11)
#### image_cache.rs
- `ImageCache` — LRU кэш для загруженных изображений
- Лимит по размеру (MB) с автоматической очисткой
- Хранение как в памяти (DynamicImage), так и на диске (PathBuf)
- MAX_IMAGE_CACHE_SIZE = 100 MB (настраивается в config)
#### image_loader.rs
- `ImageLoader` — асинхронная загрузка изображений через TDLib
- Метод `load_photo(file_id)` — получить изображение из кэша или загрузить
- Метод `download_and_cache(file)` — загрузка через TDLib downloadFile API
- Обработка состояний загрузки (pending/downloading/ready)
- Приоритизация видимых изображений
#### image_renderer.rs
- `ImageRenderer` — рендеринг изображений в ratatui
- Auto-detection протокола терминала (Sixel/Kitty/iTerm2/Halfblocks)
- Автоматическое масштабирование под размер области
- Сохранение aspect ratio
- Fast resize для превью
- Fallback на текстовую заглушку
#### mod.rs
- Экспорт публичных типов
- `PhotoInfo` struct — метаданные изображения (file_id, width, height)
- `TerminalProtocol` enum — поддерживаемые протоколы отображения
### notifications.rs — Desktop уведомления
- `NotificationManager` — управление desktop уведомлениями
- Интеграция с notify-rust для кроссплатформенных уведомлений
- Фильтрация по muted чатам и mentions
- Beautification медиа-типов с emoji
- Настраиваемый timeout и urgency (Linux)
### tdlib/ — Telegram интеграция
#### client.rs
- `TdClient` — обёртка над TDLib
- Авторизация (телефон, код, 2FA)
- Загрузка чатов и сообщений
- Отправка/редактирование/удаление сообщений
- Reply, Forward
- Реакции (`ReactionInfo`)
- LRU кеши (users, statuses)
- `NetworkState` enum
#### mod.rs
- Экспорт публичных типов
### ui/ — Рендеринг интерфейса
#### mod.rs
- `render()` — роутинг по экранам
- Проверка минимального размера терминала (80x20)
#### loading.rs
- Экран "Loading..."
#### auth.rs
- Экран авторизации (ввод телефона, кода, пароля)
#### main_screen.rs
- Главный экран
- Отображение папок сверху
#### chat_list.rs
- Список чатов
- Индикаторы: 📌, 🔇, @, (N)
- Онлайн-статус (●)
- Поиск по чатам
#### messages.rs
- Область сообщений
- Группировка по дате и отправителю
- Markdown форматирование
- Реакции под сообщениями
- Emoji picker modal
- Profile modal
- Delete confirmation modal
- Pinned message
- Динамический инпут
- Блочный курсор
#### footer.rs
- Футер с командами
- Индикатор состояния сети
### input/ — Обработка ввода ### input/ — Обработка ввода
#### mod.rs **Маршрутизация** (порядок приоритетов в `main_input.rs`):
- Роутинг ввода по экранам 1. Global commands (Ctrl+R/S/P/F)
2. Profile mode
3. Message search mode
4. Pinned messages mode
5. Reaction picker mode
6. Delete confirmation
7. Forward mode
8. Chat search mode
9. Enter/Esc commands
10. Open chat input / Chat list navigation
#### auth.rs ### tdlib/ — Telegram интеграция
- Обработка ввода на экране авторизации
#### main_input.rs **Dependency Injection**: `TdClientTrait` позволяет подменять TdClient на `FakeTdClient` в тестах.
- Обработка ввода на главном экране
- **Важно**: порядок обработчиков имеет значение!
1. Reaction picker (Enter/Esc)
2. Delete confirmation
3. Profile modal
4. Search в чате
5. Forward mode
6. Edit/Reply mode
7. Message selection
8. Chat list
- Поддержка русской раскладки
## Конфигурационные файлы **MessageManager** — управление сообщениями:
- `convert.rs` — конвертация TDLib JSON → MessageInfo
- `operations.rs` — 11 API операций (get_history, send, edit, delete, forward, search, etc.)
### Cargo.toml ### ui/ — Рендеринг
Манифест проекта:
- Metadata (name, version, authors, license)
- Dependencies
- Build dependencies (tdlib-rs)
### rustfmt.toml **Компоненты** (`ui/components/`):
Конфигурация `cargo fmt`: | Компонент | Описание |
- max_width = 100 |-----------|----------|
- imports_granularity = "Crate" | message_bubble | Рендеринг пузыря сообщения с реакциями |
- Стиль комментариев | message_list | Элемент списка сообщений (search/pinned) |
| chat_list_item | Элемент списка чатов |
| input_field | Поле ввода с курсором |
| emoji_picker | Сетка выбора реакций |
| modal | Центрированная модалка |
### .editorconfig ### config/ — Конфигурация
Универсальные настройки для IDE:
- Unix line endings (LF)
- UTF-8 encoding
- Отступы (4 spaces для Rust)
## Рантайм файлы - **mod.rs** — struct Config, GeneralConfig, ColorsConfig, NotificationsConfig
- **keybindings.rs** — Command enum (30+ команд), кастомные горячие клавиши
- **validation.rs** — валидация timezone, цветов
- **loader.rs** — загрузка из `~/.config/tele-tui/config.toml`, credentials
### tdlib_data/ ## Тестирование
Создаётся автоматически TDLib:
- Токены авторизации
- Кеш сообщений и файлов
- **НЕ коммитится** (в .gitignore)
- **НЕ делиться** (содержит чувствительные данные)
### ~/.config/tele-tui/ **500+ тестов** через `cargo test` (без TDLib).
XDG config directory:
- `config.toml` — пользовательская конфигурация
- `credentials` — API_ID и API_HASH
## Документация **Инфраструктура**:
- `TestAppBuilder` — fluent API для создания App с нужным состоянием
- `FakeTdClient` — мок TDLib, реализует TdClientTrait
- `TestMessageBuilder` — создание тестовых сообщений
### Пользовательская **Типы тестов**:
- **README.md** — главная страница, overview - Unit-тесты — в `#[cfg(test)]` секциях модулей
- **INSTALL.md** — установка и настройка - Integration-тесты — в `tests/` (навигация, отправка, UI рендеринг)
- **HOTKEYS.md** — все горячие клавиши - Doc-тесты — примеры в документации
- **FAQ.md** — часто задаваемые вопросы - E2E — smoke и user journey тесты
### Разработчика
- **CONTRIBUTING.md** — как внести вклад
- **DEVELOPMENT.md** — правила разработки
- **PROJECT_STRUCTURE.md** — этот файл
- **ROADMAP.md** — план развития
- **REFACTORING_ROADMAP.md** — план рефакторинга
- **TESTING_ROADMAP.md** — план покрытия тестами
- **CONTEXT.md** — текущий статус, архитектурные решения
### Спецификации
- **REQUIREMENTS.md** — функциональные требования
- **CHANGELOG.md** — история изменений
- **SECURITY.md** — политика безопасности
### Внутренняя
- **CLAUDE.md** — инструкции для AI ассистента
- **docs/TDLIB_INTEGRATION.md** — детали интеграции TDLib
## Ключевые концепции
### Архитектура
- **Event-driven**: TDLib updates → mpsc channel → main loop
- **Unidirectional data flow**: TDLib → App state → UI rendering
- **Modal stacking**: приоритет обработки ввода для модалок
### Оптимизации
- **needs_redraw**: рендеринг только при изменениях
- **LRU caches**: user_names, user_statuses (500 записей)
- **Limits**: 500 messages/chat, 200 chats
- **Lazy loading**: users загружаются батчами (5 за цикл)
### Состояние
```
App {
screen: AppScreen,
config: Config,
needs_redraw: bool,
// TDLib state
chats: Vec<Chat>,
folders: Vec<Folder>,
// UI state
selected_chat_id: Option<i64>,
input_text: String,
cursor_position: usize,
// Modals
is_delete_confirmation: bool,
is_reaction_picker_mode: bool,
profile_info: Option<ProfileInfo>,
view_image_mode: Option<ViewImageState>, // PLANNED - Фаза 11
// Search
search_query: String,
search_results: Vec<i64>,
// Drafts
drafts: HashMap<i64, String>,
// Audio (PLANNED - Фаза 12)
audio_player: Option<AudioPlayer>,
playback_state: Option<PlaybackState>,
voice_cache: VoiceCache,
// Media (PLANNED - Фаза 11)
image_loader: ImageLoader,
image_protocol: StatefulProtocol, // Terminal capabilities
}
```
## Потоки выполнения ## Потоки выполнения
### Main thread ```
- Event loop (16ms tick для 60 FPS) Main thread TDLib thread
- UI rendering │ │
- Input handling │ ◄── mpsc ─────── │ td_client.receive() в Tokio task
- App state updates │ │
├── poll events │
├── handle input │
├── update state │
├── render UI │
└── sleep 16ms ──► │
```
### TDLib thread ## Рантайм файлы
- `td_client.receive()` в отдельном Tokio task
- Updates отправляются через `mpsc::channel`
- Неблокирующий для main thread
### Blocking operations | Путь | Описание |
- Загрузка конфига (при запуске) |------|----------|
- Авторизация (блокирует до ввода кода) | `~/.config/tele-tui/config.toml` | Пользовательская конфигурация |
- Graceful shutdown (2 sec timeout) | `~/.config/tele-tui/credentials` | API_ID и API_HASH |
| `tdlib_data/` | TDLib сессия (НЕ коммитится) |
## Зависимости ## Зависимости
### UI | Категория | Крейт | Назначение |
- `ratatui` 0.29 — TUI framework |-----------|-------|------------|
- `crossterm` 0.28 — terminal control | UI | ratatui 0.29 | TUI framework |
- `ratatui-image` 1.0 — отображение изображений в TUI (PLANNED) | UI | crossterm 0.28 | Terminal control |
| Telegram | tdlib-rs 1.1 | TDLib bindings |
### Audio (PLANNED) | Async | tokio 1.x | Async runtime |
- `rodio` 0.17 — Pure Rust аудио библиотека (кроссплатформенная) | Config | serde + toml | Serialization |
| Time | chrono 0.4 | Date/time |
### Media (PLANNED) | System | dirs 5.0 | XDG directories |
- `image` — загрузка и обработка изображений | System | arboard 3.4 | Clipboard |
- `ratatui-image` — рендеринг в ratatui с поддержкой Sixel/Kitty/iTerm2 | Notify | notify-rust 4.11 | Desktop уведомления (feature) |
| URL | open 5.0 | Открытие URL (feature) |
### Notifications
- `notify-rust` 4.11 — desktop уведомления (feature flag)
### Telegram
- `tdlib-rs` 1.1 — TDLib bindings
- `tokio` 1.x — async runtime
### Data
- `serde` + `serde_json` 1.0 — serialization
- `toml` 0.8 — config parsing
- `chrono` 0.4 — date/time
### System
- `dirs` 5.0 — XDG directories
- `arboard` 3.4 — clipboard
- `open` 5.0 — открытие URL/файлов
- `dotenvy` 0.15 — .env файлы
## Workflow разработки
1. Изучить [ROADMAP.md](ROADMAP.md) — понять текущую фазу
2. Прочитать [DEVELOPMENT.md](DEVELOPMENT.md) — правила работы
3. Изучить [CONTEXT.md](CONTEXT.md) — архитектурные решения
4. Найти issue или создать новую фичу
5. Создать feature branch
6. Внести изменения
7. `cargo fmt` + `cargo clippy`
8. Протестировать вручную
9. Создать PR с описанием
## CI/CD
### GitHub Actions (.github/workflows/ci.yml)
- **Check**: `cargo check`
- **Format**: `cargo fmt --check`
- **Clippy**: `cargo clippy`
- **Build**: для Ubuntu, macOS, Windows
Запускается на:
- Push в `main` или `develop`
- Pull requests
## Безопасность
### Чувствительные файлы (в .gitignore)
- `.env`
- `credentials`
- `config.toml` (если в корне проекта)
- `tdlib_data/`
- `target/`
### Рекомендации
- Credentials в `~/.config/tele-tui/credentials`
- Права доступа: `chmod 600 ~/.config/tele-tui/credentials`
- Никогда не коммитить `tdlib_data/`

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

@@ -1,677 +1,168 @@
# Roadmap # Roadmap
## Фаза 1: Базовая инфраструктура [DONE] ## Завершённые фазы
- [x] Настройка проекта (Cargo.toml) | Фаза | Описание | Ключевые результаты |
- [x] TUI фреймворк (ratatui + crossterm) |------|----------|---------------------|
- [x] Базовый layout (папки, список чатов, область сообщений) | 1 | Базовая инфраструктура | ratatui + crossterm, vim-навигация, русская раскладка |
- [x] Vim-style навигация (hjkl, стрелки) | 2 | TDLib интеграция | tdlib-rs, авторизация, загрузка чатов и сообщений |
- [x] Русская раскладка (ролд) | 3 | Улучшение UX | Отправка, поиск, скролл, realtime обновления |
| 4 | Папки и фильтрация | Загрузка папок из Telegram, переключение 1-9 |
| 5 | Расширенный функционал | Онлайн-статус, галочки прочтения, медиа-заглушки, muted |
| 6 | Полировка | 60 FPS, оптимизация памяти, graceful shutdown, динамический инпут |
| 7 | Рефакторинг памяти | Единый источник данных, LRU-кэш (500 users), lazy loading |
| 8 | Дополнительные фичи | Markdown, edit/delete, reply/forward, блочный курсор |
| 9 | Расширенные возможности | Typing, pinned, поиск в чате, черновики, профиль, копирование, реакции, конфиг |
| 10 | Desktop уведомления (83%) | notify-rust, muted фильтр, mentions, медиа. TODO: кастомные звуки |
| 11 | Inline просмотр фото | Dual renderer (Halfblocks + iTerm2/Sixel), throttling 15 FPS, modal viewer, lazy loading, auto-download |
| 12 | Голосовые сообщения | ffplay player, pause/resume with seek, VoiceCache, AudioConfig, progress bar + waveform UI |
| 13 | Глубокий рефакторинг | 5 файлов (4582->модули), 5 traits, shared components, docs |
## Фаза 2: TDLib интеграция [DONE] ---
- [x] Подключение tdlib-rs ## Фаза 11: Inline просмотр фото в чате [DONE]
- [x] Авторизация (телефон + код + 2FA)
- [x] Сохранение сессии
- [x] Загрузка списка чатов
- [x] Загрузка истории сообщений
- [x] Отключение логов TDLib
## Фаза 3: Улучшение UX [DONE] **UX**: Always-show inline preview (50 chars, Halfblocks) -> `v`/`м` открывает fullscreen modal (iTerm2/Sixel) -> `←`/`→` навигация между фото.
- [x] Отправка сообщений ### Реализовано:
- [x] Фильтрация чатов (только Main, без архива) - [x] **Dual renderer архитектура**:
- [x] Поиск по чатам (Ctrl+S) - `inline_image_renderer`: Halfblocks (быстро, Unicode блоки) для навигации
- [x] Скролл истории сообщений - `modal_image_renderer`: iTerm2/Sixel (медленно, высокое качество) для просмотра
- [x] Загрузка имён пользователей (вместо User_ID) - [x] **Performance optimizations**:
- [x] Отметка сообщений как прочитанные - Frame throttling: inline 15 FPS, текст 60 FPS
- [x] Реальное время: новые сообщения - Lazy loading: только видимые изображения
- LRU cache: max 100 протоколов
- Skip partial rendering (no flickering)
- [x] **UX улучшения**:
- Always-show inline preview (фикс. ширина 50 chars)
- Fullscreen modal на `v`/`м` с aspect ratio
- Loading indicator в модалке
- Navigation hotkeys: `←`/`→` между фото, `Esc`/`q` закрыть
- [x] **Типы и API**:
- `MediaInfo`, `PhotoInfo`, `PhotoDownloadState`, `ImageModalState`
- `ImagesConfig` в config.toml
- Feature flag `images` для зависимостей
- [x] **Media модуль**:
- `cache.rs`: ImageCache (LRU)
- `image_renderer.rs`: new() + new_fast()
- [x] **UI модули**:
- `modals/image_viewer.rs`: fullscreen modal
- `messages.rs`: throttled second-pass rendering
- [x] **Авто-загрузка фото** (bugfix):
- Auto-download последних 30 фото при открытии чата (`open_chat_and_load_data`)
- Download on demand по `v` (вместо "Фото не загружено")
- Retry при ошибке загрузки
- Конфиг: `auto_download_images` + `show_images` в `[images]`
## Фаза 4: Папки и фильтрация [DONE] ---
- [x] Загрузка папок из Telegram ## Фаза 12: Прослушивание голосовых сообщений [DONE]
- [x] Переключение между папками (1-9)
- [x] Фильтрация чатов по папке
## Фаза 5: Расширенный функционал [DONE] ### Этап 1: Инфраструктура аудио [DONE]
- [x] Модуль `src/audio/`
- `player.rs` — AudioPlayer на ffplay (subprocess)
- `cache.rs` — VoiceCache (LRU, configurable size, `~/.cache/tele-tui/voice/`)
- [x] AudioPlayer API: play(), play_from(ss), pause() (SIGSTOP), resume(), resume_from(ss), stop()
- [x] Race condition fix: `starting` flag + pid ownership guard в потоках
- [x] Drop impl для AudioPlayer (убивает ffplay при выходе)
- [x] Отображение онлайн-статуса (зелёная точка ●) ### Этап 2: Интеграция с TDLib [DONE]
- [x] Статус доставки/прочтения (✓, ✓✓) - [x] Типы: `VoiceInfo`, `VoiceDownloadState`, `PlaybackState`, `PlaybackStatus`
- [x] Поддержка медиа-заглушек (фото, видео, голосовые, стикеры и др.) - [x] Конвертация `MessageVoiceNote` в `message_conversion.rs`
- [x] Mentions (@) — индикатор непрочитанных упоминаний - [x] `download_voice_note()` в TdClientTrait + client_impl + fake
- [x] Muted чаты (иконка 🔇) - [x] Методы `has_voice()`, `voice_info()`, `voice_info_mut()` на `MessageInfo`
## Фаза 6: Полировка [DONE] ### Этап 3: UI для воспроизведения [DONE]
- [x] Progress bar (━●─) с позицией и длительностью
- [x] Waveform визуализация (▁▂▃▄▅▆▇█) из base64-encoded TDLib данных
- [x] Иконки статуса: ▶ Playing, ⏸ Paused, ⏹ Stopped
- [x] Throttled redraw: обновление UI только при смене секунды (не 60 FPS)
- [x] Оптимизация использования памяти (базовая) ### Этап 4: Хоткеи [DONE]
- Очистка сообщений при закрытии чата - [x] Space — play/pause toggle (запуск + пауза/возобновление с откатом 1s)
- Лимит кэша пользователей (500) - [x] ←/→ — seek ±5 сек (через `resume_from()` — перезапуск ffplay с `-ss`)
- Периодическая очистка неактивных записей - [x] Seek работает и при воспроизведении, и на паузе (на паузе двигает позицию, при resume стартует с неё)
- [x] Оптимизация 60 FPS - [x] MoveLeft/MoveRight как alias для SeekBackward/SeekForward (HashMap non-deterministic order fix)
- Poll таймаут 16ms - [x] Автоматическая остановка при навигации на другое сообщение
- Флаг `needs_redraw` — рендеринг только при изменениях - [x] Остановка ffplay при выходе из приложения (Ctrl+C)
- Обработка Event::Resize для перерисовки при изменении размера
- [x] Минимальное разрешение (80x20)
- Предупреждение если терминал слишком мал
- [x] Обработка ошибок сети
- NetworkState enum (WaitingForNetwork, Connecting, etc.)
- Индикатор в футере с цветовой индикацией
- [x] Graceful shutdown
- AtomicBool флаг для остановки polling
- Корректное закрытие TDLib клиента
- Таймаут ожидания завершения задач
- [x] Динамический инпут
- Автоматическое расширение до 10 строк
- Wrap для длинного текста
- [x] Перенос длинных сообщений
- Автоматический wrap на несколько строк
- Правильное выравнивание для исходящих/входящих
## Фаза 7: Глубокий рефакторинг памяти [DONE] ### Этап 5: Конфигурация и кэш [DONE]
- [x] `AudioConfig` в config.toml (`cache_size_mb`, `auto_download_voice`)
- [x] Удалить дублирование current_messages между App и TdClient - [x] `DEFAULT_AUDIO_CACHE_SIZE_MB` константа (100 MB)
- [x] Использовать единый источник данных для сообщений - [x] Ticker для progress bar в event loop (delta-based position tracking)
- [x] Реализовать LRU-кэш для user_names/user_statuses вместо простого лимита - [x] VoiceCache интеграция: проверка кэша перед загрузкой, кэширование после download
- [x] Lazy loading для имён пользователей (батчевая загрузка последних 5 за цикл)
- [x] Лимиты памяти:
- MAX_MESSAGES_IN_CHAT = 500
- MAX_CHATS = 200
- MAX_CHAT_USER_IDS = 500
- MAX_USER_CACHE_SIZE = 500 (LRU)
## Фаза 8: Дополнительные фичи [DONE]
- [x] Markdown форматирование в сообщениях
- Bold, Italic, Underline, Strikethrough
- Code (inline, Pre, PreCode)
- Spoiler (скрытый текст)
- URLs, упоминания (@)
- [x] Редактирование сообщений
- ↑ при пустом инпуте → выбор сообщения
- Enter для начала редактирования
- Подсветка выбранного сообщения (▶)
- Esc для отмены
- [x] Удаление сообщений
- d / в / Delete в режиме выбора
- Модалка подтверждения (y/n)
- Удаление для всех если возможно
- [x] Индикатор редактирования (✎)
- Отображается рядом с временем для отредактированных сообщений
- [x] Блочный курсор в поле ввода
- Vim-style курсор █
- Перемещение ←/→, Home/End
- Редактирование в любой позиции
- [x] Reply на сообщения
- `r` / `к` в режиме выбора → режим ответа
- Превью сообщения в поле ввода
- Esc для отмены
- [x] Forward сообщений
- `f` / `а` в режиме выбора → режим пересылки
- Превью сообщения в поле ввода
- Выбор чата стрелками, Enter для пересылки
- Esc для отмены
- Отображение "↪ Переслано от" для пересланных сообщений
## Фаза 9: Расширенные возможности [DONE]
- [x] Typing indicator ("печатает...")
- Показывать когда собеседник печатает
- Отправлять свой статус печати при наборе текста
- [x] Закреплённые сообщения (Pinned)
- Отображать pinned message вверху открытого чата
- Клик/хоткей для перехода к закреплённому сообщению
- [x] Поиск по сообщениям в чате
- `Ctrl+F` — поиск текста внутри открытого чата
- Навигация по результатам (n/N или стрелки)
- Подсветка найденных совпадений
- [x] Черновики
- Сохранять набранный текст при переключении между чатами
- Индикатор черновика в списке чатов
- Восстановление текста при возврате в чат
- [x] Профиль пользователя/чата
- `Ctrl+i` — открыть информацию о чате/собеседнике
- Для личных чатов: имя, username, телефон, био
- Для групп: название, описание, количество участников
- [x] Копирование сообщений
- `y` / `н` в режиме выбора — скопировать текст в системный буфер обмена
- Использовать clipboard crate для кроссплатформенности
- [x] Реакции
- Отображение реакций под сообщениями
- `e` в режиме выбора — добавить реакцию (emoji picker)
- Список доступных реакций чата
- [x] Конфигурационный файл
- `~/.config/tele-tui/config.toml`
- Настройки: цветовая схема, часовой пояс, хоткеи
- Загрузка конфига при старте
## Фаза 10: Desktop уведомления [DONE - 83%]
### Стадия 1: Базовая реализация [DONE]
- [x] NotificationManager модуль
- notify-rust интеграция (версия 4.11)
- Feature flag "notifications" в Cargo.toml
- Базовая структура с настройками
- [x] Конфигурация уведомлений
- NotificationsConfig в config.toml
- enabled: bool - вкл/выкл уведомлений
- only_mentions: bool - только упоминания
- show_preview: bool - показывать превью текста
- [x] Интеграция с TdClient
- Поле notification_manager в TdClient
- Метод configure_notifications()
- Обработка в handle_new_message_update()
- [x] Базовая отправка уведомлений
- Уведомления для сообщений не из текущего чата
- Форматирование title (имя чата) и body (текст/медиа-заглушка)
- Sender name из MessageInfo
### Стадия 2: Улучшения [IN PROGRESS]
- [x] Синхронизация muted чатов
- Загрузка списка muted чатов из Telegram
- Вызов sync_muted_chats() при инициализации и обновлении (Ctrl+R)
- Muted чаты автоматически фильтруются из уведомлений
- [x] Фильтрация по упоминаниям
- Метод MessageInfo::has_mention() проверяет TextEntityType::Mention и MentionName
- NotificationManager применяет фильтр only_mentions из конфига
- Работает для @username и inline mentions
- [x] Поддержка типов медиа
- Метод beautify_media_labels() заменяет текстовые заглушки на emoji
- Поддержка: 📷 Фото, 🎥 Видео, 🎞️ GIF, 🎤 Голосовое, 🎨 Стикер
- Также: 📎 Файл, 🎵 Аудио, 📹 Видеосообщение, 📍 Локация, 👤 Контакт, 📊 Опрос
- [ ] Кастомизация звуков
- Настройка звуков уведомлений в config.toml
- Разные звуки для разных типов сообщений
### Стадия 3: Полировка [DONE]
- [x] Обработка ошибок
- Graceful fallback если уведомления недоступны (возвращает Ok без паники)
- Логирование ошибок через tracing::warn!
- Детальное логирование причин пропуска уведомлений (debug level)
- [x] Дополнительные настройки
- timeout_ms - продолжительность показа (0 = системное значение)
- urgency - уровень важности: "low", "normal", "critical" (только Linux)
- Красивые эмодзи для типов медиа
- [ ] Опциональные улучшения (не критично)
- Кросс-платформенное тестирование (требует ручного тестирования)
- icon - кастомная иконка приложения
- Actions в уведомлениях (кнопки "Ответить", "Прочитано")
## Фаза 11: Показ изображений в чате [PLANNED]
### Этап 1: Инфраструктура [TODO]
- [ ] Модуль src/media/
- image_cache.rs - LRU кэш для загруженных изображений
- image_loader.rs - Асинхронная загрузка через TDLib
- image_renderer.rs - Рендеринг в ratatui
- [ ] Зависимости
- ratatui-image 1.0 - поддержка изображений в TUI
- Определение протокола терминала (Sixel/Kitty/iTerm2/Halfblocks)
- [ ] ImageCache с лимитами
- LRU кэш с максимальным размером в МБ
- Автоматическая очистка старых изображений
- MAX_IMAGE_CACHE_SIZE = 100 MB (по умолчанию)
### Этап 2: Интеграция с TDLib [TODO]
- [ ] Обработка MessageContentPhoto
- Добавить PhotoInfo в MessageInfo
- Извлечение file_id, width, height из Photo
- Выбор оптимального размера изображения (до 800px)
- [ ] Загрузка файлов
- Метод TdClient::download_photo(file_id)
- Асинхронная загрузка через downloadFile API
- Обработка состояний загрузки (pending/downloading/ready)
- [ ] Кэширование
- Сохранение путей к загруженным файлам
- Повторное использование уже загруженных изображений
### Этап 3: Рендеринг в UI [TODO]
- [ ] Модификация render_messages()
- Определение возможностей терминала при старте
- Рендеринг изображений через ratatui-image
- Автоматическое масштабирование под размер области
- Сохранение aspect ratio
- [ ] Превью в списке сообщений
- Миниатюры размером 20x10 символов
- Lazy loading (загрузка только видимых)
- Placeholder пока изображение грузится
- [ ] Индикатор загрузки
- Текстовая заглушка "[Загрузка фото...]"
- Progress bar для больших файлов
- Процент загрузки
### Этап 4: Полноэкранный просмотр [TODO]
- [ ] Новый режим: ViewImage
- `v` / `м` в режиме выбора - открыть изображение
- Показ на весь экран терминала
- `Esc` для закрытия
- [ ] Информация об изображении
- Размер файла
- Разрешение (width x height)
- Формат (JPEG/PNG/GIF)
- [ ] Навигация
- `←` / `→` - предыдущее/следующее изображение в чате
- Автоматическая загрузка соседних изображений
### Этап 5: Конфигурация и UX [TODO]
- [ ] MediaConfig в config.toml
- show_images: bool - включить/отключить показ изображений
- image_cache_mb: usize - размер кэша в МБ
- preview_quality: "low" | "medium" | "high"
- render_protocol: "auto" | "sixel" | "kitty" | "iterm2" | "halfblocks"
- [ ] Поддержка различных терминалов
- Auto-detection протокола при старте
- Fallback на Unicode halfblocks для любого терминала
- Опция отключения изображений если терминал не поддерживает
- [ ] Оптимизация производительности
- Асинхронная загрузка (не блокирует UI)
- Приоритизация видимых изображений
- Fast resize для превью
- Кэширование отмасштабированных версий
### Этап 6: Обработка ошибок [TODO]
- [ ] Graceful fallback
- Текстовая заглушка "[Фото]" если загрузка не удалась
- Повторная попытка по запросу пользователя
- Логирование проблем через tracing
- [ ] Ограничения
- Таймаут загрузки (30 сек)
- Максимальный размер файла для автозагрузки (10 MB)
- Предупреждение для больших файлов
### Технические детали ### Технические детали
- **Поддерживаемые протоколы:** - **Аудио:** ffplay (subprocess), resume/seek через перезапуск с `-ss` offset
- Sixel (xterm, WezTerm, mintty) - **Race conditions:** `starting` flag предотвращает false `is_stopped()` при старте ffplay; pid ownership guard в потоках предотвращает затирание pid нового процесса старым
- Kitty Graphics Protocol (Kitty terminal) - **Keybinding conflict:** Left/Right привязаны к MoveLeft/MoveRight и SeekBackward/SeekForward; HashMap iteration order не гарантирован → оба варианта обрабатываются как seek в режиме выбора сообщения
- iTerm2 Inline Images (iTerm2 на macOS) - **Платформы:** macOS, Linux (везде где есть ffmpeg)
- Unicode Halfblocks (fallback для всех) - **Хоткеи:** Space (play/pause), ←/→ (seek ±5s)
- **Поддерживаемые форматы:**
- JPEG, PNG, GIF, WebP, BMP
- **Новые хоткеи:**
- `v` / `м` - открыть изображение в полном размере (режим выбора)
- `←` / `→` - навигация между изображениями (в режиме просмотра)
- `Esc` - закрыть полноэкранный просмотр
## Фаза 12: Прослушивание голосовых сообщений [PLANNED] ---
### Этап 1: Инфраструктура аудио [TODO] ## Фаза 14: Мультиаккаунт
- [ ] Модуль src/audio/
- player.rs - AudioPlayer на rodio
- cache.rs - VoiceCache для загруженных файлов
- state.rs - PlaybackState (статус, позиция, громкость)
- [ ] Зависимости
- rodio 0.17 - Pure Rust аудио библиотека
- Feature flag "audio" в Cargo.toml
- [ ] AudioPlayer API
- play() - воспроизведение файла
- pause() / resume() - пауза/возобновление
- stop() - остановка
- seek() - перемотка
- set_volume() - регулировка громкости
- get_position() - текущая позиция
- [ ] VoiceCache
- Кэш загруженных OGG файлов в ~/.cache/tele-tui/voice/
- LRU политика очистки
- MAX_VOICE_CACHE_SIZE = 100 MB
### Этап 2: Интеграция с TDLib [TODO] **Цель**: поддержка нескольких Telegram-аккаунтов с мгновенным переключением внутри приложения.
- [ ] Обработка MessageContentVoiceNote
- Добавить VoiceNoteInfo в MessageInfo
- Извлечение file_id, duration, mime_type, waveform
- Метка формата (OGG Opus обычно)
- [ ] Загрузка файлов
- Метод TdClient::download_voice_note(file_id)
- Асинхронная загрузка через downloadFile API
- Обработка состояний (pending/downloading/ready)
- [ ] Кэширование
- Сохранение путей к загруженным файлам
- Не перезагружать уже скачанные голосовые
- Проверка существования файла перед воспроизведением
### Этап 3: UI для воспроизведения [TODO] ### UI: Индикатор в footer + хоткеи
- [ ] Индикатор в сообщении
- Иконка 🎤 и длительность голосового
- Progress bar во время воспроизведения
- Статус: ▶ (playing), ⏸ (paused), ⏹ (stopped), ⏳ (loading)
- Текущее время / общая длительность (0:08 / 0:15)
- [ ] Модификация render_messages()
- render_voice_note() для голосовых сообщений
- render_progress_bar() для индикатора воспроизведения
- Hint "[Space] Воспроизвести" если не играет
- [ ] Footer с управлением
- Отображение доступных команд при воспроизведении
- "[Space] Play/Pause [s] Stop [←/→] Seek [↑/↓] Volume"
- [ ] Waveform визуализация (опционально)
- Конвертация waveform данных из Telegram в ASCII bars
- Использование символов ▁▂▃▄▅▆▇█ для визуализации
### Этап 4: Хоткеи для управления [TODO] ```
- [ ] Новые команды ┌──────────────┬───────────────────────────┐
- PlayVoice - Space в режиме выбора голосового │ Saved Msgs │ Привет! │
- PauseVoice - Space во время воспроизведения │ Иван Петров │ Как дела? │
- StopVoice - s / ы │ Работа чат │ │
- SeekBackward - ← (перемотка назад на 5 сек) ├──────────────┴───────────────────────────┤
- SeekForward - → (перемотка вперед на 5 сек) │ [NORMAL] Михаил ⟨1/2⟩ Work(3) │ Ctrl+A │
- VolumeUp - ↑ (увеличить на 10%) └──────────────────────────────────────────┘
- VolumeDown - ↓ (уменьшить на 10%)
- [ ] Контекстная обработка
- Space работает как play/pause в зависимости от состояния
- ← / → для seek только во время воспроизведения
- ↑ / ↓ для громкости только во время воспроизведения
- [ ] Поддержка русской раскладки
- s / ы - stop
- Остальные клавиши универсальны (Space, стрелки)
### Этап 5: Конфигурация и UX [TODO]
- [ ] AudioConfig в config.toml
- enabled: bool - включить/отключить аудио
- default_volume: f32 - громкость по умолчанию (0.0 - 1.0)
- seek_step_seconds: i32 - шаг перемотки в секундах
- autoplay: bool - автовоспроизведение при выборе
- cache_size_mb: usize - размер кэша голосовых
- show_waveform: bool - показывать waveform визуализацию
- system_player_fallback: bool - использовать системный плеер
- system_player: String - команда системного плеера (mpv, ffplay)
- [ ] Асинхронная загрузка
- Не блокировать UI во время загрузки файла
- Индикатор загрузки с процентами
- Возможность отмены загрузки
- [ ] Обновление UI
- Ticker для обновления progress bar (каждые 100ms)
- Плавное обновление позиции воспроизведения
- Автоматическая остановка при достижении конца
### Этап 6: Обработка ошибок [TODO]
- [ ] Graceful fallback на системный плеер
- Если rodio не работает - использовать mpv/ffplay
- Логирование ошибок через tracing
- Предупреждение пользователю если аудио недоступно
- [ ] Обработка ошибок загрузки
- Таймаут загрузки (30 сек)
- Повторная попытка по запросу
- Сообщение об ошибке в UI
- [ ] Ограничения
- Максимальный размер файла для кэша
- Автоматическая очистка старых файлов
- Предупреждение для очень длинных голосовых (>5 мин)
### Этап 7: Дополнительные улучшения [TODO]
- [ ] Управление воспроизведением
- Автоматическая остановка при закрытии чата
- Сохранение позиции при паузе
- Автопереход к следующему голосовому (опционально)
- [ ] Оптимизация
- Lazy loading (загрузка только при воспроизведении)
- Префетчинг следующего голосового (опционально)
- Минимальная задержка при нажатии Play
- [ ] Визуальные улучшения
- Анимация progress bar
- Цветовая индикация статуса (зеленый - playing, желтый - paused)
- Иконки в зависимости от статуса
### Технические детали
- **Аудио библиотека:**
- rodio 0.17 (Pure Rust, кроссплатформенная)
- Поддержка OGG Opus (формат голосовых в Telegram)
- Контроль воспроизведения через Sink API
- **Платформы:**
- Linux (ALSA, PulseAudio)
- macOS (CoreAudio)
- Windows (WASAPI)
- **Fallback:**
- mpv --no-video (универсальный плеер)
- ffplay -nodisp (из ffmpeg)
- **Новые хоткеи:**
- `Space` - воспроизвести/пауза (в режиме выбора голосового)
- `s` / `ы` - остановить воспроизведение
- `←` / `→` - перемотка -5с / +5с (во время воспроизведения)
- `↑` / `↓` - громкость +/- 10% (во время воспроизведения)
## Фаза 13: Глубокий рефакторинг архитектуры [PLANNED]
**Мотивация:** Код вырос до критических размеров - некоторые файлы содержат >1000 строк, что затрудняет поддержку и навигацию. Необходимо разбить монолитные файлы на логические модули.
**Проблемы:**
- `src/input/main_input.rs` - 1199 строк (самый большой файл!)
- `src/app/mod.rs` - 1015 строк, 116 функций (God Object)
- `src/ui/messages.rs` - 893 строки
- `src/tdlib/messages.rs` - 833 строки
- `src/config/mod.rs` - 642 строки
### Этап 1: Разбить input/main_input.rs (1199 → <200 строк) [TODO]
**Текущая проблема:**
- Весь input handling в одном файле
- Функции по 300-400 строк
- Невозможно быстро найти нужный handler
**План:**
- [ ] Создать `src/input/handlers/` директорию
- [ ] Создать `handlers/chat.rs` - обработка ввода в открытом чате
- Переместить `handle_open_chat_keyboard_input()`
- Обработка скролла, выбора сообщений
- ~300-400 строк
- [ ] Создать `handlers/chat_list.rs` - обработка в списке чатов
- Переместить `handle_chat_list_keyboard_input()`
- Навигация по чатам, папки
- ~200-300 строк
- [ ] Создать `handlers/compose.rs` - режимы edit/reply/forward
- Обработка ввода в режимах редактирования
- Input field управление (курсор, backspace, delete)
- ~200 строк
- [ ] Создать `handlers/modal.rs` - модалки
- Delete confirmation
- Emoji picker
- Profile modal
- ~150 строк
- [ ] Создать `handlers/search.rs` - поиск
- Search mode в чате
- Search mode в списке чатов
- ~100 строк
- [ ] Обновить `main_input.rs` - только роутинг
- Определение текущего режима
- Делегация в нужный handler
- <200 строк
**Результат:** 1199 строк → 6 файлов по <400 строк
### Этап 2: Уменьшить app/mod.rs (116 функций → traits) [TODO]
**Текущая проблема:**
- God Object с 116 функциями
- Сложно найти нужный метод
- Нарушение Single Responsibility Principle
**План:**
- [ ] Создать `app/methods/` директорию
- [ ] Создать trait `NavigationMethods`
- `next_chat()`, `previous_chat()`
- `scroll_up()`, `scroll_down()`
- `select_chat()`, `open_chat()`
- ~15 методов
- [ ] Создать trait `MessageMethods`
- `send_message()`, `edit_message()`, `delete_message()`
- `reply_to_message()`, `forward_message()`
- `select_message()`, `deselect_message()`
- ~20 методов
- [ ] Создать trait `ComposeMethods`
- `enter_edit_mode()`, `enter_reply_mode()`, `enter_forward_mode()`
- `handle_input_char()`, `move_cursor_left()`, `move_cursor_right()`
- ~15 методов
- [ ] Создать trait `SearchMethods`
- `start_search()`, `search_next()`, `search_previous()`
- `clear_search()`
- ~5 методов
- [ ] Создать trait `ModalMethods`
- `show_delete_confirmation()`, `show_emoji_picker()`
- `show_profile()`, `close_modal()`
- ~10 методов
- [ ] Оставить в `app/mod.rs` только:
- Struct definition
- Constructor (new, with_client)
- Getters/setters для полей
- ~30-40 методов
**Структура:**
```rust
// app/mod.rs - только core
impl<T: TdClientTrait> App<T> {
pub fn new() -> Self { ... }
pub fn config(&self) -> &Config { ... }
}
// app/methods/navigation.rs
pub trait NavigationMethods {
fn next_chat(&mut self);
fn previous_chat(&mut self);
}
impl<T: TdClientTrait> NavigationMethods for App<T> { ... }
// app/methods/messages.rs
pub trait MessageMethods {
async fn send_message(&mut self, text: String);
}
impl<T: TdClientTrait> MessageMethods for App<T> { ... }
``` ```
**Результат:** 116 функций → 6 trait impl блоков - **Footer**: текущий аккаунт + номер `⟨1/2⟩` + бейджи непрочитанных с других аккаунтов
- **Быстрое переключение**: `Ctrl+1`..`Ctrl+9` — мгновенный switch без модалки
- **Модалка управления** (`Ctrl+A`): список аккаунтов, добавление/удаление, выбор активного
### Этап 3: Разбить ui/messages.rs (893 → <300 строк) [TODO] ### Модалка переключения аккаунтов
**Текущая проблема:**
- Весь UI рендеринг сообщений в одном файле
- Модалки смешаны с основным рендерингом
- Compose bar (input field) в том же файле
**План:**
- [ ] Создать `ui/modals/` директорию
- [ ] Создать `modals/delete_confirm.rs`
- Рендеринг модалки подтверждения удаления
- Обработка y/n input
- ~50 строк
- [ ] Создать `modals/emoji_picker.rs`
- Рендеринг сетки эмодзи
- Навигация по сетке
- ~100 строк
- [ ] Создать `modals/search_modal.rs`
- Поиск в чате
- Подсветка результатов
- Навигация по совпадениям
- ~80 строк
- [ ] Создать `modals/profile_modal.rs`
- Профиль пользователя/чата
- Отображение информации
- ~100 строк
- [ ] Создать `ui/compose_bar.rs`
- Поле ввода сообщения
- Превью для edit/reply/forward
- Курсор, автоматический wrap
- ~150 строк
- [ ] Оставить в `messages.rs`:
- Основной layout сообщений
- Рендеринг списка message bubbles
- Группировка по дате
- Pinned message
- ~300 строк
**Результат:** 893 строки → 6 файлов по <150 строк
### Этап 4: Разбить tdlib/messages.rs (833 → 2 файла) [TODO]
**Текущая проблема:**
- Смешивается конвертация из TDLib и операции
- Большой файл сложно читать
**План:**
- [ ] Создать `tdlib/messages/` директорию
- [ ] Создать `messages/convert.rs`
- Конвертация MessageContent из TDLib
- Парсинг всех типов (Text, Photo, Video, Voice, etc.)
- Обработка форматирования (entities)
- ~500 строк
- [ ] Создать `messages/operations.rs`
- send_message(), edit_message(), delete_message()
- forward_message(), reply_to_message()
- get_chat_history(), load_older_messages()
- ~300 строк
- [ ] Обновить `tdlib/messages.rs``tdlib/messages/mod.rs`
- Re-export публичных типов
- ~30 строк
**Результат:** 833 строки → 2 файла по <500 строк
### Этап 5: Разбить config/mod.rs (642 → 3 файла) [TODO]
**Текущая проблема:**
- Много default_* функций (по 1-3 строки каждая)
- Validation logic смешана с определениями
- Сложно найти нужную секцию конфига
**План:**
- [ ] Создать `config/defaults.rs`
- Все default_* функции
- ~100 строк
- [ ] Создать `config/validation.rs`
- Валидация timezone
- Валидация цветов
- Валидация notification settings
- ~150 строк
- [ ] Создать `config/loader.rs`
- Загрузка из файла
- Поиск путей (XDG, home, etc.)
- Обработка ошибок чтения
- ~100 строк
- [ ] Оставить в `config/mod.rs`:
- Struct definitions
- Default impls (вызывают defaults.rs)
- Re-exports
- ~200-300 строк
**Результат:** 642 строки → 4 файла по <200 строк
### Этап 6: Code Duplication Cleanup [TODO]
**План:**
- [ ] Найти дублированный код в handlers
- Общая логика обработки клавиш
- Вынести в `input/common.rs`
- [ ] Найти дублированный код в UI
- Общие компоненты рендеринга
- Вынести в `ui/components/`
- [ ] Использовать DRY принцип везде
### Этап 7: Documentation Update [TODO]
**План:**
- [ ] Обновить CONTEXT.md с новой структурой
- [ ] Обновить PROJECT_STRUCTURE.md
- [ ] Добавить module-level документацию
- [ ] Создать architecture diagram (ASCII)
### Метрики успеха
**До рефакторинга:**
``` ```
input/main_input.rs: 1199 строк ┌──────────────────────────────────┐
app/mod.rs: 1015 строк (116 функций) │ Аккаунты │
ui/messages.rs: 893 строки │ │
tdlib/messages.rs: 833 строки │ 1. Михаил (+7 900 ...) ● │ ← активный
config/mod.rs: 642 строки │ 2. Work (+7 911 ...) (3) │ ← 3 непрочитанных
ИТОГО: 4582 строки в 5 файлах │ 3. + Добавить аккаунт │
│ │
│ [j/k навигация, Enter выбор] │
│ [d — удалить аккаунт] │
└──────────────────────────────────┘
``` ```
**После рефакторинга:** ### Техническая реализация: все клиенты одновременно
```
input/handlers/*.rs: ~6 файлов по <400 строк
app/methods/*.rs: ~6 traits с impl блоками
ui/modals/*.rs: ~4 файла по <150 строк
tdlib/messages/*.rs: 2 файла по <500 строк
config/*.rs: 4 файла по <200 строк
ИТОГО: те же строки, но в ~20+ файлах
```
**Преимущества:** - **Несколько TdClient**: каждый аккаунт — отдельный `TdClient` со своим `database_directory`
- ✅ Легче найти нужный код - Аккаунт 1: `~/.local/share/tele-tui/accounts/1/tdlib_data/`
- ✅ Легче тестировать модули - Аккаунт 2: `~/.local/share/tele-tui/accounts/2/tdlib_data/`
- ✅ Меньше конфликтов при работе в команде - **Все клиенты активны**: polling updates со всех аккаунтов одновременно (уведомления, непрочитанные)
- ✅ Лучше читаемость и поддерживаемость - **Мгновенное переключение**: swap активного `App.td_client` — чаты и сообщения уже загружены
- ✅ Соблюдение Single Responsibility Principle - **Общий конфиг**: `~/.config/tele-tui/config.toml` (один для всех аккаунтов)
- **Профили аккаунтов**: `~/.config/tele-tui/accounts.toml` — список аккаунтов (имя, путь к БД)
### Этапы
- [x] **Этап 1: Инфраструктура профилей** (DONE)
- Структура `AccountProfile` (name, display_name, db_path)
- `accounts.toml` — хранение списка аккаунтов
- Миграция `tdlib_data/``accounts/default/tdlib_data/` (обратная совместимость)
- CLI: `--account <name>` для запуска конкретного аккаунта
- [x] **Этап 2+3: Account Switcher Modal + Переключение** (DONE)
- Подход: single-client reinit (close TDLib → new TdClient → auth)
- Модалка `Ctrl+A` — глобальный оверлей с навигацией j/k
- Footer индикатор `[account_name]` если не "default"
- `AccountSwitcherState` enum (SelectAccount / AddAccount)
- `recreate_client()` метод в TdClientTrait (close → new → set_tdlib_parameters)
- `add_account()` — создание нового аккаунта из модалки
- `pending_account_switch` флаг → обработка в main loop
- [ ] **Этап 4: Расширенные возможности мультиаккаунта**
- Хоткеи `Ctrl+1`..`Ctrl+9` — быстрое переключение
- Бейджи непрочитанных с других аккаунтов (требует множественных TdClient)

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

202
src/accounts/manager.rs Normal file
View File

@@ -0,0 +1,202 @@
//! Account manager: loading, saving, migration, and resolution.
//!
//! Handles `accounts.toml` lifecycle and legacy `./tdlib_data/` migration
//! to XDG data directory.
use std::fs;
use std::path::PathBuf;
use super::profile::{account_db_path, validate_account_name, AccountsConfig};
/// Returns the path to `accounts.toml` in the config directory.
///
/// `~/.config/tele-tui/accounts.toml`
pub fn accounts_config_path() -> Option<PathBuf> {
dirs::config_dir().map(|mut path| {
path.push("tele-tui");
path.push("accounts.toml");
path
})
}
/// Loads `accounts.toml` or creates it with default values.
///
/// On first run, also attempts to migrate legacy `./tdlib_data/` directory
/// to the XDG data location.
pub fn load_or_create() -> AccountsConfig {
let config_path = match accounts_config_path() {
Some(path) => path,
None => {
tracing::warn!("Could not determine config directory for accounts, using defaults");
return AccountsConfig::default_single();
}
};
if config_path.exists() {
// Load existing config
match fs::read_to_string(&config_path) {
Ok(content) => match toml::from_str::<AccountsConfig>(&content) {
Ok(config) => return config,
Err(e) => {
tracing::warn!("Could not parse accounts.toml: {}", e);
return AccountsConfig::default_single();
}
},
Err(e) => {
tracing::warn!("Could not read accounts.toml: {}", e);
return AccountsConfig::default_single();
}
}
}
// First run: migrate legacy data if present, then create default config
migrate_legacy();
let config = AccountsConfig::default_single();
if let Err(e) = save(&config) {
tracing::warn!("Could not save initial accounts.toml: {}", e);
}
config
}
/// Saves `AccountsConfig` to `accounts.toml`.
pub fn save(config: &AccountsConfig) -> Result<(), String> {
let config_path =
accounts_config_path().ok_or_else(|| "Could not determine config directory".to_string())?;
// Ensure parent directory exists
if let Some(parent) = config_path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("Could not create config directory: {}", e))?;
}
let toml_string = toml::to_string_pretty(config)
.map_err(|e| format!("Could not serialize accounts config: {}", e))?;
fs::write(&config_path, toml_string)
.map_err(|e| format!("Could not write accounts.toml: {}", e))?;
Ok(())
}
/// Migrates legacy `./tdlib_data/` from CWD to XDG data dir.
///
/// If `./tdlib_data/` exists in the current working directory, moves it to
/// `~/.local/share/tele-tui/accounts/default/tdlib_data/`.
fn migrate_legacy() {
let legacy_path = PathBuf::from("tdlib_data");
if !legacy_path.exists() || !legacy_path.is_dir() {
return;
}
let target = account_db_path("default");
// Don't overwrite if target already exists
if target.exists() {
tracing::info!(
"Legacy ./tdlib_data/ found but target already exists at {}, skipping migration",
target.display()
);
return;
}
// Create parent directories
if let Some(parent) = target.parent() {
if let Err(e) = fs::create_dir_all(parent) {
tracing::error!("Could not create target directory for migration: {}", e);
return;
}
}
// Move (rename) the directory
match fs::rename(&legacy_path, &target) {
Ok(()) => {
tracing::info!("Migrated ./tdlib_data/ -> {}", target.display());
}
Err(e) => {
tracing::error!("Could not migrate ./tdlib_data/ to {}: {}", target.display(), e);
}
}
}
/// Resolves which account to use from CLI arg or default.
///
/// # Arguments
///
/// * `config` - The loaded accounts configuration
/// * `account_arg` - Optional account name from `--account` CLI flag
///
/// # Returns
///
/// The resolved account name and its db_path.
///
/// # Errors
///
/// Returns an error if the specified account is not found or the name is invalid.
pub fn resolve_account(
config: &AccountsConfig,
account_arg: Option<&str>,
) -> Result<(String, PathBuf), String> {
let account_name = account_arg.unwrap_or(&config.default_account);
// Validate name
validate_account_name(account_name)?;
// Find account in config
let _account = config.find_account(account_name).ok_or_else(|| {
let available: Vec<&str> = config.accounts.iter().map(|a| a.name.as_str()).collect();
format!(
"Account '{}' not found. Available accounts: {}",
account_name,
available.join(", ")
)
})?;
let db_path = account_db_path(account_name);
Ok((account_name.to_string(), db_path))
}
/// Adds a new account to `accounts.toml` and creates its data directory.
///
/// Validates the name, checks for duplicates, adds the profile to config,
/// saves the config, and creates the data directory.
///
/// # Returns
///
/// The db_path for the new account.
///
/// # Errors
///
/// Returns an error if the name is invalid, already exists, or I/O fails.
pub fn add_account(name: &str, display_name: &str) -> Result<std::path::PathBuf, String> {
validate_account_name(name)?;
let mut config = load_or_create();
// Check for duplicate
if config.find_account(name).is_some() {
return Err(format!("Account '{}' already exists", name));
}
// Add new profile
config.accounts.push(super::profile::AccountProfile {
name: name.to_string(),
display_name: display_name.to_string(),
});
// Save config
save(&config)?;
// Create data directory
ensure_account_dir(name)
}
/// Ensures the account data directory exists.
///
/// Creates `~/.local/share/tele-tui/accounts/{name}/tdlib_data/` if needed.
pub fn ensure_account_dir(account_name: &str) -> Result<PathBuf, String> {
let db_path = account_db_path(account_name);
fs::create_dir_all(&db_path)
.map_err(|e| format!("Could not create account directory: {}", e))?;
Ok(db_path)
}

15
src/accounts/mod.rs Normal file
View File

@@ -0,0 +1,15 @@
//! Account profiles module for multi-account support.
//!
//! Manages account profiles stored in `~/.config/tele-tui/accounts.toml`.
//! Each account has its own TDLib database directory under
//! `~/.local/share/tele-tui/accounts/{name}/tdlib_data/`.
pub mod lock;
pub mod manager;
pub mod profile;
pub use lock::{acquire_lock, release_lock};
#[allow(unused_imports)]
pub use manager::{add_account, ensure_account_dir, load_or_create, resolve_account, save};
#[allow(unused_imports)]
pub use profile::{account_db_path, validate_account_name, AccountProfile, AccountsConfig};

147
src/accounts/profile.rs Normal file
View File

@@ -0,0 +1,147 @@
//! Account profile data structures and validation.
//!
//! Defines `AccountProfile` and `AccountsConfig` for multi-account support.
//! Account names are validated to contain only alphanumeric characters, hyphens, and underscores.
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
/// Configuration for all accounts, stored in `~/.config/tele-tui/accounts.toml`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AccountsConfig {
/// Name of the default account to use when no `--account` flag is provided.
pub default_account: String,
/// List of configured accounts.
pub accounts: Vec<AccountProfile>,
}
/// A single account profile.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AccountProfile {
/// Unique identifier (used in directory names and CLI flag).
pub name: String,
/// Human-readable display name.
pub display_name: String,
}
impl AccountsConfig {
/// Creates a default config with a single "default" account.
pub fn default_single() -> Self {
Self {
default_account: "default".to_string(),
accounts: vec![AccountProfile {
name: "default".to_string(),
display_name: "Default".to_string(),
}],
}
}
/// Finds an account by name.
pub fn find_account(&self, name: &str) -> Option<&AccountProfile> {
self.accounts.iter().find(|a| a.name == name)
}
}
impl AccountProfile {
/// Computes the TDLib database directory path for this account.
///
/// Returns `~/.local/share/tele-tui/accounts/{name}/tdlib_data`
/// (or platform equivalent via `dirs::data_dir()`).
pub fn db_path(&self) -> PathBuf {
account_db_path(&self.name)
}
}
/// Computes the TDLib database directory path for a given account name.
///
/// Returns `{data_dir}/tele-tui/accounts/{name}/tdlib_data`.
pub fn account_db_path(account_name: &str) -> PathBuf {
let mut path = dirs::data_dir().unwrap_or_else(|| PathBuf::from("."));
path.push("tele-tui");
path.push("accounts");
path.push(account_name);
path.push("tdlib_data");
path
}
/// Validates an account name.
///
/// Valid names contain only lowercase alphanumeric characters, hyphens, and underscores.
/// Must be 1-32 characters long.
///
/// # Errors
///
/// Returns a descriptive error message if the name is invalid.
pub fn validate_account_name(name: &str) -> Result<(), String> {
if name.is_empty() {
return Err("Account name cannot be empty".to_string());
}
if name.len() > 32 {
return Err("Account name cannot be longer than 32 characters".to_string());
}
if !name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
{
return Err(
"Account name can only contain lowercase letters, digits, hyphens, and underscores"
.to_string(),
);
}
if name.starts_with('-') || name.starts_with('_') {
return Err("Account name cannot start with a hyphen or underscore".to_string());
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validate_account_name_valid() {
assert!(validate_account_name("default").is_ok());
assert!(validate_account_name("work").is_ok());
assert!(validate_account_name("my-account").is_ok());
assert!(validate_account_name("account_2").is_ok());
assert!(validate_account_name("a").is_ok());
}
#[test]
fn test_validate_account_name_invalid() {
assert!(validate_account_name("").is_err());
assert!(validate_account_name("My Account").is_err());
assert!(validate_account_name("UPPER").is_err());
assert!(validate_account_name("with spaces").is_err());
assert!(validate_account_name("-starts-with-dash").is_err());
assert!(validate_account_name("_starts-with-underscore").is_err());
assert!(validate_account_name(&"a".repeat(33)).is_err());
}
#[test]
fn test_default_single_config() {
let config = AccountsConfig::default_single();
assert_eq!(config.default_account, "default");
assert_eq!(config.accounts.len(), 1);
assert_eq!(config.accounts[0].name, "default");
}
#[test]
fn test_find_account() {
let config = AccountsConfig::default_single();
assert!(config.find_account("default").is_some());
assert!(config.find_account("nonexistent").is_none());
}
#[test]
fn test_db_path_contains_account_name() {
let path = account_db_path("work");
let path_str = path.to_string_lossy();
assert!(path_str.contains("tele-tui"));
assert!(path_str.contains("accounts"));
assert!(path_str.contains("work"));
assert!(path_str.ends_with("tdlib_data"));
}
}

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

@@ -3,10 +3,21 @@
use crate::tdlib::{MessageInfo, ProfileInfo}; use crate::tdlib::{MessageInfo, ProfileInfo};
use crate::types::MessageId; use crate::types::MessageId;
/// Vim-like input mode for chat view
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum InputMode {
/// Normal mode — navigation and commands (default)
#[default]
Normal,
/// Insert mode — text input only
Insert,
}
/// Состояния чата - взаимоисключающие режимы работы с чатом /// Состояния чата - взаимоисключающие режимы работы с чатом
#[derive(Debug, Clone)] #[derive(Debug, Clone, Default)]
pub enum ChatState { pub enum ChatState {
/// Обычный режим - просмотр сообщений, набор текста /// Обычный режим - просмотр сообщений, набор текста
#[default]
Normal, Normal,
/// Выбор сообщения для действия (edit/delete/reply/forward/reaction) /// Выбор сообщения для действия (edit/delete/reply/forward/reaction)
@@ -80,12 +91,6 @@ pub enum ChatState {
}, },
} }
impl Default for ChatState {
fn default() -> Self {
ChatState::Normal
}
}
impl ChatState { impl ChatState {
/// Проверка: находимся в режиме выбора сообщения /// Проверка: находимся в режиме выбора сообщения
pub fn is_message_selection(&self) -> bool { pub fn is_message_selection(&self) -> bool {

117
src/app/methods/compose.rs Normal file
View File

@@ -0,0 +1,117 @@
//! Compose methods for App
//!
//! Handles reply, forward, and draft functionality
use crate::app::methods::messages::MessageMethods;
use crate::app::{App, ChatState};
use crate::tdlib::{MessageInfo, TdClientTrait};
/// Compose methods for reply/forward/draft
pub trait ComposeMethods<T: TdClientTrait> {
/// Start replying to the selected message
/// Returns true if reply mode started, false if no message selected
fn start_reply_to_selected(&mut self) -> bool;
/// Cancel reply mode
fn cancel_reply(&mut self);
/// Check if currently in reply mode
fn is_replying(&self) -> bool;
/// Get the message being replied to
fn get_replying_to_message(&self) -> Option<MessageInfo>;
/// Start forwarding the selected message
/// Returns true if forward mode started, false if no message selected
fn start_forward_selected(&mut self) -> bool;
/// Cancel forward mode
fn cancel_forward(&mut self);
/// Check if currently in forward mode (selecting target chat)
fn is_forwarding(&self) -> bool;
/// Get the message being forwarded
fn get_forwarding_message(&self) -> Option<MessageInfo>;
/// Get draft for the currently selected chat
fn get_current_draft(&self) -> Option<String>;
/// Load draft into message_input (called when opening chat)
fn load_draft(&mut self);
}
impl<T: TdClientTrait> ComposeMethods<T> for App<T> {
fn start_reply_to_selected(&mut self) -> bool {
if let Some(msg) = self.get_selected_message() {
self.chat_state = ChatState::Reply { message_id: msg.id() };
return true;
}
false
}
fn cancel_reply(&mut self) {
self.chat_state = ChatState::Normal;
}
fn is_replying(&self) -> bool {
self.chat_state.is_reply()
}
fn get_replying_to_message(&self) -> Option<MessageInfo> {
self.chat_state.selected_message_id().and_then(|id| {
self.td_client
.current_chat_messages()
.iter()
.find(|m| m.id() == id)
.cloned()
})
}
fn start_forward_selected(&mut self) -> bool {
if let Some(msg) = self.get_selected_message() {
self.chat_state = ChatState::Forward { message_id: msg.id() };
// Сбрасываем выбор чата на первый
self.chat_list_state.select(Some(0));
return true;
}
false
}
fn cancel_forward(&mut self) {
self.chat_state = ChatState::Normal;
}
fn is_forwarding(&self) -> bool {
self.chat_state.is_forward()
}
fn get_forwarding_message(&self) -> Option<MessageInfo> {
if !self.chat_state.is_forward() {
return None;
}
self.chat_state.selected_message_id().and_then(|id| {
self.td_client
.current_chat_messages()
.iter()
.find(|m| m.id() == id)
.cloned()
})
}
fn get_current_draft(&self) -> Option<String> {
self.selected_chat_id.and_then(|chat_id| {
self.chats
.iter()
.find(|c| c.id == chat_id)
.and_then(|c| c.draft_text.clone())
})
}
fn load_draft(&mut self) {
if let Some(draft) = self.get_current_draft() {
self.message_input = draft;
self.cursor_position = self.message_input.chars().count();
}
}
}

175
src/app/methods/messages.rs Normal file
View File

@@ -0,0 +1,175 @@
//! Message methods for App
//!
//! Handles message selection, editing, and operations
use crate::app::{App, ChatState};
use crate::tdlib::{MessageInfo, TdClientTrait};
/// Message operation methods
pub trait MessageMethods<T: TdClientTrait> {
/// Start message selection mode (triggered by Up arrow in empty input)
fn start_message_selection(&mut self);
/// Select previous message (up in history = older)
fn select_previous_message(&mut self);
/// Select next message (down in history = newer)
fn select_next_message(&mut self);
/// Get currently selected message
fn get_selected_message(&self) -> Option<MessageInfo>;
/// Start editing the selected message
/// Returns true if editing started, false if message cannot be edited
fn start_editing_selected(&mut self) -> bool;
/// Cancel message editing and clear input
fn cancel_editing(&mut self);
/// Check if currently in editing mode
fn is_editing(&self) -> bool;
/// Check if currently in message selection mode
fn is_selecting_message(&self) -> bool;
}
impl<T: TdClientTrait> MessageMethods<T> for App<T> {
fn start_message_selection(&mut self) {
let messages = self.td_client.current_chat_messages();
let total = messages.len();
if total == 0 {
return;
}
// Начинаем с последнего сообщения (индекс len-1 = самое новое внизу)
// Если оно часть альбома — перемещаемся к первому элементу альбома
let mut idx = total - 1;
let album_id = messages[idx].media_album_id();
if album_id != 0 {
while idx > 0 && messages[idx - 1].media_album_id() == album_id {
idx -= 1;
}
}
self.chat_state = ChatState::MessageSelection { selected_index: idx };
}
fn select_previous_message(&mut self) {
if let ChatState::MessageSelection { selected_index } = &mut self.chat_state {
if *selected_index > 0 {
let messages = self.td_client.current_chat_messages();
let current_album_id = messages[*selected_index].media_album_id();
// Перескакиваем через все сообщения текущего альбома назад
let mut new_index = *selected_index - 1;
if current_album_id != 0 {
while new_index > 0 && messages[new_index].media_album_id() == current_album_id
{
new_index -= 1;
}
}
// Если попали в середину другого альбома — перемещаемся к его первому элементу
let target_album_id = messages[new_index].media_album_id();
if target_album_id != 0 {
while new_index > 0
&& messages[new_index - 1].media_album_id() == target_album_id
{
new_index -= 1;
}
}
*selected_index = new_index;
self.stop_playback();
}
}
}
fn select_next_message(&mut self) {
let total = self.td_client.current_chat_messages().len();
if total == 0 {
return;
}
if let ChatState::MessageSelection { selected_index } = &mut self.chat_state {
if *selected_index < total - 1 {
let messages = self.td_client.current_chat_messages();
let current_album_id = messages[*selected_index].media_album_id();
// Перескакиваем через все сообщения текущего альбома вперёд
let mut new_index = *selected_index + 1;
if current_album_id != 0 {
while new_index < total - 1
&& messages[new_index].media_album_id() == current_album_id
{
new_index += 1;
}
// Если мы ещё на последнем элементе альбома — нужно шагнуть на следующее
if messages[new_index].media_album_id() == current_album_id
&& new_index < total - 1
{
new_index += 1;
}
}
if new_index < total {
*selected_index = new_index;
self.stop_playback();
}
// Если new_index >= total — остаёмся на текущем
}
// Если уже на последнем — ничего не делаем, остаёмся на месте
}
}
fn get_selected_message(&self) -> Option<MessageInfo> {
self.chat_state
.selected_message_index()
.and_then(|idx| self.td_client.current_chat_messages().get(idx).cloned())
}
fn start_editing_selected(&mut self) -> bool {
// Получаем selected_index из текущего состояния
let selected_idx = match &self.chat_state {
ChatState::MessageSelection { selected_index } => Some(*selected_index),
_ => None,
};
if selected_idx.is_none() {
return false;
}
// Сначала извлекаем данные из сообщения
let msg_data = self.get_selected_message().and_then(|msg| {
// Проверяем:
// 1. Можно редактировать
// 2. Это исходящее сообщение
// 3. ID не временный (временные ID в TDLib отрицательные)
if msg.can_be_edited() && msg.is_outgoing() && msg.id().as_i64() > 0 {
Some((msg.id(), msg.text().to_string(), selected_idx.unwrap()))
} else {
None
}
});
// Затем присваиваем
if let Some((id, content, idx)) = msg_data {
self.cursor_position = content.chars().count();
self.message_input = content;
self.chat_state = ChatState::Editing { message_id: id, selected_index: idx };
return true;
}
false
}
fn cancel_editing(&mut self) {
self.chat_state = ChatState::Normal;
self.message_input.clear();
self.cursor_position = 0;
}
fn is_editing(&self) -> bool {
self.chat_state.is_editing()
}
fn is_selecting_message(&self) -> bool {
self.chat_state.is_message_selection()
}
}

25
src/app/methods/mod.rs Normal file
View File

@@ -0,0 +1,25 @@
//! App methods organized by functionality
//!
//! This module contains traits that organize App methods into logical groups:
//! - navigation: Chat list navigation
//! - messages: Message operations and selection
//! - compose: Reply/Forward/Draft functionality
//! - search: Search in chats and messages
//! - modal: Modal dialogs (Profile, Pinned, Reactions, Delete)
pub mod compose;
pub mod messages;
pub mod modal;
pub mod navigation;
pub mod search;
#[allow(unused_imports)]
pub use compose::ComposeMethods;
#[allow(unused_imports)]
pub use messages::MessageMethods;
#[allow(unused_imports)]
pub use modal::ModalMethods;
#[allow(unused_imports)]
pub use navigation::NavigationMethods;
#[allow(unused_imports)]
pub use search::SearchMethods;

266
src/app/methods/modal.rs Normal file
View File

@@ -0,0 +1,266 @@
//! Modal methods for App
//!
//! Handles modal dialogs: Profile, Pinned Messages, Reactions, Delete Confirmation
use crate::app::{App, ChatState};
use crate::tdlib::{MessageInfo, ProfileInfo, TdClientTrait};
use crate::types::MessageId;
/// Modal dialog methods
pub trait ModalMethods<T: TdClientTrait> {
// === Delete Confirmation ===
/// Check if delete confirmation modal is shown
fn is_confirm_delete_shown(&self) -> bool;
// === Pinned Messages ===
/// Check if in pinned messages mode
fn is_pinned_mode(&self) -> bool;
/// Enter pinned messages mode
fn enter_pinned_mode(&mut self, messages: Vec<MessageInfo>);
/// Exit pinned messages mode
fn exit_pinned_mode(&mut self);
/// Select previous pinned message (up = older)
fn select_previous_pinned(&mut self);
/// Select next pinned message (down = newer)
fn select_next_pinned(&mut self);
/// Get currently selected pinned message
fn get_selected_pinned(&self) -> Option<&MessageInfo>;
/// Get ID of selected pinned message for navigation
fn get_selected_pinned_id(&self) -> Option<i64>;
// === Profile ===
/// Check if in profile mode
fn is_profile_mode(&self) -> bool;
/// Enter profile mode
fn enter_profile_mode(&mut self, info: ProfileInfo);
/// Exit profile mode
fn exit_profile_mode(&mut self);
/// Select previous profile action
fn select_previous_profile_action(&mut self);
/// Select next profile action
fn select_next_profile_action(&mut self, max_actions: usize);
/// Show first leave group confirmation
fn show_leave_group_confirmation(&mut self);
/// Show second leave group confirmation
fn show_leave_group_final_confirmation(&mut self);
/// Cancel leave group confirmation
fn cancel_leave_group(&mut self);
/// Get current leave group confirmation step (0, 1, or 2)
fn get_leave_group_confirmation_step(&self) -> u8;
/// Get profile info
fn get_profile_info(&self) -> Option<&ProfileInfo>;
/// Get selected profile action index
fn get_selected_profile_action(&self) -> Option<usize>;
// === Reactions ===
/// Check if in reaction picker mode
fn is_reaction_picker_mode(&self) -> bool;
/// Enter reaction picker mode
fn enter_reaction_picker_mode(&mut self, message_id: i64, available_reactions: Vec<String>);
/// Exit reaction picker mode
fn exit_reaction_picker_mode(&mut self);
/// Select previous reaction
fn select_previous_reaction(&mut self);
/// Select next reaction
fn select_next_reaction(&mut self);
/// Get currently selected reaction emoji
fn get_selected_reaction(&self) -> Option<&String>;
/// Get message ID for which reaction is being selected
fn get_selected_message_for_reaction(&self) -> Option<i64>;
}
impl<T: TdClientTrait> ModalMethods<T> for App<T> {
fn is_confirm_delete_shown(&self) -> bool {
self.chat_state.is_delete_confirmation()
}
fn is_pinned_mode(&self) -> bool {
self.chat_state.is_pinned_mode()
}
fn enter_pinned_mode(&mut self, messages: Vec<MessageInfo>) {
if !messages.is_empty() {
self.chat_state = ChatState::PinnedMessages { messages, selected_index: 0 };
}
}
fn exit_pinned_mode(&mut self) {
self.chat_state = ChatState::Normal;
}
fn select_previous_pinned(&mut self) {
if let ChatState::PinnedMessages { selected_index, messages } = &mut self.chat_state {
if *selected_index + 1 < messages.len() {
*selected_index += 1;
}
}
}
fn select_next_pinned(&mut self) {
if let ChatState::PinnedMessages { selected_index, .. } = &mut self.chat_state {
if *selected_index > 0 {
*selected_index -= 1;
}
}
}
fn get_selected_pinned(&self) -> Option<&MessageInfo> {
if let ChatState::PinnedMessages { messages, selected_index } = &self.chat_state {
messages.get(*selected_index)
} else {
None
}
}
fn get_selected_pinned_id(&self) -> Option<i64> {
self.get_selected_pinned().map(|m| m.id().as_i64())
}
fn is_profile_mode(&self) -> bool {
self.chat_state.is_profile()
}
fn enter_profile_mode(&mut self, info: ProfileInfo) {
self.chat_state = ChatState::Profile {
info,
selected_action: 0,
leave_group_confirmation_step: 0,
};
}
fn exit_profile_mode(&mut self) {
self.chat_state = ChatState::Normal;
}
fn select_previous_profile_action(&mut self) {
if let ChatState::Profile { selected_action, .. } = &mut self.chat_state {
if *selected_action > 0 {
*selected_action -= 1;
}
}
}
fn select_next_profile_action(&mut self, max_actions: usize) {
if let ChatState::Profile { selected_action, .. } = &mut self.chat_state {
if *selected_action < max_actions.saturating_sub(1) {
*selected_action += 1;
}
}
}
fn show_leave_group_confirmation(&mut self) {
if let ChatState::Profile { leave_group_confirmation_step, .. } = &mut self.chat_state {
*leave_group_confirmation_step = 1;
}
}
fn show_leave_group_final_confirmation(&mut self) {
if let ChatState::Profile { leave_group_confirmation_step, .. } = &mut self.chat_state {
*leave_group_confirmation_step = 2;
}
}
fn cancel_leave_group(&mut self) {
if let ChatState::Profile { leave_group_confirmation_step, .. } = &mut self.chat_state {
*leave_group_confirmation_step = 0;
}
}
fn get_leave_group_confirmation_step(&self) -> u8 {
if let ChatState::Profile { leave_group_confirmation_step, .. } = &self.chat_state {
*leave_group_confirmation_step
} else {
0
}
}
fn get_profile_info(&self) -> Option<&ProfileInfo> {
if let ChatState::Profile { info, .. } = &self.chat_state {
Some(info)
} else {
None
}
}
fn get_selected_profile_action(&self) -> Option<usize> {
if let ChatState::Profile { selected_action, .. } = &self.chat_state {
Some(*selected_action)
} else {
None
}
}
fn is_reaction_picker_mode(&self) -> bool {
self.chat_state.is_reaction_picker()
}
fn enter_reaction_picker_mode(&mut self, message_id: i64, available_reactions: Vec<String>) {
self.chat_state = ChatState::ReactionPicker {
message_id: MessageId::new(message_id),
available_reactions,
selected_index: 0,
};
}
fn exit_reaction_picker_mode(&mut self) {
self.chat_state = ChatState::Normal;
}
fn select_previous_reaction(&mut self) {
if let ChatState::ReactionPicker { selected_index, .. } = &mut self.chat_state {
if *selected_index > 0 {
*selected_index -= 1;
}
}
}
fn select_next_reaction(&mut self) {
if let ChatState::ReactionPicker { selected_index, available_reactions, .. } =
&mut self.chat_state
{
if *selected_index + 1 < available_reactions.len() {
*selected_index += 1;
}
}
}
fn get_selected_reaction(&self) -> Option<&String> {
if let ChatState::ReactionPicker { available_reactions, selected_index, .. } =
&self.chat_state
{
available_reactions.get(*selected_index)
} else {
None
}
}
fn get_selected_message_for_reaction(&self) -> Option<i64> {
self.chat_state.selected_message_id().map(|id| id.as_i64())
}
}

View File

@@ -0,0 +1,147 @@
//! Navigation methods for App
//!
//! Handles chat list navigation and selection
use crate::app::methods::search::SearchMethods;
use crate::app::{App, ChatState, InputMode};
use crate::tdlib::TdClientTrait;
/// Navigation methods for chat list
pub trait NavigationMethods<T: TdClientTrait> {
/// Move to next chat in the list (wraps around)
fn next_chat(&mut self);
/// Move to previous chat in the list (wraps around)
fn previous_chat(&mut self);
/// Select currently highlighted chat
fn select_current_chat(&mut self);
/// Close currently open chat and reset state
fn close_chat(&mut self);
/// Move to next filtered chat (considering search query)
fn next_filtered_chat(&mut self);
/// Move to previous filtered chat (considering search query)
fn previous_filtered_chat(&mut self);
/// Select currently highlighted filtered chat
fn select_filtered_chat(&mut self);
}
impl<T: TdClientTrait> NavigationMethods<T> for App<T> {
fn next_chat(&mut self) {
let filtered = self.get_filtered_chats();
if filtered.is_empty() {
return;
}
let i = match self.chat_list_state.selected() {
Some(i) => {
if i >= filtered.len() - 1 {
0
} else {
i + 1
}
}
None => 0,
};
self.chat_list_state.select(Some(i));
}
fn previous_chat(&mut self) {
let filtered = self.get_filtered_chats();
if filtered.is_empty() {
return;
}
let i = match self.chat_list_state.selected() {
Some(i) => {
if i == 0 {
filtered.len() - 1
} else {
i - 1
}
}
None => 0,
};
self.chat_list_state.select(Some(i));
}
fn select_current_chat(&mut self) {
let filtered = self.get_filtered_chats();
if let Some(i) = self.chat_list_state.selected() {
if let Some(chat) = filtered.get(i) {
self.selected_chat_id = Some(chat.id);
}
}
}
fn close_chat(&mut self) {
self.selected_chat_id = None;
self.message_input.clear();
self.cursor_position = 0;
self.message_scroll_offset = 0;
self.last_typing_sent = None;
self.pending_chat_init = None;
// Останавливаем фоновую загрузку фото (drop receiver)
#[cfg(feature = "images")]
{
self.photo_download_rx = None;
self.pending_image_open = None;
}
// Сбрасываем состояние чата в нормальный режим
self.chat_state = ChatState::Normal;
self.input_mode = InputMode::Normal;
// Очищаем данные в TdClient
self.td_client.set_current_chat_id(None);
self.td_client.clear_current_chat_messages();
self.td_client.set_typing_status(None);
self.td_client.set_current_pinned_message(None);
}
fn next_filtered_chat(&mut self) {
let filtered = self.get_filtered_chats();
if filtered.is_empty() {
return;
}
let i = match self.chat_list_state.selected() {
Some(i) => {
if i >= filtered.len() - 1 {
0
} else {
i + 1
}
}
None => 0,
};
self.chat_list_state.select(Some(i));
}
fn previous_filtered_chat(&mut self) {
let filtered = self.get_filtered_chats();
if filtered.is_empty() {
return;
}
let i = match self.chat_list_state.selected() {
Some(i) => {
if i == 0 {
filtered.len() - 1
} else {
i - 1
}
}
None => 0,
};
self.chat_list_state.select(Some(i));
}
fn select_filtered_chat(&mut self) {
let filtered = self.get_filtered_chats();
if let Some(i) = self.chat_list_state.selected() {
if let Some(chat) = filtered.get(i) {
self.selected_chat_id = Some(chat.id);
self.cancel_search();
}
}
}
}

165
src/app/methods/search.rs Normal file
View File

@@ -0,0 +1,165 @@
//! Search methods for App
//!
//! Handles chat list search and message search within chat
use crate::app::{App, ChatFilter, ChatFilterCriteria, ChatState};
use crate::tdlib::{ChatInfo, MessageInfo, TdClientTrait};
/// Search methods for chats and messages
pub trait SearchMethods<T: TdClientTrait> {
// === Chat Search ===
/// Start search mode in chat list
fn start_search(&mut self);
/// Cancel search mode and reset query
fn cancel_search(&mut self);
/// Get filtered chats based on search query and selected folder
fn get_filtered_chats(&self) -> Vec<&ChatInfo>;
// === Message Search ===
/// Check if message search mode is active
fn is_message_search_mode(&self) -> bool;
/// Enter message search mode within chat
fn enter_message_search_mode(&mut self);
/// Exit message search mode
fn exit_message_search_mode(&mut self);
/// Set search results
fn set_search_results(&mut self, results: Vec<MessageInfo>);
/// Select previous search result (up)
fn select_previous_search_result(&mut self);
/// Select next search result (down)
fn select_next_search_result(&mut self);
/// Get currently selected search result
fn get_selected_search_result(&self) -> Option<&MessageInfo>;
/// Get ID of selected search result for navigation
fn get_selected_search_result_id(&self) -> Option<i64>;
/// Get current search query
fn get_search_query(&self) -> Option<&str>;
/// Update search query
fn update_search_query(&mut self, new_query: String);
/// Get index of selected search result
#[allow(dead_code)]
fn get_search_selected_index(&self) -> Option<usize>;
/// Get all search results
#[allow(dead_code)]
fn get_search_results(&self) -> Option<&[MessageInfo]>;
}
impl<T: TdClientTrait> SearchMethods<T> for App<T> {
fn start_search(&mut self) {
self.is_searching = true;
self.search_query.clear();
}
fn cancel_search(&mut self) {
self.is_searching = false;
self.search_query.clear();
self.chat_list_state.select(Some(0));
}
fn get_filtered_chats(&self) -> Vec<&ChatInfo> {
// Используем ChatFilter для централизованной фильтрации
let mut criteria = ChatFilterCriteria::new().with_folder(self.selected_folder_id);
if !self.search_query.is_empty() {
criteria = criteria.with_search(self.search_query.clone());
}
ChatFilter::filter(&self.chats, &criteria)
}
fn is_message_search_mode(&self) -> bool {
self.chat_state.is_search_in_chat()
}
fn enter_message_search_mode(&mut self) {
self.chat_state = ChatState::SearchInChat {
query: String::new(),
results: Vec::new(),
selected_index: 0,
};
}
fn exit_message_search_mode(&mut self) {
self.chat_state = ChatState::Normal;
}
fn set_search_results(&mut self, results: Vec<MessageInfo>) {
if let ChatState::SearchInChat { results: r, selected_index, .. } = &mut self.chat_state {
*r = results;
*selected_index = 0;
}
}
fn select_previous_search_result(&mut self) {
if let ChatState::SearchInChat { selected_index, .. } = &mut self.chat_state {
if *selected_index > 0 {
*selected_index -= 1;
}
}
}
fn select_next_search_result(&mut self) {
if let ChatState::SearchInChat { selected_index, results, .. } = &mut self.chat_state {
if *selected_index + 1 < results.len() {
*selected_index += 1;
}
}
}
fn get_selected_search_result(&self) -> Option<&MessageInfo> {
if let ChatState::SearchInChat { results, selected_index, .. } = &self.chat_state {
results.get(*selected_index)
} else {
None
}
}
fn get_selected_search_result_id(&self) -> Option<i64> {
self.get_selected_search_result().map(|m| m.id().as_i64())
}
fn get_search_query(&self) -> Option<&str> {
if let ChatState::SearchInChat { query, .. } = &self.chat_state {
Some(query.as_str())
} else {
None
}
}
fn update_search_query(&mut self, new_query: String) {
if let ChatState::SearchInChat { query, .. } = &mut self.chat_state {
*query = new_query;
}
}
fn get_search_selected_index(&self) -> Option<usize> {
if let ChatState::SearchInChat { selected_index, .. } = &self.chat_state {
Some(*selected_index)
} else {
None
}
}
fn get_search_results(&self) -> Option<&[MessageInfo]> {
if let ChatState::SearchInChat { results, .. } = &self.chat_state {
Some(results.as_slice())
} else {
None
}
}
}

File diff suppressed because it is too large Load Diff

155
src/audio/cache.rs Normal file
View File

@@ -0,0 +1,155 @@
//! Voice message cache management.
//!
//! Caches downloaded OGG voice files in ~/.cache/tele-tui/voice/
//! with LRU eviction when cache size exceeds limit.
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
/// Cache for voice message files
pub struct VoiceCache {
cache_dir: PathBuf,
/// file_id -> (path, size_bytes, access_count)
files: HashMap<String, (PathBuf, u64, usize)>,
access_counter: usize,
max_size_bytes: u64,
}
impl VoiceCache {
/// Creates a new VoiceCache with the given max size in MB
pub fn new(max_size_mb: u64) -> Result<Self, String> {
let cache_dir = dirs::cache_dir()
.ok_or("Failed to get cache directory")?
.join("tele-tui")
.join("voice");
fs::create_dir_all(&cache_dir)
.map_err(|e| format!("Failed to create cache directory: {}", e))?;
Ok(Self {
cache_dir,
files: HashMap::new(),
access_counter: 0,
max_size_bytes: max_size_mb * 1024 * 1024,
})
}
/// Gets the path for a cached voice file, if it exists
pub fn get(&mut self, file_id: &str) -> Option<PathBuf> {
if let Some((path, _, access)) = self.files.get_mut(file_id) {
// Update access count for LRU
self.access_counter += 1;
*access = self.access_counter;
Some(path.clone())
} else {
None
}
}
/// Stores a voice file in the cache
pub fn store(&mut self, file_id: &str, source_path: &Path) -> Result<PathBuf, String> {
// Copy file to cache
let filename = format!("{}.ogg", file_id.replace('/', "_"));
let dest_path = self.cache_dir.join(&filename);
fs::copy(source_path, &dest_path)
.map_err(|e| format!("Failed to copy voice file to cache: {}", e))?;
// Get file size
let size = fs::metadata(&dest_path)
.map_err(|e| format!("Failed to get file size: {}", e))?
.len();
// Store in cache
self.access_counter += 1;
self.files
.insert(file_id.to_string(), (dest_path.clone(), size, self.access_counter));
// Check if we need to evict
self.evict_if_needed()?;
Ok(dest_path)
}
/// Returns the total size of all cached files
pub fn total_size(&self) -> u64 {
self.files.values().map(|(_, size, _)| size).sum()
}
/// Evicts oldest files until cache is under max size
fn evict_if_needed(&mut self) -> Result<(), String> {
while self.total_size() > self.max_size_bytes && !self.files.is_empty() {
// Find least recently accessed file
let oldest_id = self
.files
.iter()
.min_by_key(|(_, (_, _, access))| access)
.map(|(id, _)| id.clone());
if let Some(id) = oldest_id {
self.evict(&id)?;
}
}
Ok(())
}
/// Evicts a specific file from cache
fn evict(&mut self, file_id: &str) -> Result<(), String> {
if let Some((path, _, _)) = self.files.remove(file_id) {
fs::remove_file(&path).map_err(|e| format!("Failed to remove cached file: {}", e))?;
}
Ok(())
}
/// Clears all cached files
#[allow(dead_code)]
pub fn clear(&mut self) -> Result<(), String> {
for (path, _, _) in self.files.values() {
let _ = fs::remove_file(path); // Ignore errors
}
self.files.clear();
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn test_voice_cache_creation() {
let cache = VoiceCache::new(100);
assert!(cache.is_ok());
}
#[test]
fn test_cache_get_nonexistent() {
let mut cache = VoiceCache::new(100).unwrap();
assert!(cache.get("nonexistent").is_none());
}
#[test]
fn test_cache_store_and_get() {
let mut cache = VoiceCache::new(100).unwrap();
// Create temporary file
let temp_dir = std::env::temp_dir();
let temp_file = temp_dir.join("test_voice.ogg");
let mut file = fs::File::create(&temp_file).unwrap();
file.write_all(b"test audio data").unwrap();
// Store in cache
let result = cache.store("test123", &temp_file);
assert!(result.is_ok());
// Get from cache
let cached_path = cache.get("test123");
assert!(cached_path.is_some());
assert!(cached_path.unwrap().exists());
// Cleanup
fs::remove_file(&temp_file).unwrap();
}
}

11
src/audio/mod.rs Normal file
View File

@@ -0,0 +1,11 @@
//! Audio playback module for voice messages.
//!
//! Provides:
//! - AudioPlayer: rodio-based playback with play/pause/stop/volume controls
//! - VoiceCache: LRU cache for downloaded OGG voice files
pub mod cache;
pub mod player;
pub use cache::VoiceCache;
pub use player::AudioPlayer;

198
src/audio/player.rs Normal file
View File

@@ -0,0 +1,198 @@
//! Audio player for voice messages.
//!
//! Uses ffplay (from FFmpeg) for reliable Opus/OGG playback.
//! Pause/resume implemented via SIGSTOP/SIGCONT signals.
use std::path::Path;
use std::process::Command;
use std::sync::{Arc, Mutex};
use std::time::Duration;
/// Audio player state and controls
pub struct AudioPlayer {
/// PID of current playback process (if any)
current_pid: Arc<Mutex<Option<u32>>>,
/// Whether the process is currently paused (SIGSTOP)
paused: Arc<Mutex<bool>>,
/// Path to the currently playing file (for restart with seek)
current_path: Arc<Mutex<Option<std::path::PathBuf>>>,
/// True between play_from() call and ffplay actually starting (race window)
starting: Arc<Mutex<bool>>,
}
impl AudioPlayer {
/// Creates a new AudioPlayer
pub fn new() -> Result<Self, String> {
Command::new("which")
.arg("ffplay")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.output()
.map_err(|_| "ffplay not found (install ffmpeg)".to_string())?;
Ok(Self {
current_pid: Arc::new(Mutex::new(None)),
paused: Arc::new(Mutex::new(false)),
current_path: Arc::new(Mutex::new(None)),
starting: Arc::new(Mutex::new(false)),
})
}
/// Plays an audio file from the given path
pub fn play<P: AsRef<Path>>(&self, path: P) -> Result<(), String> {
self.play_from(path, 0.0)
}
/// Plays an audio file starting from the given position (seconds)
pub fn play_from<P: AsRef<Path>>(&self, path: P, start_secs: f32) -> Result<(), String> {
self.stop();
let path_owned = path.as_ref().to_path_buf();
*self.current_path.lock().unwrap() = Some(path_owned.clone());
*self.starting.lock().unwrap() = true;
let current_pid = self.current_pid.clone();
let paused = self.paused.clone();
let starting = self.starting.clone();
std::thread::spawn(move || {
let mut cmd = Command::new("ffplay");
cmd.arg("-nodisp")
.arg("-autoexit")
.arg("-loglevel")
.arg("quiet");
if start_secs > 0.0 {
cmd.arg("-ss").arg(format!("{:.1}", start_secs));
}
if let Ok(mut child) = cmd
.arg(&path_owned)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
{
let pid = child.id();
*current_pid.lock().unwrap() = Some(pid);
*paused.lock().unwrap() = false;
*starting.lock().unwrap() = false;
let _ = child.wait();
// Обнуляем только если это наш pid (новый play мог уже заменить его)
let mut pid_guard = current_pid.lock().unwrap();
if *pid_guard == Some(pid) {
*pid_guard = None;
*paused.lock().unwrap() = false;
}
} else {
*starting.lock().unwrap() = false;
}
});
Ok(())
}
/// Pauses playback via SIGSTOP
pub fn pause(&self) {
if let Some(pid) = *self.current_pid.lock().unwrap() {
let _ = Command::new("kill")
.arg("-STOP")
.arg(pid.to_string())
.output();
*self.paused.lock().unwrap() = true;
}
}
/// Resumes playback via SIGCONT (from the same position)
pub fn resume(&self) {
if let Some(pid) = *self.current_pid.lock().unwrap() {
let _ = Command::new("kill")
.arg("-CONT")
.arg(pid.to_string())
.output();
*self.paused.lock().unwrap() = false;
}
}
/// Resumes playback from a specific position (restarts ffplay with -ss)
pub fn resume_from(&self, position_secs: f32) -> Result<(), String> {
let path = self.current_path.lock().unwrap().clone();
if let Some(path) = path {
self.play_from(&path, position_secs)
} else {
Err("No file to resume".to_string())
}
}
/// Stops playback (kills the process)
pub fn stop(&self) {
*self.starting.lock().unwrap() = false;
if let Some(pid) = self.current_pid.lock().unwrap().take() {
// Resume first if paused, then kill
let _ = Command::new("kill")
.arg("-CONT")
.arg(pid.to_string())
.output();
let _ = Command::new("kill").arg(pid.to_string()).output();
}
*self.paused.lock().unwrap() = false;
}
/// Returns true if a process is active (playing or paused)
#[allow(dead_code)]
pub fn is_playing(&self) -> bool {
self.current_pid.lock().unwrap().is_some() && !*self.paused.lock().unwrap()
}
/// Returns true if paused
#[allow(dead_code)]
pub fn is_paused(&self) -> bool {
self.current_pid.lock().unwrap().is_some() && *self.paused.lock().unwrap()
}
/// Returns true if no active process and not starting a new one
pub fn is_stopped(&self) -> bool {
self.current_pid.lock().unwrap().is_none() && !*self.starting.lock().unwrap()
}
#[allow(dead_code)]
pub fn set_volume(&self, _volume: f32) {}
#[allow(dead_code)]
pub fn adjust_volume(&self, _delta: f32) {}
pub fn volume(&self) -> f32 {
1.0
}
#[allow(dead_code)]
pub fn seek(&self, _delta: Duration) -> Result<(), String> {
Err("Seeking not supported".to_string())
}
}
impl Drop for AudioPlayer {
fn drop(&mut self) {
self.stop();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_audio_player_creation() {
if let Ok(player) = AudioPlayer::new() {
assert!(player.is_stopped());
assert!(!player.is_playing());
assert!(!player.is_paused());
}
}
#[test]
fn test_volume() {
if let Ok(player) = AudioPlayer::new() {
assert_eq!(player.volume(), 1.0);
}
}
}

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

197
src/config/loader.rs Normal file
View File

@@ -0,0 +1,197 @@
//! Config file loading, saving, and credentials management.
//!
//! Searches for config at `~/.config/tele-tui/config.toml`.
//! Credentials loaded from file or environment variables.
use std::fs;
use std::path::PathBuf;
use super::Config;
impl Config {
/// Возвращает путь к конфигурационному файлу.
///
/// # Returns
///
/// `Some(PathBuf)` - `~/.config/tele-tui/config.toml`
/// `None` - Не удалось определить директорию конфигурации
pub fn config_path() -> Option<PathBuf> {
dirs::config_dir().map(|mut path| {
path.push("tele-tui");
path.push("config.toml");
path
})
}
/// Путь к директории конфигурации
pub fn config_dir() -> Option<PathBuf> {
dirs::config_dir().map(|mut path| {
path.push("tele-tui");
path
})
}
/// Загружает конфигурацию из файла.
///
/// Ищет конфиг в `~/.config/tele-tui/config.toml`.
/// Если файл не существует, создаёт дефолтный.
/// Если файл невалиден, возвращает дефолтные значения.
///
/// # Returns
///
/// Всегда возвращает валидную конфигурацию.
pub fn load() -> Self {
let config_path = match Self::config_path() {
Some(path) => path,
None => {
tracing::warn!("Could not determine config directory, using defaults");
return Self::default();
}
};
if !config_path.exists() {
// Создаём дефолтный конфиг при первом запуске
let default_config = Self::default();
if let Err(e) = default_config.save() {
tracing::warn!("Could not create default config: {}", e);
}
return default_config;
}
match fs::read_to_string(&config_path) {
Ok(content) => match toml::from_str::<Config>(&content) {
Ok(config) => {
// Валидируем загруженный конфиг
if let Err(e) = config.validate() {
tracing::error!("Config validation error: {}", e);
tracing::warn!("Using default configuration instead");
Self::default()
} else {
config
}
}
Err(e) => {
tracing::warn!("Could not parse config file: {}", e);
Self::default()
}
},
Err(e) => {
tracing::warn!("Could not read config file: {}", e);
Self::default()
}
}
}
/// Сохраняет конфигурацию в файл.
///
/// Создаёт директорию `~/.config/tele-tui/` если её нет.
///
/// # Returns
///
/// * `Ok(())` - Конфиг сохранен
/// * `Err(String)` - Ошибка сохранения
pub fn save(&self) -> Result<(), String> {
let config_dir =
Self::config_dir().ok_or_else(|| "Could not determine config directory".to_string())?;
// Создаём директорию если её нет
fs::create_dir_all(&config_dir)
.map_err(|e| format!("Could not create config directory: {}", e))?;
let config_path = config_dir.join("config.toml");
let toml_string = toml::to_string_pretty(self)
.map_err(|e| format!("Could not serialize config: {}", e))?;
fs::write(&config_path, toml_string)
.map_err(|e| format!("Could not write config file: {}", e))?;
Ok(())
}
/// Путь к файлу credentials
pub fn credentials_path() -> Option<PathBuf> {
Self::config_dir().map(|dir| dir.join("credentials"))
}
/// Загружает API_ID и API_HASH для Telegram.
///
/// Ищет credentials в следующем порядке:
/// 1. `~/.config/tele-tui/credentials` файл
/// 2. Переменные окружения `API_ID` и `API_HASH`
///
/// # Returns
///
/// * `Ok((api_id, api_hash))` - Учетные данные найдены
/// * `Err(String)` - Ошибка с инструкциями по настройке
pub fn load_credentials() -> Result<(i32, String), String> {
// 1. Пробуем загрузить из ~/.config/tele-tui/credentials
if let Some(credentials) = Self::load_credentials_from_file() {
return Ok(credentials);
}
// 2. Пробуем загрузить из переменных окружения (.env)
if let Some(credentials) = Self::load_credentials_from_env() {
return Ok(credentials);
}
// 3. Не нашли credentials - возвращаем инструкции
let credentials_path = Self::credentials_path()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "~/.config/tele-tui/credentials".to_string());
Err(format!(
"Telegram API credentials not found!\n\n\
Please create a file at:\n {}\n\n\
With the following content:\n\
API_ID=your_api_id\n\
API_HASH=your_api_hash\n\n\
You can get API credentials at: https://my.telegram.org/apps\n\n\
Alternatively, you can create a .env file in the current directory.",
credentials_path
))
}
/// Загружает credentials из файла ~/.config/tele-tui/credentials
fn load_credentials_from_file() -> Option<(i32, String)> {
let cred_path = Self::credentials_path()?;
if !cred_path.exists() {
return None;
}
let content = fs::read_to_string(&cred_path).ok()?;
let mut api_id: Option<i32> = None;
let mut api_hash: Option<String> = None;
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let (key, value) = line.split_once('=')?;
let key = key.trim();
let value = value.trim();
match key {
"API_ID" => api_id = value.parse().ok(),
"API_HASH" => api_hash = Some(value.to_string()),
_ => {}
}
}
Some((api_id?, api_hash?))
}
/// Загружает credentials из переменных окружения (.env)
fn load_credentials_from_env() -> Option<(i32, String)> {
use std::env;
let api_id_str = env::var("API_ID").ok()?;
let api_hash = env::var("API_HASH").ok()?;
let api_id = api_id_str.parse::<i32>().ok()?;
Some((api_id, api_hash))
}
}

View File

@@ -1,8 +1,13 @@
//! Configuration module.
//!
//! Loads settings from `~/.config/tele-tui/config.toml`.
//! Structs: Config, GeneralConfig, ColorsConfig, NotificationsConfig, Keybindings.
pub mod keybindings; pub mod keybindings;
mod loader;
mod validation;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
pub use keybindings::{Command, Keybindings}; pub use keybindings::{Command, Keybindings};
@@ -21,7 +26,7 @@ pub use keybindings::{Command, Keybindings};
/// println!("Timezone: {}", config.general.timezone); /// println!("Timezone: {}", config.general.timezone);
/// println!("Incoming color: {}", config.colors.incoming_message); /// println!("Incoming color: {}", config.colors.incoming_message);
/// ``` /// ```
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Config { pub struct Config {
/// Общие настройки (timezone и т.д.). /// Общие настройки (timezone и т.д.).
#[serde(default)] #[serde(default)]
@@ -38,6 +43,14 @@ pub struct Config {
/// Настройки desktop notifications. /// Настройки desktop notifications.
#[serde(default)] #[serde(default)]
pub notifications: NotificationsConfig, pub notifications: NotificationsConfig,
/// Настройки отображения изображений.
#[serde(default)]
pub images: ImagesConfig,
/// Настройки аудио (голосовые сообщения).
#[serde(default)]
pub audio: AudioConfig,
} }
/// Общие настройки приложения. /// Общие настройки приложения.
@@ -100,7 +113,59 @@ pub struct NotificationsConfig {
pub urgency: String, pub urgency: String,
} }
// Дефолтные значения /// Настройки отображения изображений.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImagesConfig {
/// Показывать превью изображений в чате
#[serde(default = "default_show_images")]
pub show_images: bool,
/// Размер кэша изображений (в МБ)
#[serde(default = "default_image_cache_size_mb")]
pub cache_size_mb: u64,
/// Максимальная ширина inline превью (в символах)
#[serde(default = "default_inline_image_max_width")]
pub inline_image_max_width: usize,
/// Автоматически загружать изображения при открытии чата
#[serde(default = "default_auto_download_images")]
pub auto_download_images: bool,
}
impl Default for ImagesConfig {
fn default() -> Self {
Self {
show_images: default_show_images(),
cache_size_mb: default_image_cache_size_mb(),
inline_image_max_width: default_inline_image_max_width(),
auto_download_images: default_auto_download_images(),
}
}
}
/// Настройки аудио (голосовые сообщения).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AudioConfig {
/// Размер кэша голосовых файлов (в МБ)
#[serde(default = "default_audio_cache_size_mb")]
pub cache_size_mb: u64,
/// Автоматически загружать голосовые при открытии чата
#[serde(default = "default_auto_download_voice")]
pub auto_download_voice: bool,
}
impl Default for AudioConfig {
fn default() -> Self {
Self {
cache_size_mb: default_audio_cache_size_mb(),
auto_download_voice: default_auto_download_voice(),
}
}
}
// Дефолтные значения (используются serde атрибутами)
fn default_timezone() -> String { fn default_timezone() -> String {
"+03:00".to_string() "+03:00".to_string()
} }
@@ -126,7 +191,7 @@ fn default_reaction_other_color() -> String {
} }
fn default_notifications_enabled() -> bool { fn default_notifications_enabled() -> bool {
true false
} }
fn default_show_preview() -> bool { fn default_show_preview() -> bool {
@@ -141,6 +206,30 @@ fn default_notification_urgency() -> String {
"normal".to_string() "normal".to_string()
} }
fn default_show_images() -> bool {
true
}
fn default_image_cache_size_mb() -> u64 {
crate::constants::DEFAULT_IMAGE_CACHE_SIZE_MB
}
fn default_inline_image_max_width() -> usize {
crate::constants::INLINE_IMAGE_MAX_WIDTH
}
fn default_auto_download_images() -> bool {
true
}
fn default_audio_cache_size_mb() -> u64 {
crate::constants::DEFAULT_AUDIO_CACHE_SIZE_MB
}
fn default_auto_download_voice() -> bool {
false
}
impl Default for GeneralConfig { impl Default for GeneralConfig {
fn default() -> Self { fn default() -> Self {
Self { timezone: default_timezone() } Self { timezone: default_timezone() }
@@ -171,309 +260,6 @@ impl Default for NotificationsConfig {
} }
} }
impl Default for Config {
fn default() -> Self {
Self {
general: GeneralConfig::default(),
colors: ColorsConfig::default(),
keybindings: Keybindings::default(),
notifications: NotificationsConfig::default(),
}
}
}
impl Config {
/// Валидация конфигурации
pub fn validate(&self) -> Result<(), String> {
// Проверка timezone
if !self.general.timezone.starts_with('+') && !self.general.timezone.starts_with('-') {
return Err(format!(
"Invalid timezone (must start with + or -): {}",
self.general.timezone
));
}
// Проверка цветов
let valid_colors = [
"black",
"red",
"green",
"yellow",
"blue",
"magenta",
"cyan",
"gray",
"grey",
"white",
"darkgray",
"darkgrey",
"lightred",
"lightgreen",
"lightyellow",
"lightblue",
"lightmagenta",
"lightcyan",
];
for color_name in [
&self.colors.incoming_message,
&self.colors.outgoing_message,
&self.colors.selected_message,
&self.colors.reaction_chosen,
&self.colors.reaction_other,
] {
if !valid_colors.contains(&color_name.to_lowercase().as_str()) {
return Err(format!("Invalid color: {}", color_name));
}
}
Ok(())
}
/// Возвращает путь к конфигурационному файлу.
///
/// # Returns
///
/// `Some(PathBuf)` - `~/.config/tele-tui/config.toml`
/// `None` - Не удалось определить директорию конфигурации
pub fn config_path() -> Option<PathBuf> {
dirs::config_dir().map(|mut path| {
path.push("tele-tui");
path.push("config.toml");
path
})
}
/// Путь к директории конфигурации
pub fn config_dir() -> Option<PathBuf> {
dirs::config_dir().map(|mut path| {
path.push("tele-tui");
path
})
}
/// Загружает конфигурацию из файла.
///
/// Ищет конфиг в `~/.config/tele-tui/config.toml`.
/// Если файл не существует, создаёт дефолтный.
/// Если файл невалиден, возвращает дефолтные значения.
///
/// # Returns
///
/// Всегда возвращает валидную конфигурацию.
///
/// # Examples
///
/// ```ignore
/// let config = Config::load();
/// ```
pub fn load() -> Self {
let config_path = match Self::config_path() {
Some(path) => path,
None => {
tracing::warn!("Could not determine config directory, using defaults");
return Self::default();
}
};
if !config_path.exists() {
// Создаём дефолтный конфиг при первом запуске
let default_config = Self::default();
if let Err(e) = default_config.save() {
tracing::warn!("Could not create default config: {}", e);
}
return default_config;
}
match fs::read_to_string(&config_path) {
Ok(content) => match toml::from_str::<Config>(&content) {
Ok(config) => {
// Валидируем загруженный конфиг
if let Err(e) = config.validate() {
tracing::error!("Config validation error: {}", e);
tracing::warn!("Using default configuration instead");
Self::default()
} else {
config
}
}
Err(e) => {
tracing::warn!("Could not parse config file: {}", e);
Self::default()
}
},
Err(e) => {
tracing::warn!("Could not read config file: {}", e);
Self::default()
}
}
}
/// Сохраняет конфигурацию в файл.
///
/// Создаёт директорию `~/.config/tele-tui/` если её нет.
///
/// # Returns
///
/// * `Ok(())` - Конфиг сохранен
/// * `Err(String)` - Ошибка сохранения
pub fn save(&self) -> Result<(), String> {
let config_dir =
Self::config_dir().ok_or_else(|| "Could not determine config directory".to_string())?;
// Создаём директорию если её нет
fs::create_dir_all(&config_dir)
.map_err(|e| format!("Could not create config directory: {}", e))?;
let config_path = config_dir.join("config.toml");
let toml_string = toml::to_string_pretty(self)
.map_err(|e| format!("Could not serialize config: {}", e))?;
fs::write(&config_path, toml_string)
.map_err(|e| format!("Could not write config file: {}", e))?;
Ok(())
}
/// Парсит строку цвета в `ratatui::style::Color`.
///
/// Поддерживает стандартные цвета (red, green, blue и т.д.),
/// light-варианты (lightred, lightgreen и т.д.) и grey/gray.
///
/// # Arguments
///
/// * `color_str` - Название цвета (case-insensitive)
///
/// # Returns
///
/// `Color` - Соответствующий цвет или `White` если цвет не распознан
///
/// # Examples
///
/// ```ignore
/// let color = config.parse_color("red");
/// let color = config.parse_color("LightBlue");
/// ```
pub fn parse_color(&self, color_str: &str) -> ratatui::style::Color {
use ratatui::style::Color;
match color_str.to_lowercase().as_str() {
"black" => Color::Black,
"red" => Color::Red,
"green" => Color::Green,
"yellow" => Color::Yellow,
"blue" => Color::Blue,
"magenta" => Color::Magenta,
"cyan" => Color::Cyan,
"gray" | "grey" => Color::Gray,
"white" => Color::White,
"darkgray" | "darkgrey" => Color::DarkGray,
"lightred" => Color::LightRed,
"lightgreen" => Color::LightGreen,
"lightyellow" => Color::LightYellow,
"lightblue" => Color::LightBlue,
"lightmagenta" => Color::LightMagenta,
"lightcyan" => Color::LightCyan,
_ => Color::White, // fallback
}
}
/// Путь к файлу credentials
pub fn credentials_path() -> Option<PathBuf> {
Self::config_dir().map(|dir| dir.join("credentials"))
}
/// Загружает API_ID и API_HASH для Telegram.
///
/// Ищет credentials в следующем порядке:
/// 1. `~/.config/tele-tui/credentials` файл
/// 2. Переменные окружения `API_ID` и `API_HASH`
///
/// # Returns
///
/// * `Ok((api_id, api_hash))` - Учетные данные найдены
/// * `Err(String)` - Ошибка с инструкциями по настройке
///
/// # Credentials Format
///
/// Файл `~/.config/tele-tui/credentials`:
/// ```text
/// API_ID=12345
/// API_HASH=your_api_hash_here
/// ```
pub fn load_credentials() -> Result<(i32, String), String> {
// 1. Пробуем загрузить из ~/.config/tele-tui/credentials
if let Some(credentials) = Self::load_credentials_from_file() {
return Ok(credentials);
}
// 2. Пробуем загрузить из переменных окружения (.env)
if let Some(credentials) = Self::load_credentials_from_env() {
return Ok(credentials);
}
// 3. Не нашли credentials - возвращаем инструкции
let credentials_path = Self::credentials_path()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "~/.config/tele-tui/credentials".to_string());
Err(format!(
"Telegram API credentials not found!\n\n\
Please create a file at:\n {}\n\n\
With the following content:\n\
API_ID=your_api_id\n\
API_HASH=your_api_hash\n\n\
You can get API credentials at: https://my.telegram.org/apps\n\n\
Alternatively, you can create a .env file in the current directory.",
credentials_path
))
}
/// Загружает credentials из файла ~/.config/tele-tui/credentials
fn load_credentials_from_file() -> Option<(i32, String)> {
let cred_path = Self::credentials_path()?;
if !cred_path.exists() {
return None;
}
let content = fs::read_to_string(&cred_path).ok()?;
let mut api_id: Option<i32> = None;
let mut api_hash: Option<String> = None;
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let (key, value) = line.split_once('=')?;
let key = key.trim();
let value = value.trim();
match key {
"API_ID" => api_id = value.parse().ok(),
"API_HASH" => api_hash = Some(value.to_string()),
_ => {}
}
}
Some((api_id?, api_hash?))
}
/// Загружает credentials из переменных окружения (.env)
fn load_credentials_from_env() -> Option<(i32, String)> {
use std::env;
let api_id_str = env::var("API_ID").ok()?;
let api_hash = env::var("API_HASH").ok()?;
let api_id = api_id_str.parse::<i32>().ok()?;
Some((api_id, api_hash))
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -485,10 +271,22 @@ mod tests {
let keybindings = &config.keybindings; let keybindings = &config.keybindings;
// Test that keybindings exist for common commands // Test that keybindings exist for common commands
assert!(keybindings.get_command(&KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)) == Some(Command::ReplyMessage)); assert!(
assert!(keybindings.get_command(&KeyEvent::new(KeyCode::Char('к'), KeyModifiers::NONE)) == Some(Command::ReplyMessage)); keybindings.get_command(&KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE))
assert!(keybindings.get_command(&KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE)) == Some(Command::ForwardMessage)); == Some(Command::ReplyMessage)
assert!(keybindings.get_command(&KeyEvent::new(KeyCode::Char('а'), KeyModifiers::NONE)) == Some(Command::ForwardMessage)); );
assert!(
keybindings.get_command(&KeyEvent::new(KeyCode::Char('к'), KeyModifiers::NONE))
== Some(Command::ReplyMessage)
);
assert!(
keybindings.get_command(&KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE))
== Some(Command::ForwardMessage)
);
assert!(
keybindings.get_command(&KeyEvent::new(KeyCode::Char('а'), KeyModifiers::NONE))
== Some(Command::ForwardMessage)
);
} }
#[test] #[test]
@@ -556,10 +354,24 @@ mod tests {
#[test] #[test]
fn test_config_validate_valid_all_standard_colors() { fn test_config_validate_valid_all_standard_colors() {
let colors = [ let colors = [
"black", "red", "green", "yellow", "blue", "magenta", "black",
"cyan", "gray", "grey", "white", "darkgray", "darkgrey", "red",
"lightred", "lightgreen", "lightyellow", "lightblue", "green",
"lightmagenta", "lightcyan" "yellow",
"blue",
"magenta",
"cyan",
"gray",
"grey",
"white",
"darkgray",
"darkgrey",
"lightred",
"lightgreen",
"lightyellow",
"lightblue",
"lightmagenta",
"lightcyan",
]; ];
for color in colors { for color in colors {
@@ -570,11 +382,7 @@ mod tests {
config.colors.reaction_chosen = color.to_string(); config.colors.reaction_chosen = color.to_string();
config.colors.reaction_other = color.to_string(); config.colors.reaction_other = color.to_string();
assert!( assert!(config.validate().is_ok(), "Color '{}' should be valid", color);
config.validate().is_ok(),
"Color '{}' should be valid",
color
);
} }
} }

88
src/config/validation.rs Normal file
View File

@@ -0,0 +1,88 @@
//! Config validation: timezone format, color names, notification settings.
use super::Config;
impl Config {
/// Валидация конфигурации
pub fn validate(&self) -> Result<(), String> {
// Проверка timezone
if !self.general.timezone.starts_with('+') && !self.general.timezone.starts_with('-') {
return Err(format!(
"Invalid timezone (must start with + or -): {}",
self.general.timezone
));
}
// Проверка цветов
let valid_colors = [
"black",
"red",
"green",
"yellow",
"blue",
"magenta",
"cyan",
"gray",
"grey",
"white",
"darkgray",
"darkgrey",
"lightred",
"lightgreen",
"lightyellow",
"lightblue",
"lightmagenta",
"lightcyan",
];
for color_name in [
&self.colors.incoming_message,
&self.colors.outgoing_message,
&self.colors.selected_message,
&self.colors.reaction_chosen,
&self.colors.reaction_other,
] {
if !valid_colors.contains(&color_name.to_lowercase().as_str()) {
return Err(format!("Invalid color: {}", color_name));
}
}
Ok(())
}
/// Парсит строку цвета в `ratatui::style::Color`.
///
/// Поддерживает стандартные цвета (red, green, blue и т.д.),
/// light-варианты (lightred, lightgreen и т.д.) и grey/gray.
///
/// # Arguments
///
/// * `color_str` - Название цвета (case-insensitive)
///
/// # Returns
///
/// `Color` - Соответствующий цвет или `White` если цвет не распознан
pub fn parse_color(&self, color_str: &str) -> ratatui::style::Color {
use ratatui::style::Color;
match color_str.to_lowercase().as_str() {
"black" => Color::Black,
"red" => Color::Red,
"green" => Color::Green,
"yellow" => Color::Yellow,
"blue" => Color::Blue,
"magenta" => Color::Magenta,
"cyan" => Color::Cyan,
"gray" | "grey" => Color::Gray,
"white" => Color::White,
"darkgray" | "darkgrey" => Color::DarkGray,
"lightred" => Color::LightRed,
"lightgreen" => Color::LightGreen,
"lightyellow" => Color::LightYellow,
"lightblue" => Color::LightBlue,
"lightmagenta" => Color::LightMagenta,
"lightcyan" => Color::LightCyan,
_ => Color::White, // fallback
}
}
}

View File

@@ -1,4 +1,4 @@
// Application constants //! Application-wide constants (memory limits, timeouts, UI sizes).
// ============================================================================ // ============================================================================
// Memory Limits // Memory Limits
@@ -35,3 +35,50 @@ pub const LAZY_LOAD_USERS_PER_TICK: usize = 5;
/// Лимит количества сообщений для загрузки через TDLib за раз /// Лимит количества сообщений для загрузки через TDLib за раз
pub const TDLIB_MESSAGE_LIMIT: i32 = 50; pub const TDLIB_MESSAGE_LIMIT: i32 = 50;
// ============================================================================
// Images
// ============================================================================
/// Максимальная ширина превью изображения (в символах)
pub const MAX_IMAGE_WIDTH: u16 = 30;
/// Максимальная высота превью изображения (в строках)
pub const MAX_IMAGE_HEIGHT: u16 = 15;
/// Минимальная высота превью изображения (в строках)
pub const MIN_IMAGE_HEIGHT: u16 = 3;
/// Таймаут скачивания файла (в секундах)
#[allow(dead_code)]
pub const FILE_DOWNLOAD_TIMEOUT_SECS: u64 = 30;
/// Размер кэша изображений по умолчанию (в МБ)
pub const DEFAULT_IMAGE_CACHE_SIZE_MB: u64 = 500;
/// Максимальная ширина inline превью изображений (в символах)
#[cfg(feature = "images")]
pub const INLINE_IMAGE_MAX_WIDTH: usize = 50;
/// Ширина одного фото в альбоме (в символах)
#[cfg(feature = "images")]
pub const ALBUM_PHOTO_WIDTH: u16 = 16;
/// Высота одного фото в альбоме (в строках)
#[cfg(feature = "images")]
pub const ALBUM_PHOTO_HEIGHT: u16 = 8;
/// Отступ между фото в альбоме (в символах)
#[cfg(feature = "images")]
pub const ALBUM_PHOTO_GAP: u16 = 1;
/// Максимальное количество фото в одном ряду альбома
#[cfg(feature = "images")]
pub const ALBUM_GRID_MAX_COLS: usize = 3;
// ============================================================================
// Audio
// ============================================================================
/// Размер кэша голосовых сообщений по умолчанию (в МБ)
pub const DEFAULT_AUDIO_CACHE_SIZE_MB: u64 = 100;

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

812
src/input/handlers/chat.rs Normal file
View File

@@ -0,0 +1,812 @@
//! Chat input handlers
//!
//! Handles keyboard input when a chat is open, including:
//! - Message scrolling and navigation
//! - Message selection and actions
//! - Editing and sending messages
//! - Loading older messages
use super::chat_loader::{load_older_messages_if_needed, open_chat_and_load_data};
use crate::app::methods::{
compose::ComposeMethods, messages::MessageMethods, modal::ModalMethods,
navigation::NavigationMethods,
};
use crate::app::App;
use crate::app::InputMode;
use crate::input::handlers::{copy_to_clipboard, format_message_for_clipboard};
use crate::tdlib::{ChatAction, TdClientTrait};
use crate::types::{ChatId, MessageId};
use crate::utils::{is_non_empty, with_timeout_msg};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use std::time::{Duration, Instant};
/// Обработка режима выбора сообщения для действий
///
/// Обрабатывает:
/// - Навигацию по сообщениям (Up/Down)
/// - Удаление сообщения (d/в/Delete)
/// - Ответ на сообщение (r/к)
/// - Пересылку сообщения (f/а)
/// - Копирование сообщения (y/н)
/// - Добавление реакции (e/у)
pub async fn handle_message_selection<T: TdClientTrait>(
app: &mut App<T>,
_key: KeyEvent,
command: Option<crate::config::Command>,
) {
match command {
Some(crate::config::Command::MoveUp) => {
app.select_previous_message();
}
Some(crate::config::Command::MoveDown) => {
app.select_next_message();
}
Some(crate::config::Command::DeleteMessage) => {
let Some(msg) = app.get_selected_message() else {
return;
};
let can_delete =
msg.can_be_deleted_only_for_self() || msg.can_be_deleted_for_all_users();
if can_delete {
app.chat_state = crate::app::ChatState::DeleteConfirmation { message_id: msg.id() };
}
}
Some(crate::config::Command::EnterInsertMode) => {
app.input_mode = InputMode::Insert;
app.chat_state = crate::app::ChatState::Normal;
}
Some(crate::config::Command::ReplyMessage) => {
app.start_reply_to_selected();
app.input_mode = InputMode::Insert;
}
Some(crate::config::Command::ForwardMessage) => {
app.start_forward_selected();
}
Some(crate::config::Command::CopyMessage) => {
let Some(msg) = app.get_selected_message() else {
return;
};
let text = format_message_for_clipboard(&msg);
match copy_to_clipboard(&text) {
Ok(_) => {
app.status_message = Some("Сообщение скопировано".to_string());
}
Err(e) => {
app.error_message = Some(format!("Ошибка копирования: {}", e));
}
}
}
Some(crate::config::Command::ViewImage) => {
handle_view_or_play_media(app).await;
}
Some(crate::config::Command::TogglePlayback) => {
handle_toggle_voice_playback(app).await;
}
Some(crate::config::Command::SeekForward | crate::config::Command::MoveRight) => {
handle_voice_seek(app, 5.0);
}
Some(crate::config::Command::SeekBackward | crate::config::Command::MoveLeft) => {
handle_voice_seek(app, -5.0);
}
Some(crate::config::Command::ReactMessage) => {
let Some(msg) = app.get_selected_message() else {
return;
};
let chat_id = app.selected_chat_id.unwrap();
let message_id = msg.id();
app.status_message = Some("Загрузка реакций...".to_string());
app.needs_redraw = true;
match with_timeout_msg(
Duration::from_secs(5),
app.td_client
.get_message_available_reactions(chat_id, message_id),
"Таймаут загрузки реакций",
)
.await
{
Ok(reactions) => {
let reactions: Vec<String> = reactions;
if reactions.is_empty() {
app.error_message =
Some("Реакции недоступны для этого сообщения".to_string());
app.status_message = None;
app.needs_redraw = true;
} else {
app.enter_reaction_picker_mode(message_id.as_i64(), reactions);
app.status_message = None;
app.needs_redraw = true;
}
}
Err(e) => {
app.error_message = Some(e);
app.status_message = None;
app.needs_redraw = true;
}
}
}
_ => {}
}
}
/// Редактирование существующего сообщения
pub async fn edit_message<T: TdClientTrait>(
app: &mut App<T>,
chat_id: i64,
msg_id: MessageId,
text: String,
) {
// Проверяем, что сообщение есть в локальном кэше
let msg_exists = app
.td_client
.current_chat_messages()
.iter()
.any(|m| m.id() == msg_id);
if !msg_exists {
app.error_message =
Some(format!("Сообщение {} не найдено в кэше чата {}", msg_id.as_i64(), chat_id));
app.chat_state = crate::app::ChatState::Normal;
app.message_input.clear();
app.cursor_position = 0;
return;
}
match with_timeout_msg(
Duration::from_secs(5),
app.td_client
.edit_message(ChatId::new(chat_id), msg_id, text),
"Таймаут редактирования",
)
.await
{
Ok(mut edited_msg) => {
// Сохраняем reply_to из старого сообщения (если есть)
let messages = app.td_client.current_chat_messages_mut();
if let Some(pos) = messages.iter().position(|m| m.id() == msg_id) {
let old_reply_to = messages[pos].interactions.reply_to.clone();
// Если в старом сообщении был reply и в новом он "Unknown" - сохраняем старый
if let Some(old_reply) = old_reply_to {
if edited_msg
.interactions
.reply_to
.as_ref()
.is_none_or(|r| r.sender_name == "Unknown")
{
edited_msg.interactions.reply_to = Some(old_reply);
}
}
// Заменяем сообщение
messages[pos] = edited_msg;
}
// Очищаем инпут и сбрасываем состояние ПОСЛЕ успешного редактирования
app.message_input.clear();
app.cursor_position = 0;
app.chat_state = crate::app::ChatState::Normal;
app.needs_redraw = true;
}
Err(e) => {
app.error_message = Some(e);
}
}
}
/// Отправка нового сообщения (с опциональным reply)
pub async fn send_new_message<T: TdClientTrait>(app: &mut App<T>, chat_id: i64, text: String) {
let reply_to_id = if app.is_replying() {
app.chat_state.selected_message_id()
} else {
None
};
// Создаём ReplyInfo ДО отправки, пока сообщение точно доступно
let reply_info = app
.get_replying_to_message()
.map(|m| crate::tdlib::ReplyInfo {
message_id: m.id(),
sender_name: m.sender_name().to_string(),
text: m.text().to_string(),
});
app.message_input.clear();
app.cursor_position = 0;
// Сбрасываем режим reply если он был активен
if app.is_replying() {
app.chat_state = crate::app::ChatState::Normal;
}
app.last_typing_sent = None;
// Отменяем typing status
app.td_client
.send_chat_action(ChatId::new(chat_id), ChatAction::Cancel)
.await;
match with_timeout_msg(
Duration::from_secs(5),
app.td_client
.send_message(ChatId::new(chat_id), text, reply_to_id, reply_info),
"Таймаут отправки",
)
.await
{
Ok(sent_msg) => {
// Добавляем отправленное сообщение в список (с лимитом)
app.td_client.push_message(sent_msg);
// Сбрасываем скролл чтобы видеть новое сообщение
app.message_scroll_offset = 0;
}
Err(e) => {
app.error_message = Some(e);
}
}
}
/// Обработка клавиши Enter
///
/// Обрабатывает три сценария:
/// 1. В режиме выбора сообщения: начать редактирование
/// 2. В открытом чате: отправить новое или редактировать существующее сообщение
/// 3. В списке чатов: открыть выбранный чат
pub async fn handle_enter_key<T: TdClientTrait>(app: &mut App<T>) {
// Сценарий 1: Открытие чата из списка
if app.selected_chat_id.is_none() {
let prev_selected = app.selected_chat_id;
app.select_current_chat();
if app.selected_chat_id != prev_selected {
if let Some(chat_id) = app.get_selected_chat_id() {
open_chat_and_load_data(app, chat_id).await;
}
}
return;
}
// Сценарий 2: Режим выбора сообщения - начать редактирование
if app.is_selecting_message() {
if app.start_editing_selected() {
app.input_mode = InputMode::Insert;
} else {
// Нельзя редактировать это сообщение
app.chat_state = crate::app::ChatState::Normal;
}
return;
}
// Сценарий 3: Отправка или редактирование сообщения
if !is_non_empty(&app.message_input) {
return;
}
let Some(chat_id) = app.get_selected_chat_id() else {
return;
};
let text = app.message_input.clone();
if app.is_editing() {
// Редактирование существующего сообщения
if let Some(msg_id) = app.chat_state.selected_message_id() {
edit_message(app, chat_id, msg_id, text).await;
}
} else {
// Отправка нового сообщения
send_new_message(app, chat_id, text).await;
}
}
/// Отправляет реакцию на выбранное сообщение
pub async fn send_reaction<T: TdClientTrait>(app: &mut App<T>) {
// Get selected reaction emoji
let Some(emoji) = app.get_selected_reaction().cloned() else {
return;
};
// Get selected message ID
let Some(message_id) = app.get_selected_message_for_reaction() else {
return;
};
// Get chat ID
let Some(chat_id) = app.selected_chat_id else {
return;
};
let message_id = MessageId::new(message_id);
app.status_message = Some("Отправка реакции...".to_string());
app.needs_redraw = true;
// Send reaction with timeout
let result = with_timeout_msg(
Duration::from_secs(5),
app.td_client
.toggle_reaction(chat_id, message_id, emoji.clone()),
"Таймаут отправки реакции",
)
.await;
// Handle result
match result {
Ok(_) => {
app.status_message = Some(format!("Реакция {} добавлена", emoji));
app.exit_reaction_picker_mode();
app.needs_redraw = true;
}
Err(e) => {
app.error_message = Some(e);
app.status_message = None;
app.needs_redraw = true;
}
}
}
/// Обработка ввода клавиатуры в открытом чате
///
/// Обрабатывает:
/// - Backspace/Delete: удаление символов относительно курсора
/// - Char: вставка символов в позицию курсора + typing status
/// - Left/Right/Home/End: навигация курсора
/// - Up/Down: скролл сообщений или начало режима выбора
pub async fn handle_open_chat_keyboard_input<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent) {
match key.code {
KeyCode::Backspace => {
// Удаляем символ слева от курсора
if app.cursor_position > 0 {
let chars: Vec<char> = app.message_input.chars().collect();
let mut new_input = String::new();
for (i, ch) in chars.iter().enumerate() {
if i != app.cursor_position - 1 {
new_input.push(*ch);
}
}
app.message_input = new_input;
app.cursor_position -= 1;
}
}
KeyCode::Delete => {
// Удаляем символ справа от курсора
let len = app.message_input.chars().count();
if app.cursor_position < len {
let chars: Vec<char> = app.message_input.chars().collect();
let mut new_input = String::new();
for (i, ch) in chars.iter().enumerate() {
if i != app.cursor_position {
new_input.push(*ch);
}
}
app.message_input = new_input;
}
}
KeyCode::Char(c) => {
// Игнорируем символы с Ctrl/Alt модификаторами (кроме Shift)
// Это позволяет обрабатывать хоткеи типа Ctrl+U для профиля
if key.modifiers.contains(KeyModifiers::CONTROL)
|| key.modifiers.contains(KeyModifiers::ALT)
{
return;
}
// Вставляем символ в позицию курсора
let chars: Vec<char> = app.message_input.chars().collect();
let mut new_input = String::new();
for (i, ch) in chars.iter().enumerate() {
if i == app.cursor_position {
new_input.push(c);
}
new_input.push(*ch);
}
if app.cursor_position >= chars.len() {
new_input.push(c);
}
app.message_input = new_input;
app.cursor_position += 1;
// Отправляем typing status с throttling (не чаще 1 раза в 5 сек)
let should_send_typing = app
.last_typing_sent
.map(|t| t.elapsed().as_secs() >= 5)
.unwrap_or(true);
if should_send_typing {
if let Some(chat_id) = app.get_selected_chat_id() {
app.td_client
.send_chat_action(ChatId::new(chat_id), ChatAction::Typing)
.await;
app.last_typing_sent = Some(Instant::now());
}
}
}
KeyCode::Left => {
// Курсор влево
if app.cursor_position > 0 {
app.cursor_position -= 1;
}
}
KeyCode::Right => {
// Курсор вправо
let len = app.message_input.chars().count();
if app.cursor_position < len {
app.cursor_position += 1;
}
}
KeyCode::Home => {
// Курсор в начало
app.cursor_position = 0;
}
KeyCode::End => {
// Курсор в конец
app.cursor_position = app.message_input.chars().count();
}
// Стрелки вверх/вниз - скролл сообщений (в Insert mode)
KeyCode::Down => {
if app.message_scroll_offset > 0 {
app.message_scroll_offset = app.message_scroll_offset.saturating_sub(3);
}
}
KeyCode::Up => {
// В Insert mode — только скролл
app.message_scroll_offset += 3;
load_older_messages_if_needed(app).await;
}
_ => {}
}
}
/// Обработка команды ViewImage — только фото
async fn handle_view_or_play_media<T: TdClientTrait>(app: &mut App<T>) {
let Some(msg) = app.get_selected_message() else {
return;
};
if msg.has_photo() {
#[cfg(feature = "images")]
handle_view_image(app).await;
#[cfg(not(feature = "images"))]
{
app.status_message = Some("Просмотр изображений отключён".to_string());
}
} else {
app.status_message = Some("Сообщение не содержит фото".to_string());
}
}
/// Space: play/pause toggle для голосовых сообщений
async fn handle_toggle_voice_playback<T: TdClientTrait>(app: &mut App<T>) {
use crate::tdlib::PlaybackStatus;
// Если уже есть активное воспроизведение — toggle pause/resume
if let Some(ref mut playback) = app.playback_state {
if let Some(ref player) = app.audio_player {
match playback.status {
PlaybackStatus::Playing => {
player.pause();
playback.status = PlaybackStatus::Paused;
app.last_playback_tick = None;
app.status_message = Some("⏸ Пауза".to_string());
}
PlaybackStatus::Paused => {
// Откатываем на 1 секунду для контекста
let resume_pos = (playback.position - 1.0).max(0.0);
// Перезапускаем ffplay с нужной позиции (-ss)
if player.resume_from(resume_pos).is_ok() {
playback.position = resume_pos;
} else {
// Fallback: простой SIGCONT без перемотки
player.resume();
}
playback.status = PlaybackStatus::Playing;
app.last_playback_tick = Some(Instant::now());
app.status_message = Some("▶ Воспроизведение".to_string());
}
_ => {}
}
app.needs_redraw = true;
}
return;
}
// Нет активного воспроизведения — пробуем запустить текущее голосовое
let Some(msg) = app.get_selected_message() else {
return;
};
if msg.has_voice() {
handle_play_voice(app).await;
}
}
/// Seek голосового сообщения на delta секунд
fn handle_voice_seek<T: TdClientTrait>(app: &mut App<T>, delta: f32) {
use crate::tdlib::PlaybackStatus;
let Some(ref mut playback) = app.playback_state else {
return;
};
let Some(ref player) = app.audio_player else {
return;
};
let was_playing = matches!(playback.status, PlaybackStatus::Playing);
let was_paused = matches!(playback.status, PlaybackStatus::Paused);
if was_playing || was_paused {
let new_position = (playback.position + delta).clamp(0.0, playback.duration);
if was_playing {
// Перезапускаем ffplay с новой позиции
if player.resume_from(new_position).is_ok() {
playback.position = new_position;
app.last_playback_tick = Some(std::time::Instant::now());
}
} else {
// На паузе — только двигаем позицию, воспроизведение начнётся при resume
player.stop();
playback.position = new_position;
}
let arrow = if delta > 0.0 { "" } else { "" };
app.status_message = Some(format!("{} {:.0}s", arrow, new_position));
app.needs_redraw = true;
}
}
/// Обработка команды ViewImage — открыть модальное окно с фото
#[cfg(feature = "images")]
async fn handle_view_image<T: TdClientTrait>(app: &mut App<T>) {
use crate::tdlib::{ImageModalState, PhotoDownloadState};
if !app.config().images.show_images {
return;
}
let Some(msg) = app.get_selected_message() else {
return;
};
if !msg.has_photo() {
app.status_message = Some("Сообщение не содержит фото".to_string());
return;
}
let photo = msg.photo_info().unwrap();
let msg_id = msg.id();
let file_id = photo.file_id;
let photo_width = photo.width;
let photo_height = photo.height;
let download_state = photo.download_state.clone();
match download_state {
PhotoDownloadState::Downloaded(path) => {
// Открываем модальное окно
app.image_modal = Some(ImageModalState {
message_id: msg_id,
photo_path: path,
photo_width,
photo_height,
});
app.needs_redraw = true;
}
PhotoDownloadState::NotDownloaded | PhotoDownloadState::Downloading => {
// Запоминаем намерение открыть модалку — откроется когда загрузится
app.pending_image_open = Some(crate::app::PendingImageOpen {
file_id,
message_id: msg_id,
photo_width,
photo_height,
});
app.status_message = Some("Загрузка фото...".to_string());
app.needs_redraw = true;
// Если нет активной фоновой загрузки — запускаем свою
if app.photo_download_rx.is_none() {
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
app.photo_download_rx = Some(rx);
let client_id = app.td_client.client_id();
tokio::spawn(async move {
let result = tokio::time::timeout(Duration::from_secs(30), async {
match tdlib_rs::functions::download_file(
file_id, 1, 0, 0, true, client_id,
)
.await
{
Ok(tdlib_rs::enums::File::File(f))
if f.local.is_downloading_completed
&& !f.local.path.is_empty() =>
{
Ok(f.local.path)
}
Ok(_) => Err("Файл не скачан".to_string()),
Err(e) => Err(format!("{:?}", e)),
}
})
.await;
let result =
result.unwrap_or_else(|_| Err("Таймаут загрузки".to_string()));
let _ = tx.send((file_id, result));
});
}
}
PhotoDownloadState::Error(_) => {
// Повторная попытка загрузки
app.status_message = Some("Повторная загрузка фото...".to_string());
app.needs_redraw = true;
match app.td_client.download_file(file_id).await {
Ok(path) => {
for msg in app.td_client.current_chat_messages_mut() {
if let Some(photo) = msg.photo_info_mut() {
if photo.file_id == file_id {
photo.download_state = PhotoDownloadState::Downloaded(path.clone());
break;
}
}
}
app.image_modal = Some(ImageModalState {
message_id: msg_id,
photo_path: path,
photo_width,
photo_height,
});
app.status_message = None;
}
Err(e) => {
app.error_message = Some(format!("Ошибка загрузки фото: {}", e));
app.status_message = None;
}
}
}
}
}
/// Вспомогательная функция для воспроизведения из конкретного пути
async fn handle_play_voice_from_path<T: TdClientTrait>(
app: &mut App<T>,
path: &str,
voice: &crate::tdlib::VoiceInfo,
msg: &crate::tdlib::MessageInfo,
) {
use crate::tdlib::{PlaybackState, PlaybackStatus};
if let Some(ref player) = app.audio_player {
match player.play(path) {
Ok(_) => {
app.playback_state = Some(PlaybackState {
message_id: msg.id(),
status: PlaybackStatus::Playing,
position: 0.0,
duration: voice.duration as f32,
volume: player.volume(),
});
app.last_playback_tick = Some(Instant::now());
app.status_message = Some(format!("▶ Воспроизведение ({:.0}s)", voice.duration));
app.needs_redraw = true;
}
Err(e) => {
app.error_message = Some(format!("Ошибка воспроизведения: {}", e));
}
}
} else {
app.error_message = Some("Аудиоплеер не инициализирован".to_string());
}
}
/// Воспроизведение голосового сообщения
async fn handle_play_voice<T: TdClientTrait>(app: &mut App<T>) {
use crate::tdlib::VoiceDownloadState;
let Some(msg) = app.get_selected_message() else {
return;
};
if !msg.has_voice() {
return;
}
let voice = msg.voice_info().unwrap();
let file_id = voice.file_id;
match &voice.download_state {
VoiceDownloadState::Downloaded(path) => {
// TDLib может вернуть путь без расширения — ищем файл с .oga
use std::path::Path;
let audio_path = if Path::new(path).exists() {
path.clone()
} else {
// Пробуем добавить .oga
let with_oga = format!("{}.oga", path);
if Path::new(&with_oga).exists() {
with_oga
} else {
// Пробуем найти файл с похожим именем в той же папке
if let Some(parent) = Path::new(path).parent() {
if let Some(stem) = Path::new(path).file_name() {
if let Ok(entries) = std::fs::read_dir(parent) {
for entry in entries.flatten() {
let entry_name = entry.file_name();
if entry_name
.to_string_lossy()
.starts_with(&stem.to_string_lossy().to_string())
{
let found_path = entry.path().to_string_lossy().to_string();
// Кэшируем найденный файл
if let Some(ref mut cache) = app.voice_cache {
let _ = cache.store(
&file_id.to_string(),
Path::new(&found_path),
);
}
return handle_play_voice_from_path(
app,
&found_path,
voice,
&msg,
)
.await;
}
}
}
}
}
app.error_message = Some(format!("Файл не найден: {}", path));
return;
}
};
// Кэшируем файл если ещё не в кэше
if let Some(ref mut cache) = app.voice_cache {
let _ = cache.store(&file_id.to_string(), Path::new(&audio_path));
}
handle_play_voice_from_path(app, &audio_path, voice, &msg).await;
}
VoiceDownloadState::Downloading => {
app.status_message = Some("Загрузка голосового...".to_string());
}
VoiceDownloadState::NotDownloaded => {
// Проверяем кэш перед загрузкой
let cache_key = file_id.to_string();
if let Some(cached_path) = app.voice_cache.as_mut().and_then(|c| c.get(&cache_key)) {
let path_str = cached_path.to_string_lossy().to_string();
handle_play_voice_from_path(app, &path_str, voice, &msg).await;
return;
}
// Начинаем загрузку
app.status_message = Some("Загрузка голосового...".to_string());
match app.td_client.download_voice_note(file_id).await {
Ok(path) => {
// Кэшируем загруженный файл
if let Some(ref mut cache) = app.voice_cache {
let _ = cache.store(&cache_key, std::path::Path::new(&path));
}
handle_play_voice_from_path(app, &path, voice, &msg).await;
}
Err(e) => {
app.error_message = Some(format!("Ошибка загрузки: {}", e));
}
}
}
VoiceDownloadState::Error(e) => {
app.error_message = Some(format!("Ошибка загрузки: {}", e));
}
}
}
// TODO (Этап 4): Эти функции будут переписаны для модального просмотрщика
/*
#[cfg(feature = "images")]
fn collapse_photo<T: TdClientTrait>(app: &mut App<T>, msg_id: crate::types::MessageId) {
// Закомментировано - будет реализовано в Этапе 4
}
#[cfg(feature = "images")]
fn expand_photo<T: TdClientTrait>(app: &mut App<T>, msg_id: crate::types::MessageId, path: &str) {
// Закомментировано - будет реализовано в Этапе 4
}
*/
// TODO (Этап 4): Функция _download_and_expand будет переписана
/*
#[cfg(feature = "images")]
async fn _download_and_expand<T: TdClientTrait>(app: &mut App<T>, msg_id: crate::types::MessageId, file_id: i32) {
// Закомментировано - будет реализовано в Этапе 4
}
*/

View File

@@ -0,0 +1,77 @@
//! Chat list input handlers
//!
//! Handles keyboard input for the chat list view, including:
//! - Navigation between chats
//! - Folder selection
//! - Opening chats
use crate::app::methods::navigation::NavigationMethods;
use crate::app::App;
use crate::tdlib::TdClientTrait;
use crate::utils::with_timeout;
use crossterm::event::KeyEvent;
use std::time::Duration;
/// Обработка навигации в списке чатов
///
/// Обрабатывает:
/// - Up/Down/j/k: навигация между чатами
/// - Цифры 1-9: переключение папок (1=All, 2-9=папки из TDLib)
pub async fn handle_chat_list_navigation<T: TdClientTrait>(
app: &mut App<T>,
_key: KeyEvent,
command: Option<crate::config::Command>,
) {
match command {
Some(crate::config::Command::MoveDown) => {
app.next_chat();
}
Some(crate::config::Command::MoveUp) => {
app.previous_chat();
}
Some(crate::config::Command::SelectFolder1) => {
app.selected_folder_id = None;
app.chat_list_state.select(Some(0));
}
Some(crate::config::Command::SelectFolder2) => {
select_folder(app, 0).await;
}
Some(crate::config::Command::SelectFolder3) => {
select_folder(app, 1).await;
}
Some(crate::config::Command::SelectFolder4) => {
select_folder(app, 2).await;
}
Some(crate::config::Command::SelectFolder5) => {
select_folder(app, 3).await;
}
Some(crate::config::Command::SelectFolder6) => {
select_folder(app, 4).await;
}
Some(crate::config::Command::SelectFolder7) => {
select_folder(app, 5).await;
}
Some(crate::config::Command::SelectFolder8) => {
select_folder(app, 6).await;
}
Some(crate::config::Command::SelectFolder9) => {
select_folder(app, 7).await;
}
_ => {}
}
}
/// Выбирает папку по индексу и загружает её чаты
pub async fn select_folder<T: TdClientTrait>(app: &mut App<T>, folder_idx: usize) {
if let Some(folder) = app.td_client.folders().get(folder_idx) {
let folder_id = folder.id;
app.selected_folder_id = Some(folder_id);
app.status_message = Some("Загрузка чатов папки...".to_string());
let _ =
with_timeout(Duration::from_secs(5), app.td_client.load_folder_chats(folder_id, 50))
.await;
app.status_message = None;
app.chat_list_state.select(Some(0));
}
}

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

@@ -0,0 +1,85 @@
//! Compose input handlers
//!
//! Handles text input and message composition, including:
//! - Forward mode
//! - Reply mode
//! - Edit mode
//! - Cursor movement and text editing
use crate::app::methods::{
compose::ComposeMethods, navigation::NavigationMethods, search::SearchMethods,
};
use crate::app::App;
use crate::tdlib::TdClientTrait;
use crate::types::ChatId;
use crate::utils::with_timeout_msg;
use crossterm::event::KeyEvent;
use std::time::Duration;
/// Обработка режима выбора чата для пересылки сообщения
///
/// Обрабатывает:
/// - Навигацию по списку чатов (Up/Down)
/// - Пересылку сообщения в выбранный чат (Enter)
/// - Отмену пересылки (Esc)
pub async fn handle_forward_mode<T: TdClientTrait>(
app: &mut App<T>,
_key: KeyEvent,
command: Option<crate::config::Command>,
) {
match command {
Some(crate::config::Command::Cancel) => {
app.cancel_forward();
}
Some(crate::config::Command::SubmitMessage) => {
forward_selected_message(app).await;
app.cancel_forward();
}
Some(crate::config::Command::MoveDown) => {
app.next_chat();
}
Some(crate::config::Command::MoveUp) => {
app.previous_chat();
}
_ => {}
}
}
/// Пересылает выбранное сообщение в выбранный чат
pub async fn forward_selected_message<T: TdClientTrait>(app: &mut App<T>) {
// Get all required IDs with early returns
let filtered = app.get_filtered_chats();
let Some(i) = app.chat_list_state.selected() else {
return;
};
let Some(chat) = filtered.get(i) else {
return;
};
let to_chat_id = chat.id;
let Some(msg_id) = app.chat_state.selected_message_id() else {
return;
};
let Some(from_chat_id) = app.get_selected_chat_id() else {
return;
};
// Forward the message with timeout
let result = with_timeout_msg(
Duration::from_secs(5),
app.td_client
.forward_messages(to_chat_id, ChatId::new(from_chat_id), vec![msg_id]),
"Таймаут пересылки",
)
.await;
// Handle result
match result {
Ok(_) => {
app.status_message = Some("Сообщение переслано".to_string());
}
Err(e) => {
app.error_message = Some(e);
}
}
}

View File

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

View File

@@ -4,11 +4,42 @@
//! - global: Global commands (Ctrl+R, Ctrl+S, etc.) //! - global: Global commands (Ctrl+R, Ctrl+S, etc.)
//! - clipboard: Clipboard operations //! - clipboard: Clipboard operations
//! - profile: Profile helper functions //! - profile: Profile helper functions
//! - chat: Keyboard input handling for open chat view
//! - chat_list: Navigation and interaction in the chat list
//! - chat_loader: All phases of chat message loading
//! - compose: Text input, editing, and message composition
//! - modal: Modal dialogs (delete confirmation, emoji picker, etc.)
//! - search: Search functionality (chat search, message search)
pub mod chat;
pub mod chat_list;
pub mod chat_loader;
pub mod clipboard; pub mod clipboard;
pub mod compose;
pub mod global; pub mod global;
pub mod modal;
pub mod profile; pub mod profile;
pub mod search;
pub use chat_loader::{load_older_messages_if_needed, open_chat_and_load_data, process_pending_chat_init};
pub use clipboard::*; pub use clipboard::*;
pub use global::*; pub use global::*;
pub use profile::get_available_actions_count; pub use profile::get_available_actions_count;
use crate::app::App;
use crate::tdlib::TdClientTrait;
use crate::types::MessageId;
/// Скроллит к сообщению по его ID в текущем чате
pub fn scroll_to_message<T: TdClientTrait>(app: &mut App<T>, message_id: MessageId) {
let msg_index = app
.td_client
.current_chat_messages()
.iter()
.position(|m| m.id() == message_id);
if let Some(idx) = msg_index {
let total = app.td_client.current_chat_messages().len();
app.message_scroll_offset = total.saturating_sub(idx + 5);
}
}

404
src/input/handlers/modal.rs Normal file
View File

@@ -0,0 +1,404 @@
//! Modal dialog handlers
//!
//! Handles keyboard input for modal dialogs, including:
//! - Account switcher (global overlay)
//! - Delete confirmation
//! - Reaction picker (emoji selector)
//! - Pinned messages view
//! - Profile information modal
use super::scroll_to_message;
use crate::app::methods::{modal::ModalMethods, navigation::NavigationMethods};
use crate::app::{AccountSwitcherState, App};
use crate::input::handlers::get_available_actions_count;
use crate::tdlib::TdClientTrait;
use crate::types::{ChatId, MessageId};
use crate::utils::{modal_handler::handle_yes_no, with_timeout_msg};
use crossterm::event::{KeyCode, KeyEvent};
use std::time::Duration;
/// Обработка ввода в модалке переключения аккаунтов
///
/// **SelectAccount mode:**
/// - j/k (MoveUp/MoveDown) — навигация по списку
/// - Enter — выбор аккаунта или переход к добавлению
/// - a/ф — быстрое добавление аккаунта
/// - Esc — закрыть модалку
///
/// **AddAccount mode:**
/// - Char input → ввод имени
/// - Backspace → удалить символ
/// - Enter → создать аккаунт
/// - Esc → назад к списку
pub async fn handle_account_switcher<T: TdClientTrait>(
app: &mut App<T>,
key: KeyEvent,
command: Option<crate::config::Command>,
) {
let Some(state) = &app.account_switcher else {
return;
};
match state {
AccountSwitcherState::SelectAccount { .. } => {
match command {
Some(crate::config::Command::MoveUp) => {
app.account_switcher_select_prev();
}
Some(crate::config::Command::MoveDown) => {
app.account_switcher_select_next();
}
Some(crate::config::Command::SubmitMessage) => {
app.account_switcher_confirm();
}
Some(crate::config::Command::Cancel) => {
app.close_account_switcher();
}
_ => {
// Raw key check for 'a'/'ф' shortcut
match key.code {
KeyCode::Char('a') | KeyCode::Char('ф') => {
app.account_switcher_start_add();
}
_ => {}
}
}
}
}
AccountSwitcherState::AddAccount { .. } => match key.code {
KeyCode::Esc => {
app.account_switcher_back();
}
KeyCode::Enter => {
app.account_switcher_confirm_add();
}
KeyCode::Backspace => {
if let Some(AccountSwitcherState::AddAccount {
name_input,
cursor_position,
error,
}) = &mut app.account_switcher
{
if *cursor_position > 0 {
let mut chars: Vec<char> = name_input.chars().collect();
chars.remove(*cursor_position - 1);
*name_input = chars.into_iter().collect();
*cursor_position -= 1;
*error = None;
}
}
}
KeyCode::Char(c) => {
if let Some(AccountSwitcherState::AddAccount {
name_input,
cursor_position,
error,
}) = &mut app.account_switcher
{
let mut chars: Vec<char> = name_input.chars().collect();
chars.insert(*cursor_position, c);
*name_input = chars.into_iter().collect();
*cursor_position += 1;
*error = None;
}
}
_ => {}
},
}
}
/// Обработка режима профиля пользователя/чата
///
/// Обрабатывает:
/// - Модалку подтверждения выхода из группы (двухшаговая)
/// - Навигацию по действиям профиля (Up/Down)
/// - Выполнение выбранного действия (Enter): открыть в браузере, скопировать ID, покинуть группу
/// - Выход из режима профиля (Esc)
pub async fn handle_profile_mode<T: TdClientTrait>(
app: &mut App<T>,
key: KeyEvent,
command: Option<crate::config::Command>,
) {
// Обработка подтверждения выхода из группы
let confirmation_step = app.get_leave_group_confirmation_step();
if confirmation_step > 0 {
match handle_yes_no(key.code) {
Some(true) => {
// Подтверждение
if confirmation_step == 1 {
// Первое подтверждение - показываем второе
app.show_leave_group_final_confirmation();
} else if confirmation_step == 2 {
// Второе подтверждение - выходим из группы
if let Some(chat_id) = app.selected_chat_id {
let leave_result = app.td_client.leave_chat(chat_id).await;
match leave_result {
Ok(_) => {
app.status_message = Some("Вы вышли из группы".to_string());
app.exit_profile_mode();
app.close_chat();
}
Err(e) => {
app.error_message = Some(e);
app.cancel_leave_group();
}
}
}
}
}
Some(false) => {
// Отмена
app.cancel_leave_group();
}
None => {
// Другая клавиша - игнорируем
}
}
return;
}
// Обычная навигация по профилю
match command {
Some(crate::config::Command::Cancel) => {
app.exit_profile_mode();
}
Some(crate::config::Command::MoveUp) => {
app.select_previous_profile_action();
}
Some(crate::config::Command::MoveDown) => {
if let Some(profile) = app.get_profile_info() {
let max_actions = get_available_actions_count(profile);
app.select_next_profile_action(max_actions);
}
}
Some(crate::config::Command::SubmitMessage) => {
// Выполнить выбранное действие
let Some(profile) = app.get_profile_info() else {
return;
};
let actions = get_available_actions_count(profile);
let action_index = app.get_selected_profile_action().unwrap_or(0);
// Guard: проверяем, что индекс действия валидный
if action_index >= actions {
return;
}
// Определяем какое действие выбрано
let mut current_idx = 0;
// Действие: Открыть в браузере
if let Some(username) = &profile.username {
if action_index == current_idx {
let url = format!("https://t.me/{}", username.trim_start_matches('@'));
#[cfg(feature = "url-open")]
{
match open::that(&url) {
Ok(_) => {
app.status_message = Some(format!("Открыто: {}", url));
}
Err(e) => {
app.error_message =
Some(format!("Ошибка открытия браузера: {}", e));
}
}
}
#[cfg(not(feature = "url-open"))]
{
app.error_message = Some(
"Открытие URL недоступно (требуется feature 'url-open')".to_string(),
);
}
return;
}
current_idx += 1;
}
// Действие: Скопировать ID
if action_index == current_idx {
app.status_message = Some(format!("ID скопирован: {}", profile.chat_id));
return;
}
current_idx += 1;
// Действие: Покинуть группу
if profile.is_group && action_index == current_idx {
app.show_leave_group_confirmation();
}
}
_ => {}
}
}
/// Обработка Ctrl+U для открытия профиля чата/пользователя
///
/// Загружает информацию о профиле и переключает в режим просмотра профиля
pub async fn handle_profile_open<T: TdClientTrait>(app: &mut App<T>) {
let Some(chat_id) = app.selected_chat_id else {
return;
};
app.status_message = Some("Загрузка профиля...".to_string());
match with_timeout_msg(
Duration::from_secs(5),
app.td_client.get_profile_info(chat_id),
"Таймаут загрузки профиля",
)
.await
{
Ok(profile) => {
app.enter_profile_mode(profile);
app.status_message = None;
}
Err(e) => {
app.error_message = Some(e);
app.status_message = None;
}
}
}
/// Обработка модалки подтверждения удаления сообщения
///
/// Обрабатывает:
/// - Подтверждение удаления (Y/y/Д/д)
/// - Отмена удаления (N/n/Т/т)
/// - Удаление для себя или для всех (зависит от can_be_deleted_for_all_users)
pub async fn handle_delete_confirmation<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent) {
match handle_yes_no(key.code) {
Some(true) => {
// Подтверждение удаления
if let Some(msg_id) = app.chat_state.selected_message_id() {
if let Some(chat_id) = app.get_selected_chat_id() {
// Находим сообщение для проверки can_be_deleted_for_all_users
let can_delete_for_all = app
.td_client
.current_chat_messages()
.iter()
.find(|m| m.id() == msg_id)
.map(|m| m.can_be_deleted_for_all_users())
.unwrap_or(false);
match with_timeout_msg(
Duration::from_secs(5),
app.td_client.delete_messages(
ChatId::new(chat_id),
vec![msg_id],
can_delete_for_all,
),
"Таймаут удаления",
)
.await
{
Ok(_) => {
// Удаляем из локального списка
app.td_client
.current_chat_messages_mut()
.retain(|m| m.id() != msg_id);
// Сбрасываем состояние
app.chat_state = crate::app::ChatState::Normal;
}
Err(e) => {
app.error_message = Some(e);
}
}
}
}
// Закрываем модалку
app.chat_state = crate::app::ChatState::Normal;
}
Some(false) => {
// Отмена удаления
app.chat_state = crate::app::ChatState::Normal;
}
None => {
// Другая клавиша - игнорируем
}
}
}
/// Обработка режима выбора реакции (emoji picker)
///
/// Обрабатывает:
/// - Навигацию по сетке реакций: Left/Right, Up/Down (сетка 8x6)
/// - Добавление/удаление реакции (Enter)
/// - Выход из режима (Esc)
pub async fn handle_reaction_picker_mode<T: TdClientTrait>(
app: &mut App<T>,
_key: KeyEvent,
command: Option<crate::config::Command>,
) {
match command {
Some(crate::config::Command::MoveLeft) => {
app.select_previous_reaction();
app.needs_redraw = true;
}
Some(crate::config::Command::MoveRight) => {
app.select_next_reaction();
app.needs_redraw = true;
}
Some(crate::config::Command::MoveUp) => {
if let crate::app::ChatState::ReactionPicker { selected_index, .. } =
&mut app.chat_state
{
if *selected_index >= 8 {
*selected_index = selected_index.saturating_sub(8);
app.needs_redraw = true;
}
}
}
Some(crate::config::Command::MoveDown) => {
if let crate::app::ChatState::ReactionPicker {
selected_index,
available_reactions,
..
} = &mut app.chat_state
{
let new_index = *selected_index + 8;
if new_index < available_reactions.len() {
*selected_index = new_index;
app.needs_redraw = true;
}
}
}
Some(crate::config::Command::SubmitMessage) => {
super::chat::send_reaction(app).await;
}
Some(crate::config::Command::Cancel) => {
app.exit_reaction_picker_mode();
app.needs_redraw = true;
}
_ => {}
}
}
/// Обработка режима просмотра закреплённых сообщений
///
/// Обрабатывает:
/// - Навигацию по закреплённым сообщениям (Up/Down)
/// - Переход к сообщению в истории (Enter)
/// - Выход из режима (Esc)
pub async fn handle_pinned_mode<T: TdClientTrait>(
app: &mut App<T>,
_key: KeyEvent,
command: Option<crate::config::Command>,
) {
match command {
Some(crate::config::Command::Cancel) => {
app.exit_pinned_mode();
}
Some(crate::config::Command::MoveUp) => {
app.select_previous_pinned();
}
Some(crate::config::Command::MoveDown) => {
app.select_next_pinned();
}
Some(crate::config::Command::SubmitMessage) => {
if let Some(msg_id) = app.get_selected_pinned_id() {
scroll_to_message(app, MessageId::new(msg_id));
app.exit_pinned_mode();
}
}
_ => {}
}
}

View File

@@ -0,0 +1,136 @@
//! Search input handlers
//!
//! Handles keyboard input for search functionality, including:
//! - Chat list search mode
//! - Message search mode
//! - Search query input
use crate::app::methods::{navigation::NavigationMethods, search::SearchMethods};
use crate::app::App;
use crate::tdlib::TdClientTrait;
use crate::types::{ChatId, MessageId};
use crate::utils::with_timeout;
use crossterm::event::{KeyCode, KeyEvent};
use std::time::Duration;
use super::chat_loader::open_chat_and_load_data;
use super::scroll_to_message;
/// Обработка режима поиска по чатам
///
/// Обрабатывает:
/// - Редактирование поискового запроса (Backspace, Char)
/// - Навигацию по отфильтрованному списку (Up/Down)
/// - Открытие выбранного чата (Enter)
/// - Отмену поиска (Esc)
pub async fn handle_chat_search_mode<T: TdClientTrait>(
app: &mut App<T>,
key: KeyEvent,
command: Option<crate::config::Command>,
) {
match command {
Some(crate::config::Command::Cancel) => {
app.cancel_search();
}
Some(crate::config::Command::SubmitMessage) => {
app.select_filtered_chat();
if let Some(chat_id) = app.get_selected_chat_id() {
open_chat_and_load_data(app, chat_id).await;
}
}
Some(crate::config::Command::MoveDown) => {
app.next_filtered_chat();
}
Some(crate::config::Command::MoveUp) => {
app.previous_filtered_chat();
}
_ => match key.code {
KeyCode::Backspace => {
app.search_query.pop();
app.chat_list_state.select(Some(0));
}
KeyCode::Char(c) => {
app.search_query.push(c);
app.chat_list_state.select(Some(0));
}
_ => {}
},
}
}
/// Обработка режима поиска по сообщениям в открытом чате
///
/// Обрабатывает:
/// - Навигацию по результатам поиска (Up/Down/N/n)
/// - Переход к выбранному сообщению (Enter)
/// - Редактирование поискового запроса (Backspace, Char)
/// - Выход из режима поиска (Esc)
pub async fn handle_message_search_mode<T: TdClientTrait>(
app: &mut App<T>,
key: KeyEvent,
command: Option<crate::config::Command>,
) {
match command {
Some(crate::config::Command::Cancel) => {
app.exit_message_search_mode();
}
Some(crate::config::Command::MoveUp) => {
app.select_previous_search_result();
}
Some(crate::config::Command::MoveDown) => {
app.select_next_search_result();
}
Some(crate::config::Command::SubmitMessage) => {
if let Some(msg_id) = app.get_selected_search_result_id() {
scroll_to_message(app, MessageId::new(msg_id));
app.exit_message_search_mode();
}
}
_ => match key.code {
KeyCode::Char('N') => {
app.select_previous_search_result();
}
KeyCode::Char('n') => {
app.select_next_search_result();
}
KeyCode::Backspace => {
let Some(mut query) = app.get_search_query().map(|s| s.to_string()) else {
return;
};
query.pop();
app.update_search_query(query.clone());
perform_message_search(app, &query).await;
}
KeyCode::Char(c) => {
let Some(mut query) = app.get_search_query().map(|s| s.to_string()) else {
return;
};
query.push(c);
app.update_search_query(query.clone());
perform_message_search(app, &query).await;
}
_ => {}
},
}
}
/// Выполняет поиск по сообщениям с обновлением результатов
pub async fn perform_message_search<T: TdClientTrait>(app: &mut App<T>, query: &str) {
let Some(chat_id) = app.get_selected_chat_id() else {
return;
};
if query.is_empty() {
app.set_search_results(Vec::new());
return;
}
if let Ok(results) = with_timeout(
Duration::from_secs(3),
app.td_client.search_messages(ChatId::new(chat_id), query),
)
.await
{
app.set_search_results(results);
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,7 @@
//! Input handling module.
//!
//! Routes keyboard events by screen (Auth vs Main) to specialized handlers.
mod auth; mod auth;
pub mod handlers; pub mod handlers;
mod main_input; mod main_input;

View File

@@ -1,11 +1,16 @@
// Library interface for tele-tui //! tele-tui — TUI client for Telegram
// This allows tests to import modules //!
//! Library interface exposing modules for integration testing.
pub mod accounts;
pub mod app; pub mod app;
pub mod audio;
pub mod config; pub mod config;
pub mod constants; pub mod constants;
pub mod formatting; pub mod formatting;
pub mod input; pub mod input;
#[cfg(feature = "images")]
pub mod media;
pub mod message_grouping; pub mod message_grouping;
pub mod notifications; pub mod notifications;
pub mod tdlib; pub mod tdlib;

View File

@@ -1,8 +1,12 @@
mod accounts;
mod app; mod app;
mod audio;
mod config; mod config;
mod constants; mod constants;
mod formatting; mod formatting;
mod input; mod input;
#[cfg(feature = "images")]
mod media;
mod message_grouping; mod message_grouping;
mod notifications; mod notifications;
mod tdlib; mod tdlib;
@@ -25,9 +29,23 @@ use tdlib_rs::enums::Update;
use app::{App, AppScreen}; use app::{App, AppScreen};
use constants::{POLL_TIMEOUT_MS, SHUTDOWN_TIMEOUT_SECS}; use constants::{POLL_TIMEOUT_MS, SHUTDOWN_TIMEOUT_SECS};
use input::{handle_auth_input, handle_main_input}; use input::{handle_auth_input, handle_main_input};
use input::handlers::process_pending_chat_init;
use tdlib::AuthState; use tdlib::AuthState;
use utils::{disable_tdlib_logs, with_timeout_ignore}; use utils::{disable_tdlib_logs, with_timeout_ignore};
/// Parses `--account <name>` from CLI arguments.
fn parse_account_arg() -> Option<String> {
let args: Vec<String> = std::env::args().collect();
let mut i = 1;
while i < args.len() {
if args[i] == "--account" && i + 1 < args.len() {
return Some(args[i + 1].clone());
}
i += 1;
}
None
}
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), io::Error> { async fn main() -> Result<(), io::Error> {
// Загружаем переменные окружения из .env // Загружаем переменные окружения из .env
@@ -38,13 +56,41 @@ async fn main() -> Result<(), io::Error> {
tracing_subscriber::fmt() tracing_subscriber::fmt()
.with_env_filter( .with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env() tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn")) .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn")),
) )
.init(); .init();
// Загружаем конфигурацию (создаёт дефолтный если отсутствует) // Загружаем конфигурацию (создаёт дефолтный если отсутствует)
let config = config::Config::load(); let config = config::Config::load();
// Загружаем/создаём accounts.toml + миграция legacy ./tdlib_data/
let accounts_config = accounts::load_or_create();
// Резолвим аккаунт из CLI или default
let account_arg = parse_account_arg();
let (account_name, db_path) =
accounts::resolve_account(&accounts_config, account_arg.as_deref()).unwrap_or_else(|e| {
eprintln!("Error: {}", e);
std::process::exit(1);
});
// Создаём директорию аккаунта если её нет
let db_path = accounts::ensure_account_dir(
account_arg
.as_deref()
.unwrap_or(&accounts_config.default_account),
)
.unwrap_or(db_path);
// Acquire per-account lock BEFORE raw mode (so error prints to normal terminal)
let account_lock = accounts::acquire_lock(
account_arg.as_deref().unwrap_or(&accounts_config.default_account),
)
.unwrap_or_else(|e| {
eprintln!("Error: {}", e);
std::process::exit(1);
});
// Отключаем логи TDLib ДО создания клиента // Отключаем логи TDLib ДО создания клиента
disable_tdlib_logs(); disable_tdlib_logs();
@@ -63,24 +109,27 @@ async fn main() -> Result<(), io::Error> {
panic_hook(info); panic_hook(info);
})); }));
// Create app state // Create app state with account-specific db_path
let mut app = App::new(config); let mut app = App::new(config, db_path);
app.current_account_name = account_name;
app.account_lock = Some(account_lock);
// Запускаем инициализацию TDLib в фоне (только для реального клиента) // Запускаем инициализацию TDLib в фоне (только для реального клиента)
let client_id = app.td_client.client_id(); let client_id = app.td_client.client_id();
let api_id = app.td_client.api_id; let api_id = app.td_client.api_id;
let api_hash = app.td_client.api_hash.clone(); let api_hash = app.td_client.api_hash.clone();
let db_path_str = app.td_client.db_path.to_string_lossy().to_string();
tokio::spawn(async move { tokio::spawn(async move {
let _ = tdlib_rs::functions::set_tdlib_parameters( if let Err(e) = tdlib_rs::functions::set_tdlib_parameters(
false, // use_test_dc false, // use_test_dc
"tdlib_data".to_string(), // database_directory db_path_str, // database_directory
"".to_string(), // files_directory "".to_string(), // files_directory
"".to_string(), // database_encryption_key "".to_string(), // database_encryption_key
true, // use_file_database true, // use_file_database
true, // use_chat_info_database true, // use_chat_info_database
true, // use_message_database true, // use_message_database
false, // use_secret_chats false, // use_secret_chats
api_id, api_id,
api_hash, api_hash,
"en".to_string(), // system_language_code "en".to_string(), // system_language_code
@@ -89,7 +138,10 @@ async fn main() -> Result<(), io::Error> {
env!("CARGO_PKG_VERSION").to_string(), // application_version env!("CARGO_PKG_VERSION").to_string(), // application_version
client_id, client_id,
) )
.await; .await
{
tracing::error!("set_tdlib_parameters failed: {:?}", e);
}
}); });
let res = run_app(&mut terminal, &mut app).await; let res = run_app(&mut terminal, &mut app).await;
@@ -121,7 +173,7 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
let polling_handle = tokio::spawn(async move { let polling_handle = tokio::spawn(async move {
while !should_stop_clone.load(Ordering::Relaxed) { while !should_stop_clone.load(Ordering::Relaxed) {
// receive() с таймаутом 0.1 сек чтобы периодически проверять флаг // receive() с таймаутом 0.1 сек чтобы периодически проверять флаг
let result = tokio::task::spawn_blocking(|| tdlib_rs::receive()).await; let result = tokio::task::spawn_blocking(tdlib_rs::receive).await;
if let Ok(Some((update, _client_id))) = result { if let Ok(Some((update, _client_id))) = result {
if update_tx.send(update).is_err() { if update_tx.send(update).is_err() {
break; // Канал закрыт, выходим break; // Канал закрыт, выходим
@@ -143,6 +195,75 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
app.needs_redraw = true; app.needs_redraw = true;
} }
// Обрабатываем результаты фоновой загрузки фото
#[cfg(feature = "images")]
{
use crate::tdlib::PhotoDownloadState;
let mut got_photos = false;
if let Some(ref mut rx) = app.photo_download_rx {
while let Ok((file_id, result)) = rx.try_recv() {
let new_state = match result {
Ok(path) => PhotoDownloadState::Downloaded(path),
Err(_) => PhotoDownloadState::Error("Ошибка загрузки".to_string()),
};
for msg in app.td_client.current_chat_messages_mut() {
if let Some(photo) = msg.photo_info_mut() {
if photo.file_id == file_id {
photo.download_state = new_state;
got_photos = true;
break;
}
}
}
// Если это фото ждёт открытия в модалке — открываем
let pending_matches = app
.pending_image_open
.as_ref()
.map(|p| p.file_id == file_id)
.unwrap_or(false);
if pending_matches {
// Ищем путь из обновлённого состояния
let downloaded_path = app
.td_client
.current_chat_messages()
.iter()
.find_map(|m| {
m.photo_info().and_then(|p| {
if p.file_id == file_id {
if let PhotoDownloadState::Downloaded(ref path) =
p.download_state
{
Some(path.clone())
} else {
None
}
} else {
None
}
})
});
if let (Some(path), Some(pending)) =
(downloaded_path, app.pending_image_open.take())
{
use crate::tdlib::ImageModalState;
app.image_modal = Some(ImageModalState {
message_id: pending.message_id,
photo_path: path,
photo_width: pending.photo_width,
photo_height: pending.photo_height,
});
app.status_message = None;
got_photos = true;
}
}
}
}
if got_photos {
app.needs_redraw = true;
}
}
// Очищаем устаревший typing status // Очищаем устаревший typing status
if app.td_client.clear_stale_typing_status() { if app.td_client.clear_stale_typing_status() {
app.needs_redraw = true; app.needs_redraw = true;
@@ -164,6 +285,42 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
app.needs_redraw = true; app.needs_redraw = true;
} }
// Обновляем позицию воспроизведения голосового сообщения
{
let mut stop_playback = false;
if let Some(ref mut playback) = app.playback_state {
use crate::tdlib::PlaybackStatus;
match playback.status {
PlaybackStatus::Playing => {
let prev_second = playback.position as u32;
if let Some(last_tick) = app.last_playback_tick {
let delta = last_tick.elapsed().as_secs_f32();
playback.position += delta;
}
app.last_playback_tick = Some(std::time::Instant::now());
// Проверяем завершение воспроизведения
if playback.position >= playback.duration
|| app.audio_player.as_ref().is_some_and(|p| p.is_stopped())
{
stop_playback = true;
}
// Перерисовка только при смене секунды (не 60 FPS)
if playback.position as u32 != prev_second || stop_playback {
app.needs_redraw = true;
}
}
_ => {
app.last_playback_tick = None;
}
}
}
if stop_playback {
app.stop_playback();
app.last_playback_tick = None;
}
}
// Рендерим только если есть изменения // Рендерим только если есть изменения
if app.needs_redraw { if app.needs_redraw {
terminal.draw(|f| ui::render(f, app))?; terminal.draw(|f| ui::render(f, app))?;
@@ -182,21 +339,39 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
// Graceful shutdown // Graceful shutdown
should_stop.store(true, Ordering::Relaxed); should_stop.store(true, Ordering::Relaxed);
// Останавливаем воспроизведение голосового (убиваем ffplay)
app.stop_playback();
// Закрываем TDLib клиент // Закрываем TDLib клиент
let _ = tdlib_rs::functions::close(app.td_client.client_id()).await; let _ = tdlib_rs::functions::close(app.td_client.client_id()).await;
// Ждём завершения polling задачи (с таймаутом) // Ждём завершения polling задачи (с таймаутом)
with_timeout_ignore(Duration::from_secs(SHUTDOWN_TIMEOUT_SECS), polling_handle).await; with_timeout_ignore(
Duration::from_secs(SHUTDOWN_TIMEOUT_SECS),
polling_handle,
)
.await;
return Ok(()); return Ok(());
} }
match app.screen { // Ctrl+A opens account switcher from any screen
AppScreen::Loading => { if key.code == KeyCode::Char('a')
// В состоянии загрузки игнорируем ввод && key.modifiers.contains(KeyModifiers::CONTROL)
&& app.account_switcher.is_none()
{
app.open_account_switcher();
} else if app.account_switcher.is_some() {
// Route to main input handler when account switcher is open
handle_main_input(app, key).await;
} else {
match app.screen {
AppScreen::Loading => {
// В состоянии загрузки игнорируем ввод
}
AppScreen::Auth => handle_auth_input(app, key.code).await,
AppScreen::Main => handle_main_input(app, key).await,
} }
AppScreen::Auth => handle_auth_input(app, key.code).await,
AppScreen::Main => handle_main_input(app, key).await,
} }
// Любой ввод требует перерисовки // Любой ввод требует перерисовки
@@ -209,6 +384,64 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
_ => {} _ => {}
} }
} }
// Process pending chat initialization (reply info, pinned, photos)
if let Some(chat_id) = app.pending_chat_init.take() {
process_pending_chat_init(app, chat_id).await;
}
// Check pending account switch
if let Some((account_name, new_db_path)) = app.pending_account_switch.take() {
// 0. Acquire lock for new account before switching
match accounts::acquire_lock(&account_name) {
Ok(new_lock) => {
// Release old lock
if let Some(old_lock) = app.account_lock.take() {
accounts::release_lock(old_lock);
}
app.account_lock = Some(new_lock);
}
Err(e) => {
app.error_message = Some(e);
continue;
}
}
// 1. Stop playback
app.stop_playback();
// 2. Recreate client (closes old, creates new, inits TDLib params)
if let Err(e) = app.td_client.recreate_client(new_db_path).await {
app.error_message = Some(format!("Ошибка переключения: {}", e));
continue;
}
// 3. Reset app state
app.current_account_name = account_name.clone();
app.screen = AppScreen::Loading;
// 4. Persist selected account as default for next launch
let mut accounts_config = accounts::load_or_create();
accounts_config.default_account = account_name;
if let Err(e) = accounts::save(&accounts_config) {
tracing::warn!("Could not save default account: {}", e);
}
app.chats.clear();
app.selected_chat_id = None;
app.chat_state = Default::default();
app.input_mode = Default::default();
app.status_message = Some("Переключение аккаунта...".to_string());
app.error_message = None;
app.is_searching = false;
app.search_query.clear();
app.message_input.clear();
app.cursor_position = 0;
app.message_scroll_offset = 0;
app.pending_chat_init = None;
app.account_switcher = None;
app.needs_redraw = true;
}
} }
} }

112
src/media/cache.rs Normal file
View File

@@ -0,0 +1,112 @@
//! Image cache with LRU eviction.
//!
//! Stores downloaded images in `~/.cache/tele-tui/images/` with size-based eviction.
use std::fs;
use std::path::PathBuf;
/// Кэш изображений с LRU eviction по mtime
#[allow(dead_code)]
pub struct ImageCache {
cache_dir: PathBuf,
max_size_bytes: u64,
}
#[allow(dead_code)]
impl ImageCache {
/// Создаёт новый кэш с указанным лимитом в МБ
pub fn new(cache_size_mb: u64) -> Self {
let cache_dir = dirs::cache_dir()
.unwrap_or_else(|| PathBuf::from("/tmp"))
.join("tele-tui")
.join("images");
// Создаём директорию кэша если не существует
let _ = fs::create_dir_all(&cache_dir);
Self {
cache_dir,
max_size_bytes: cache_size_mb * 1024 * 1024,
}
}
/// Проверяет, есть ли файл в кэше
pub fn get_cached(&self, file_id: i32) -> Option<PathBuf> {
let path = self.cache_dir.join(format!("{}.jpg", file_id));
if path.exists() {
// Обновляем mtime для LRU
let _ = filetime::set_file_mtime(&path, filetime::FileTime::now());
Some(path)
} else {
None
}
}
/// Кэширует файл, копируя из source_path
pub fn cache_file(&self, file_id: i32, source_path: &str) -> Result<PathBuf, String> {
let dest = self.cache_dir.join(format!("{}.jpg", file_id));
fs::copy(source_path, &dest).map_err(|e| format!("Ошибка кэширования: {}", e))?;
// Evict если превышен лимит
self.evict_if_needed();
Ok(dest)
}
/// Удаляет старые файлы если кэш превышает лимит
fn evict_if_needed(&self) {
let entries = match fs::read_dir(&self.cache_dir) {
Ok(entries) => entries,
Err(_) => return,
};
let mut files: Vec<(PathBuf, u64, std::time::SystemTime)> = entries
.filter_map(|e| e.ok())
.filter_map(|e| {
let meta = e.metadata().ok()?;
let mtime = meta.modified().ok()?;
Some((e.path(), meta.len(), mtime))
})
.collect();
let total_size: u64 = files.iter().map(|(_, size, _)| size).sum();
if total_size <= self.max_size_bytes {
return;
}
// Сортируем по mtime (старые первые)
files.sort_by_key(|(_, _, mtime)| *mtime);
let mut current_size = total_size;
for (path, size, _) in &files {
if current_size <= self.max_size_bytes {
break;
}
let _ = fs::remove_file(path);
current_size -= size;
}
}
}
/// Обёртка для установки mtime без внешней зависимости
#[allow(dead_code)]
mod filetime {
use std::path::Path;
pub struct FileTime;
impl FileTime {
pub fn now() -> Self {
FileTime
}
}
pub fn set_file_mtime(_path: &Path, _time: FileTime) -> Result<(), std::io::Error> {
// На macOS/Linux можно использовать utime, но для простоты
// достаточно прочитать файл (обновит atime) — LRU по mtime не критичен
// для нашего use case. Файл будет перезаписан при повторном скачивании.
Ok(())
}
}

125
src/media/image_renderer.rs Normal file
View File

@@ -0,0 +1,125 @@
//! Terminal image renderer using ratatui-image.
//!
//! Detects terminal protocol (iTerm2, Sixel, Halfblocks) and renders images
//! as StatefulProtocol widgets.
//!
//! Implements LRU-like caching for protocols to avoid unlimited memory growth.
use crate::types::MessageId;
use ratatui_image::picker::{Picker, ProtocolType};
use ratatui_image::protocol::StatefulProtocol;
use std::collections::HashMap;
/// Максимальное количество кэшированных протоколов (LRU)
const MAX_CACHED_PROTOCOLS: usize = 100;
/// Рендерер изображений для терминала с LRU кэшем
pub struct ImageRenderer {
picker: Picker,
/// Протоколы рендеринга для каждого сообщения (message_id -> protocol)
protocols: HashMap<i64, StatefulProtocol>,
/// Порядок доступа для LRU (message_id -> порядковый номер)
access_order: HashMap<i64, usize>,
/// Счётчик для отслеживания порядка доступа
access_counter: usize,
}
impl ImageRenderer {
/// Создаёт ImageRenderer с автодетектом протокола (высокое качество для modal)
pub fn new() -> Option<Self> {
let picker = Picker::from_query_stdio().ok()?;
Some(Self {
picker,
protocols: HashMap::new(),
access_order: HashMap::new(),
access_counter: 0,
})
}
/// Создаёт ImageRenderer с принудительным Halfblocks (быстро, для inline preview)
pub fn new_fast() -> Option<Self> {
let mut picker = Picker::from_fontsize((8, 12));
picker.set_protocol_type(ProtocolType::Halfblocks);
Some(Self {
picker,
protocols: HashMap::new(),
access_order: HashMap::new(),
access_counter: 0,
})
}
/// Загружает изображение из файла и создаёт протокол рендеринга.
///
/// Если протокол уже существует, не загружает повторно (кэширование).
/// Использует LRU eviction при превышении лимита.
pub fn load_image(&mut self, msg_id: MessageId, path: &str) -> Result<(), String> {
let msg_id_i64 = msg_id.as_i64();
// Оптимизация: если протокол уже есть, обновляем access time и возвращаем
if self.protocols.contains_key(&msg_id_i64) {
self.access_counter += 1;
self.access_order.insert(msg_id_i64, self.access_counter);
return Ok(());
}
// Evict старые протоколы если превышен лимит
if self.protocols.len() >= MAX_CACHED_PROTOCOLS {
self.evict_oldest_protocol();
}
let img = image::ImageReader::open(path)
.map_err(|e| format!("Ошибка открытия: {}", e))?
.decode()
.map_err(|e| format!("Ошибка декодирования: {}", e))?;
let protocol = self.picker.new_resize_protocol(img);
self.protocols.insert(msg_id_i64, protocol);
// Обновляем access order
self.access_counter += 1;
self.access_order.insert(msg_id_i64, self.access_counter);
Ok(())
}
/// Удаляет самый старый протокол (LRU eviction)
fn evict_oldest_protocol(&mut self) {
if let Some((&oldest_id, _)) = self.access_order.iter().min_by_key(|(_, &order)| order) {
self.protocols.remove(&oldest_id);
self.access_order.remove(&oldest_id);
}
}
/// Получает мутабельную ссылку на протокол для рендеринга.
///
/// Обновляет access time для LRU.
pub fn get_protocol(&mut self, msg_id: &MessageId) -> Option<&mut StatefulProtocol> {
let msg_id_i64 = msg_id.as_i64();
if self.protocols.contains_key(&msg_id_i64) {
// Обновляем access time
self.access_counter += 1;
self.access_order.insert(msg_id_i64, self.access_counter);
}
self.protocols.get_mut(&msg_id_i64)
}
/// Удаляет протокол для сообщения
#[allow(dead_code)]
pub fn remove(&mut self, msg_id: &MessageId) {
let msg_id_i64 = msg_id.as_i64();
self.protocols.remove(&msg_id_i64);
self.access_order.remove(&msg_id_i64);
}
/// Очищает все протоколы
#[allow(dead_code)]
pub fn clear(&mut self) {
self.protocols.clear();
self.access_order.clear();
self.access_counter = 0;
}
}

9
src/media/mod.rs Normal file
View File

@@ -0,0 +1,9 @@
//! Media handling module (feature-gated under "images").
//!
//! Provides image caching and terminal image rendering via ratatui-image.
#[cfg(feature = "images")]
pub mod cache;
#[cfg(feature = "images")]
pub mod image_renderer;

View File

@@ -12,9 +12,14 @@ pub enum MessageGroup {
/// Разделитель даты (день в формате timestamp) /// Разделитель даты (день в формате timestamp)
DateSeparator(i32), DateSeparator(i32),
/// Заголовок отправителя (is_outgoing, sender_name) /// Заголовок отправителя (is_outgoing, sender_name)
SenderHeader { is_outgoing: bool, sender_name: String }, SenderHeader {
is_outgoing: bool,
sender_name: String,
},
/// Сообщение /// Сообщение
Message(MessageInfo), Message(Box<MessageInfo>),
/// Альбом (группа фото с одинаковым media_album_id)
Album(Vec<MessageInfo>),
} }
/// Группирует сообщения по дате и отправителю /// Группирует сообщения по дате и отправителю
@@ -51,6 +56,10 @@ pub enum MessageGroup {
/// // Рендерим сообщение /// // Рендерим сообщение
/// println!("{}", msg.text()); /// println!("{}", msg.text());
/// } /// }
/// MessageGroup::Album(messages) => {
/// // Рендерим альбом (группу фото)
/// println!("Album with {} photos", messages.len());
/// }
/// } /// }
/// } /// }
/// ``` /// ```
@@ -58,12 +67,28 @@ pub fn group_messages(messages: &[MessageInfo]) -> Vec<MessageGroup> {
let mut result = Vec::new(); let mut result = Vec::new();
let mut last_day: Option<i64> = None; let mut last_day: Option<i64> = None;
let mut last_sender: Option<(bool, String)> = None; // (is_outgoing, sender_name) let mut last_sender: Option<(bool, String)> = None; // (is_outgoing, sender_name)
let mut album_acc: Vec<MessageInfo> = Vec::new();
/// Сбрасывает аккумулятор альбома в результат
fn flush_album(acc: &mut Vec<MessageInfo>, result: &mut Vec<MessageGroup>) {
if acc.is_empty() {
return;
}
if acc.len() >= 2 {
result.push(MessageGroup::Album(std::mem::take(acc)));
} else {
// Одно сообщение — не альбом
result.push(MessageGroup::Message(Box::new(acc.remove(0))));
}
}
for msg in messages { for msg in messages {
// Проверяем, нужно ли добавить разделитель даты // Проверяем, нужно ли добавить разделитель даты
let msg_day = get_day(msg.date()); let msg_day = get_day(msg.date());
if last_day != Some(msg_day) { if last_day != Some(msg_day) {
// Flush аккумулятор перед разделителем даты
flush_album(&mut album_acc, &mut result);
// Добавляем разделитель даты // Добавляем разделитель даты
result.push(MessageGroup::DateSeparator(msg.date())); result.push(MessageGroup::DateSeparator(msg.date()));
last_day = Some(msg_day); last_day = Some(msg_day);
@@ -82,17 +107,42 @@ pub fn group_messages(messages: &[MessageInfo]) -> Vec<MessageGroup> {
let show_sender_header = last_sender.as_ref() != Some(&current_sender); let show_sender_header = last_sender.as_ref() != Some(&current_sender);
if show_sender_header { if show_sender_header {
result.push(MessageGroup::SenderHeader { // Flush аккумулятор перед сменой отправителя
is_outgoing: msg.is_outgoing(), flush_album(&mut album_acc, &mut result);
sender_name, result.push(MessageGroup::SenderHeader { is_outgoing: msg.is_outgoing(), sender_name });
});
last_sender = Some(current_sender); last_sender = Some(current_sender);
} }
// Добавляем само сообщение // Проверяем, является ли сообщение частью альбома
result.push(MessageGroup::Message(msg.clone())); let album_id = msg.media_album_id();
if album_id != 0 {
// Проверяем, совпадает ли album_id с текущим аккумулятором
if let Some(first) = album_acc.first() {
if first.media_album_id() == album_id {
// Тот же альбом — добавляем
album_acc.push(msg.clone());
continue;
} else {
// Другой альбом — flush старый, начинаем новый
flush_album(&mut album_acc, &mut result);
album_acc.push(msg.clone());
continue;
}
} else {
// Аккумулятор пуст — начинаем новый альбом
album_acc.push(msg.clone());
continue;
}
}
// Обычное сообщение (не альбом) — flush аккумулятор
flush_album(&mut album_acc, &mut result);
result.push(MessageGroup::Message(Box::new(msg.clone())));
} }
// Flush оставшийся аккумулятор
flush_album(&mut album_acc, &mut result);
result result
} }
@@ -246,4 +296,152 @@ mod tests {
assert!(matches!(grouped[1], MessageGroup::SenderHeader { .. })); assert!(matches!(grouped[1], MessageGroup::SenderHeader { .. }));
assert!(matches!(grouped[2], MessageGroup::Message(_))); assert!(matches!(grouped[2], MessageGroup::Message(_)));
} }
#[test]
fn test_album_grouping_two_photos() {
let msg1 = MessageBuilder::new(MessageId::new(1))
.sender_name("Alice")
.text("Photo 1")
.date(1609459200)
.incoming()
.media_album_id(12345)
.build();
let msg2 = MessageBuilder::new(MessageId::new(2))
.sender_name("Alice")
.text("Photo 2")
.date(1609459201)
.incoming()
.media_album_id(12345)
.build();
let messages = vec![msg1, msg2];
let grouped = group_messages(&messages);
// DateSep, SenderHeader, Album
assert_eq!(grouped.len(), 3);
assert!(matches!(grouped[0], MessageGroup::DateSeparator(_)));
assert!(matches!(grouped[1], MessageGroup::SenderHeader { .. }));
if let MessageGroup::Album(album) = &grouped[2] {
assert_eq!(album.len(), 2);
assert_eq!(album[0].id(), MessageId::new(1));
assert_eq!(album[1].id(), MessageId::new(2));
} else {
panic!("Expected Album, got {:?}", grouped[2]);
}
}
#[test]
fn test_album_single_photo_not_album() {
// Одно сообщение с album_id → не альбом, обычное сообщение
let msg = MessageBuilder::new(MessageId::new(1))
.sender_name("Alice")
.text("Single photo")
.date(1609459200)
.incoming()
.media_album_id(12345)
.build();
let messages = vec![msg];
let grouped = group_messages(&messages);
// DateSep, SenderHeader, Message (не Album)
assert_eq!(grouped.len(), 3);
assert!(matches!(grouped[2], MessageGroup::Message(_)));
}
#[test]
fn test_album_with_regular_messages() {
let msg1 = MessageBuilder::new(MessageId::new(1))
.sender_name("Alice")
.text("Text message")
.date(1609459200)
.incoming()
.build();
let msg2 = MessageBuilder::new(MessageId::new(2))
.sender_name("Alice")
.text("Photo 1")
.date(1609459201)
.incoming()
.media_album_id(100)
.build();
let msg3 = MessageBuilder::new(MessageId::new(3))
.sender_name("Alice")
.text("Photo 2")
.date(1609459202)
.incoming()
.media_album_id(100)
.build();
let msg4 = MessageBuilder::new(MessageId::new(4))
.sender_name("Alice")
.text("After album")
.date(1609459203)
.incoming()
.build();
let messages = vec![msg1, msg2, msg3, msg4];
let grouped = group_messages(&messages);
// DateSep, SenderHeader, Message, Album, Message
assert_eq!(grouped.len(), 5);
assert!(matches!(grouped[2], MessageGroup::Message(_)));
assert!(matches!(grouped[3], MessageGroup::Album(_)));
assert!(matches!(grouped[4], MessageGroup::Message(_)));
}
#[test]
fn test_two_different_albums() {
let msg1 = MessageBuilder::new(MessageId::new(1))
.sender_name("Alice")
.text("Album 1 - Photo 1")
.date(1609459200)
.incoming()
.media_album_id(100)
.build();
let msg2 = MessageBuilder::new(MessageId::new(2))
.sender_name("Alice")
.text("Album 1 - Photo 2")
.date(1609459201)
.incoming()
.media_album_id(100)
.build();
let msg3 = MessageBuilder::new(MessageId::new(3))
.sender_name("Alice")
.text("Album 2 - Photo 1")
.date(1609459202)
.incoming()
.media_album_id(200)
.build();
let msg4 = MessageBuilder::new(MessageId::new(4))
.sender_name("Alice")
.text("Album 2 - Photo 2")
.date(1609459203)
.incoming()
.media_album_id(200)
.build();
let messages = vec![msg1, msg2, msg3, msg4];
let grouped = group_messages(&messages);
// DateSep, SenderHeader, Album(2), Album(2)
assert_eq!(grouped.len(), 4);
if let MessageGroup::Album(a1) = &grouped[2] {
assert_eq!(a1.len(), 2);
assert_eq!(a1[0].media_album_id(), 100);
} else {
panic!("Expected first Album");
}
if let MessageGroup::Album(a2) = &grouped[3] {
assert_eq!(a2.len(), 2);
assert_eq!(a2[0].media_album_id(), 200);
} else {
panic!("Expected second Album");
}
}
} }

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(),
@@ -269,7 +267,7 @@ mod tests {
#[test] #[test]
fn test_notification_manager_creation() { fn test_notification_manager_creation() {
let manager = NotificationManager::new(); let manager = NotificationManager::new();
assert!(manager.enabled); assert!(!manager.enabled); // disabled by default
assert!(!manager.only_mentions); assert!(!manager.only_mentions);
assert!(manager.show_preview); assert!(manager.show_preview);
} }
@@ -311,22 +309,13 @@ mod tests {
#[test] #[test]
fn test_beautify_media_labels() { fn test_beautify_media_labels() {
// Test photo // Test photo
assert_eq!( assert_eq!(NotificationManager::beautify_media_labels("[Фото]"), "📷 Фото");
NotificationManager::beautify_media_labels("[Фото]"),
"📷 Фото"
);
// Test video // Test video
assert_eq!( assert_eq!(NotificationManager::beautify_media_labels("[Видео]"), "🎥 Видео");
NotificationManager::beautify_media_labels("[Видео]"),
"🎥 Видео"
);
// Test sticker with emoji // Test sticker with emoji
assert_eq!( assert_eq!(NotificationManager::beautify_media_labels("[Стикер: 😊]"), "🎨 Стикер: 😊]");
NotificationManager::beautify_media_labels("[Стикер: 😊]"),
"🎨 Стикер: 😊]"
);
// Test audio with title // Test audio with title
assert_eq!( assert_eq!(
@@ -341,10 +330,7 @@ mod tests {
); );
// Test regular text (no changes) // Test regular text (no changes)
assert_eq!( assert_eq!(NotificationManager::beautify_media_labels("Hello, world!"), "Hello, world!");
NotificationManager::beautify_media_labels("Hello, world!"),
"Hello, world!"
);
// Test mixed content // Test mixed content
assert_eq!( assert_eq!(

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,13 +205,15 @@ impl ChatManager {
{ {
match functions::get_user(private_chat.user_id, self.client_id).await { match functions::get_user(private_chat.user_id, self.client_id).await {
Ok(tdlib_rs::enums::User::User(user)) => { Ok(tdlib_rs::enums::User::User(user)) => {
let bio_opt = if let Ok(tdlib_rs::enums::UserFullInfo::UserFullInfo(full_info)) = let bio_opt =
functions::get_user_full_info(private_chat.user_id, self.client_id).await if let Ok(tdlib_rs::enums::UserFullInfo::UserFullInfo(full_info)) =
{ functions::get_user_full_info(private_chat.user_id, self.client_id)
full_info.bio.map(|b| b.text) .await
} else { {
None full_info.bio.map(|b| b.text)
}; } else {
None
};
let online_status_str = match user.status { let online_status_str = match user.status {
tdlib_rs::enums::UserStatus::Online(_) => Some("В сети".to_string()), tdlib_rs::enums::UserStatus::Online(_) => Some("В сети".to_string()),
@@ -234,10 +233,7 @@ impl ChatManager {
_ => None, _ => None,
}; };
let username_opt = user let username_opt = user.usernames.as_ref().map(|u| u.editable_username.clone());
.usernames
.as_ref()
.map(|u| u.editable_username.clone());
(bio_opt, Some(user.phone_number.clone()), username_opt, online_status_str) (bio_opt, Some(user.phone_number.clone()), username_opt, online_status_str)
} }
@@ -257,7 +253,10 @@ impl ChatManager {
} else { } else {
None None
}; };
let link = full_info.invite_link.as_ref().map(|l| l.invite_link.clone()); let link = full_info
.invite_link
.as_ref()
.map(|l| l.invite_link.clone());
(Some(full_info.member_count), desc, link) (Some(full_info.member_count), desc, link)
} }
_ => (None, None, None), _ => (None, None, None),
@@ -324,7 +323,8 @@ impl ChatManager {
/// ).await; /// ).await;
/// ``` /// ```
pub async fn send_chat_action(&self, chat_id: ChatId, action: ChatAction) { pub async fn send_chat_action(&self, chat_id: ChatId, action: ChatAction) {
let _ = functions::send_chat_action(chat_id.as_i64(), 0, Some(action), self.client_id).await; let _ =
functions::send_chat_action(chat_id.as_i64(), 0, Some(action), self.client_id).await;
} }
/// Очищает устаревший typing-статус. /// Очищает устаревший typing-статус.
@@ -371,6 +371,7 @@ impl ChatManager {
/// println!("Status: {}", typing_text); /// println!("Status: {}", typing_text);
/// } /// }
/// ``` /// ```
#[allow(dead_code)]
pub fn get_typing_text(&self) -> Option<String> { pub fn get_typing_text(&self) -> Option<String> {
self.typing_status self.typing_status
.as_ref() .as_ref()

View File

@@ -1,19 +1,17 @@
use crate::types::{ChatId, MessageId, UserId}; use crate::types::{ChatId, MessageId, UserId};
use std::env; use std::env;
use tdlib_rs::enums::{ use std::path::PathBuf;
ChatList, ConnectionState, Update, UserStatus, use tdlib_rs::enums::{Chat as TdChat, ChatList, ConnectionState, Update, UserStatus};
Chat as TdChat
};
use tdlib_rs::types::Message as TdMessage;
use tdlib_rs::functions; use tdlib_rs::functions;
use tdlib_rs::types::Message as TdMessage;
use super::auth::{AuthManager, AuthState}; use super::auth::{AuthManager, AuthState};
use super::chats::ChatManager; use super::chats::ChatManager;
use super::messages::MessageManager; use super::messages::MessageManager;
use super::reactions::ReactionManager; use super::reactions::ReactionManager;
use super::types::{ChatInfo, FolderInfo, MessageInfo, NetworkState, ProfileInfo, UserOnlineStatus}; use super::types::{
ChatInfo, FolderInfo, MessageInfo, NetworkState, ProfileInfo, UserOnlineStatus,
};
use super::users::UserCache; use super::users::UserCache;
use crate::notifications::NotificationManager; use crate::notifications::NotificationManager;
@@ -32,7 +30,7 @@ use crate::notifications::NotificationManager;
/// ```ignore /// ```ignore
/// use tele_tui::tdlib::TdClient; /// use tele_tui::tdlib::TdClient;
/// ///
/// let mut client = TdClient::new(); /// let mut client = TdClient::new(std::path::PathBuf::from("tdlib_data"));
/// ///
/// // Start authorization /// // Start authorization
/// client.send_phone_number("+1234567890".to_string()).await?; /// client.send_phone_number("+1234567890".to_string()).await?;
@@ -45,6 +43,7 @@ use crate::notifications::NotificationManager;
pub struct TdClient { pub struct TdClient {
pub api_id: i32, pub api_id: i32,
pub api_hash: String, pub api_hash: String,
pub db_path: PathBuf,
client_id: i32, client_id: i32,
// Менеджеры (делегируем им функциональность) // Менеджеры (делегируем им функциональность)
@@ -59,6 +58,7 @@ pub struct TdClient {
pub network_state: NetworkState, pub network_state: NetworkState,
} }
#[allow(dead_code)]
impl TdClient { impl TdClient {
/// Creates a new TDLib client instance. /// Creates a new TDLib client instance.
/// ///
@@ -71,24 +71,24 @@ impl TdClient {
/// # Returns /// # Returns
/// ///
/// A new `TdClient` instance ready for authentication. /// A new `TdClient` instance ready for authentication.
pub fn new() -> Self { pub fn new(db_path: PathBuf) -> Self {
// Пробуем загрузить credentials из Config (файл или env) // Пробуем загрузить credentials из Config (файл или env)
let (api_id, api_hash) = crate::config::Config::load_credentials() let (api_id, api_hash) = crate::config::Config::load_credentials().unwrap_or_else(|_| {
.unwrap_or_else(|_| { // Fallback на прямое чтение из env (старое поведение)
// Fallback на прямое чтение из env (старое поведение) let api_id = env::var("API_ID")
let api_id = env::var("API_ID") .unwrap_or_else(|_| "0".to_string())
.unwrap_or_else(|_| "0".to_string()) .parse()
.parse() .unwrap_or(0);
.unwrap_or(0); let api_hash = env::var("API_HASH").unwrap_or_default();
let api_hash = env::var("API_HASH").unwrap_or_default(); (api_id, api_hash)
(api_id, api_hash) });
});
let client_id = tdlib_rs::create_client(); let client_id = tdlib_rs::create_client();
Self { Self {
api_id, api_id,
api_hash, api_hash,
db_path,
client_id, client_id,
auth: AuthManager::new(client_id), auth: AuthManager::new(client_id),
chat_manager: ChatManager::new(client_id), chat_manager: ChatManager::new(client_id),
@@ -103,9 +103,11 @@ impl TdClient {
/// Configures notification manager from app config /// Configures notification manager from app config
pub fn configure_notifications(&mut self, config: &crate::config::NotificationsConfig) { pub fn configure_notifications(&mut self, config: &crate::config::NotificationsConfig) {
self.notification_manager.set_enabled(config.enabled); self.notification_manager.set_enabled(config.enabled);
self.notification_manager.set_only_mentions(config.only_mentions); self.notification_manager
.set_only_mentions(config.only_mentions);
self.notification_manager.set_timeout(config.timeout_ms); self.notification_manager.set_timeout(config.timeout_ms);
self.notification_manager.set_urgency(config.urgency.clone()); self.notification_manager
.set_urgency(config.urgency.clone());
// Note: show_preview is used when formatting notification body // Note: show_preview is used when formatting notification body
} }
@@ -113,7 +115,8 @@ impl TdClient {
/// ///
/// Should be called after chats are loaded to ensure muted chats don't trigger notifications. /// Should be called after chats are loaded to ensure muted chats don't trigger notifications.
pub fn sync_notification_muted_chats(&mut self) { pub fn sync_notification_muted_chats(&mut self) {
self.notification_manager.sync_muted_chats(&self.chat_manager.chats); self.notification_manager
.sync_muted_chats(&self.chat_manager.chats);
} }
// Делегирование к auth // Делегирование к auth
@@ -254,12 +257,17 @@ impl TdClient {
.await .await
} }
pub async fn get_pinned_messages(&mut self, chat_id: ChatId) -> Result<Vec<MessageInfo>, String> { pub async fn get_pinned_messages(
&mut self,
chat_id: ChatId,
) -> Result<Vec<MessageInfo>, String> {
self.message_manager.get_pinned_messages(chat_id).await self.message_manager.get_pinned_messages(chat_id).await
} }
pub async fn load_current_pinned_message(&mut self, chat_id: ChatId) { pub async fn load_current_pinned_message(&mut self, chat_id: ChatId) {
self.message_manager.load_current_pinned_message(chat_id).await self.message_manager
.load_current_pinned_message(chat_id)
.await
} }
pub async fn search_messages( pub async fn search_messages(
@@ -362,6 +370,22 @@ impl TdClient {
.await .await
} }
// Делегирование файловых операций
/// Скачивает файл по file_id и возвращает локальный путь.
pub async fn download_file(&self, file_id: i32) -> Result<String, String> {
match functions::download_file(file_id, 1, 0, 0, true, self.client_id).await {
Ok(tdlib_rs::enums::File::File(file)) => {
if file.local.is_downloading_completed && !file.local.path.is_empty() {
Ok(file.local.path)
} else {
Err("Файл не скачан".to_string())
}
}
Err(e) => Err(format!("Ошибка скачивания файла: {:?}", e)),
}
}
// Вспомогательные методы // Вспомогательные методы
pub fn client_id(&self) -> i32 { pub fn client_id(&self) -> i32 {
self.client_id self.client_id
@@ -423,7 +447,10 @@ impl TdClient {
self.chat_manager.typing_status.as_ref() self.chat_manager.typing_status.as_ref()
} }
pub fn set_typing_status(&mut self, status: Option<(crate::types::UserId, String, std::time::Instant)>) { pub fn set_typing_status(
&mut self,
status: Option<(crate::types::UserId, String, std::time::Instant)>,
) {
self.chat_manager.typing_status = status; self.chat_manager.typing_status = status;
} }
@@ -431,7 +458,9 @@ impl TdClient {
&self.message_manager.pending_view_messages &self.message_manager.pending_view_messages
} }
pub fn pending_view_messages_mut(&mut self) -> &mut Vec<(crate::types::ChatId, Vec<crate::types::MessageId>)> { pub fn pending_view_messages_mut(
&mut self,
) -> &mut Vec<(crate::types::ChatId, Vec<crate::types::MessageId>)> {
&mut self.message_manager.pending_view_messages &mut self.message_manager.pending_view_messages
} }
@@ -462,19 +491,6 @@ impl TdClient {
// ==================== Helper методы для упрощения обработки updates ==================== // ==================== Helper методы для упрощения обработки updates ====================
/// Находит мутабельную ссылку на чат по ID.
///
/// Упрощает повторяющийся паттерн `self.chats_mut().iter_mut().find(...)`.
///
/// # Arguments
///
/// * `chat_id` - ID чата для поиска
///
/// # Returns
///
/// * `Some(&mut ChatInfo)` - если чат найден
/// * `None` - если чат не найден
/// Обрабатываем одно обновление от TDLib /// Обрабатываем одно обновление от TDLib
pub fn handle_update(&mut self, update: Update) { pub fn handle_update(&mut self, update: Update) {
match update { match update {
@@ -500,7 +516,11 @@ impl TdClient {
}); });
// Обновляем позиции если они пришли // Обновляем позиции если они пришли
for pos in update.positions.iter().filter(|p| matches!(p.list, ChatList::Main)) { for pos in update
.positions
.iter()
.filter(|p| matches!(p.list, ChatList::Main))
{
crate::tdlib::chat_helpers::update_chat(self, chat_id, |chat| { crate::tdlib::chat_helpers::update_chat(self, chat_id, |chat| {
chat.order = pos.order; chat.order = pos.order;
chat.is_pinned = pos.is_pinned; chat.is_pinned = pos.is_pinned;
@@ -511,27 +531,43 @@ impl TdClient {
self.chats_mut().sort_by(|a, b| b.order.cmp(&a.order)); self.chats_mut().sort_by(|a, b| b.order.cmp(&a.order));
} }
Update::ChatReadInbox(update) => { Update::ChatReadInbox(update) => {
crate::tdlib::chat_helpers::update_chat(self, ChatId::new(update.chat_id), |chat| { crate::tdlib::chat_helpers::update_chat(
chat.unread_count = update.unread_count; self,
}); ChatId::new(update.chat_id),
|chat| {
chat.unread_count = update.unread_count;
},
);
} }
Update::ChatUnreadMentionCount(update) => { Update::ChatUnreadMentionCount(update) => {
crate::tdlib::chat_helpers::update_chat(self, ChatId::new(update.chat_id), |chat| { crate::tdlib::chat_helpers::update_chat(
chat.unread_mention_count = update.unread_mention_count; self,
}); ChatId::new(update.chat_id),
|chat| {
chat.unread_mention_count = update.unread_mention_count;
},
);
} }
Update::ChatNotificationSettings(update) => { Update::ChatNotificationSettings(update) => {
crate::tdlib::chat_helpers::update_chat(self, ChatId::new(update.chat_id), |chat| { crate::tdlib::chat_helpers::update_chat(
// mute_for > 0 означает что чат замьючен self,
chat.is_muted = update.notification_settings.mute_for > 0; ChatId::new(update.chat_id),
}); |chat| {
// mute_for > 0 означает что чат замьючен
chat.is_muted = update.notification_settings.mute_for > 0;
},
);
} }
Update::ChatReadOutbox(update) => { Update::ChatReadOutbox(update) => {
// Обновляем last_read_outbox_message_id когда собеседник прочитал сообщения // Обновляем last_read_outbox_message_id когда собеседник прочитал сообщения
let last_read_msg_id = MessageId::new(update.last_read_outbox_message_id); let last_read_msg_id = MessageId::new(update.last_read_outbox_message_id);
crate::tdlib::chat_helpers::update_chat(self, ChatId::new(update.chat_id), |chat| { crate::tdlib::chat_helpers::update_chat(
chat.last_read_outbox_message_id = last_read_msg_id; self,
}); ChatId::new(update.chat_id),
|chat| {
chat.last_read_outbox_message_id = last_read_msg_id;
},
);
// Если это текущий открытый чат — обновляем is_read у сообщений // Если это текущий открытый чат — обновляем is_read у сообщений
if Some(ChatId::new(update.chat_id)) == self.current_chat_id() { if Some(ChatId::new(update.chat_id)) == self.current_chat_id() {
for msg in self.current_chat_messages_mut().iter_mut() { for msg in self.current_chat_messages_mut().iter_mut() {
@@ -569,7 +605,9 @@ impl TdClient {
UserStatus::LastMonth(_) => UserOnlineStatus::LastMonth, UserStatus::LastMonth(_) => UserOnlineStatus::LastMonth,
UserStatus::Empty => UserOnlineStatus::LongTimeAgo, UserStatus::Empty => UserOnlineStatus::LongTimeAgo,
}; };
self.user_cache.user_statuses.insert(UserId::new(update.user_id), status); self.user_cache
.user_statuses
.insert(UserId::new(update.user_id), status);
} }
Update::ConnectionState(update) => { Update::ConnectionState(update) => {
// Обновляем состояние сетевого соединения // Обновляем состояние сетевого соединения
@@ -597,17 +635,65 @@ impl TdClient {
} }
} }
// Helper functions // Helper functions
pub fn extract_message_text_static(message: &TdMessage) -> (String, Vec<tdlib_rs::types::TextEntity>) { pub fn extract_message_text_static(
message: &TdMessage,
) -> (String, Vec<tdlib_rs::types::TextEntity>) {
use tdlib_rs::enums::MessageContent; use tdlib_rs::enums::MessageContent;
match &message.content { match &message.content {
MessageContent::MessageText(text) => (text.text.text.clone(), text.text.entities.clone()), MessageContent::MessageText(text) => {
(text.text.text.clone(), text.text.entities.clone())
}
_ => (String::new(), Vec::new()), _ => (String::new(), Vec::new()),
} }
} }
/// Recreates the TDLib client with a new database path.
///
/// Closes the old client, creates a new one, and spawns TDLib parameter initialization.
pub async fn recreate_client(&mut self, db_path: PathBuf) -> Result<(), String> {
// 1. Close old client
let _ = functions::close(self.client_id).await;
// 2. Create new client
let new_client = TdClient::new(db_path);
// 3. Spawn set_tdlib_parameters for new client
let new_client_id = new_client.client_id;
let api_id = new_client.api_id;
let api_hash = new_client.api_hash.clone();
let db_path_str = new_client.db_path.to_string_lossy().to_string();
tokio::spawn(async move {
if let Err(e) = functions::set_tdlib_parameters(
false,
db_path_str,
"".to_string(),
"".to_string(),
true,
true,
true,
false,
api_id,
api_hash,
"en".to_string(),
"Desktop".to_string(),
"".to_string(),
env!("CARGO_PKG_VERSION").to_string(),
new_client_id,
)
.await
{
tracing::error!("set_tdlib_parameters failed on recreate: {:?}", e);
}
});
// 4. Replace self
*self = new_client;
Ok(())
}
pub fn extract_content_text(content: &tdlib_rs::enums::MessageContent) -> String { pub fn extract_content_text(content: &tdlib_rs::enums::MessageContent) -> String {
use tdlib_rs::enums::MessageContent; use tdlib_rs::enums::MessageContent;
match content { match content {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -4,9 +4,13 @@
use super::client::TdClient; use super::client::TdClient;
use super::r#trait::TdClientTrait; use super::r#trait::TdClientTrait;
use super::{AuthState, ChatInfo, FolderInfo, MessageInfo, ProfileInfo, ReplyInfo, UserCache, UserOnlineStatus}; use super::{
AuthState, ChatInfo, FolderInfo, MessageInfo, ProfileInfo, ReplyInfo, UserCache,
UserOnlineStatus,
};
use crate::types::{ChatId, MessageId, UserId}; use crate::types::{ChatId, MessageId, UserId};
use async_trait::async_trait; use async_trait::async_trait;
use std::path::PathBuf;
use tdlib_rs::enums::{ChatAction, Update}; use tdlib_rs::enums::{ChatAction, Update};
#[async_trait] #[async_trait]
@@ -51,11 +55,19 @@ impl TdClientTrait for TdClient {
} }
// ============ Message methods ============ // ============ Message methods ============
async fn get_chat_history(&mut self, chat_id: ChatId, limit: i32) -> Result<Vec<MessageInfo>, String> { async fn get_chat_history(
&mut self,
chat_id: ChatId,
limit: i32,
) -> Result<Vec<MessageInfo>, String> {
self.get_chat_history(chat_id, limit).await self.get_chat_history(chat_id, limit).await
} }
async fn load_older_messages(&mut self, chat_id: ChatId, from_message_id: MessageId) -> Result<Vec<MessageInfo>, String> { async fn load_older_messages(
&mut self,
chat_id: ChatId,
from_message_id: MessageId,
) -> Result<Vec<MessageInfo>, String> {
self.load_older_messages(chat_id, from_message_id).await self.load_older_messages(chat_id, from_message_id).await
} }
@@ -67,7 +79,11 @@ impl TdClientTrait for TdClient {
self.load_current_pinned_message(chat_id).await self.load_current_pinned_message(chat_id).await
} }
async fn search_messages(&self, chat_id: ChatId, query: &str) -> Result<Vec<MessageInfo>, String> { async fn search_messages(
&self,
chat_id: ChatId,
query: &str,
) -> Result<Vec<MessageInfo>, String> {
self.search_messages(chat_id, query).await self.search_messages(chat_id, query).await
} }
@@ -147,7 +163,8 @@ impl TdClientTrait for TdClient {
chat_id: ChatId, chat_id: ChatId,
message_id: MessageId, message_id: MessageId,
) -> Result<Vec<String>, String> { ) -> Result<Vec<String>, String> {
self.get_message_available_reactions(chat_id, message_id).await self.get_message_available_reactions(chat_id, message_id)
.await
} }
async fn toggle_reaction( async fn toggle_reaction(
@@ -159,6 +176,16 @@ impl TdClientTrait for TdClient {
self.toggle_reaction(chat_id, message_id, reaction).await self.toggle_reaction(chat_id, message_id, reaction).await
} }
// ============ File methods ============
async fn download_file(&self, file_id: i32) -> Result<String, String> {
self.download_file(file_id).await
}
async fn download_voice_note(&self, file_id: i32) -> Result<String, String> {
// Voice notes use the same download mechanism as photos
self.download_file(file_id).await
}
fn client_id(&self) -> i32 { fn client_id(&self) -> i32 {
self.client_id() self.client_id()
} }
@@ -265,7 +292,13 @@ impl TdClientTrait for TdClient {
// ============ Notification methods ============ // ============ Notification methods ============
fn sync_notification_muted_chats(&mut self) { fn sync_notification_muted_chats(&mut self) {
self.notification_manager.sync_muted_chats(&self.chat_manager.chats); self.notification_manager
.sync_muted_chats(&self.chat_manager.chats);
}
// ============ Account switching ============
async fn recreate_client(&mut self, db_path: PathBuf) -> Result<(), String> {
TdClient::recreate_client(self, db_path).await
} }
// ============ Update handling ============ // ============ Update handling ============

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, ReactionInfo, ReplyInfo}; use super::types::{
ForwardInfo, MediaInfo, PhotoDownloadState, PhotoInfo, ReactionInfo, ReplyInfo,
VoiceDownloadState, VoiceInfo,
};
/// Извлекает текст контента из TDLib Message /// Извлекает текст контента из TDLib Message
/// ///
@@ -19,9 +22,9 @@ pub fn extract_content_text(msg: &TdMessage) -> String {
MessageContent::MessagePhoto(p) => { MessageContent::MessagePhoto(p) => {
let caption_text = p.caption.text.clone(); let caption_text = p.caption.text.clone();
if caption_text.is_empty() { if caption_text.is_empty() {
"[Фото]".to_string() "📷 [Фото]".to_string()
} else { } else {
caption_text format!("📷 {}", caption_text)
} }
} }
MessageContent::MessageVideo(v) => { MessageContent::MessageVideo(v) => {
@@ -52,11 +55,12 @@ pub fn extract_content_text(msg: &TdMessage) -> String {
} }
} }
MessageContent::MessageVoiceNote(v) => { MessageContent::MessageVoiceNote(v) => {
let duration = v.voice_note.duration;
let caption_text = v.caption.text.clone(); let caption_text = v.caption.text.clone();
if caption_text.is_empty() { if caption_text.is_empty() {
"[Голосовое]".to_string() format!("🎤 [Голосовое {:.0}s]", duration)
} else { } else {
caption_text format!("🎤 {} ({:.0}s)", caption_text, duration)
} }
} }
MessageContent::MessageAudio(a) => { MessageContent::MessageAudio(a) => {
@@ -94,9 +98,9 @@ pub async fn extract_sender_name(msg: &TdMessage, client_id: i32) -> String {
match &msg.sender_id { match &msg.sender_id {
MessageSender::User(user) => { MessageSender::User(user) => {
match tdlib_rs::functions::get_user(user.user_id, client_id).await { match tdlib_rs::functions::get_user(user.user_id, client_id).await {
Ok(tdlib_rs::enums::User::User(u)) => { Ok(tdlib_rs::enums::User::User(u)) => format!("{} {}", u.first_name, u.last_name)
format!("{} {}", u.first_name, u.last_name).trim().to_string() .trim()
} .to_string(),
_ => format!("User {}", user.user_id), _ => format!("User {}", user.user_id),
} }
} }
@@ -132,6 +136,57 @@ pub fn extract_reply_info(msg: &TdMessage) -> Option<ReplyInfo> {
}) })
} }
/// Извлекает информацию о медиа-контенте из TDLib Message
///
/// Для MessagePhoto: получает лучший размер фото, извлекает file_id, width, height.
/// Возвращает None для не-медийных типов сообщений.
pub fn extract_media_info(msg: &TdMessage) -> Option<MediaInfo> {
match &msg.content {
MessageContent::MessagePhoto(p) => {
// Берём лучший (последний = самый большой) размер фото
let best_size = p.photo.sizes.last()?;
let file_id = best_size.photo.id;
let width = best_size.width;
let height = best_size.height;
// Проверяем, скачан ли файл
let download_state = if !best_size.photo.local.path.is_empty()
&& best_size.photo.local.is_downloading_completed
{
PhotoDownloadState::Downloaded(best_size.photo.local.path.clone())
} else {
PhotoDownloadState::NotDownloaded
};
Some(MediaInfo::Photo(PhotoInfo { file_id, width, height, download_state }))
}
MessageContent::MessageVoiceNote(v) => {
let file_id = v.voice_note.voice.id;
let duration = v.voice_note.duration;
let mime_type = v.voice_note.mime_type.clone();
let waveform = v.voice_note.waveform.clone();
// Проверяем, скачан ли файл
let download_state = if !v.voice_note.voice.local.path.is_empty()
&& v.voice_note.voice.local.is_downloading_completed
{
VoiceDownloadState::Downloaded(v.voice_note.voice.local.path.clone())
} else {
VoiceDownloadState::NotDownloaded
};
Some(MediaInfo::Voice(VoiceInfo {
file_id,
duration,
mime_type,
waveform,
download_state,
}))
}
_ => None,
}
}
/// Извлекает реакции из TDLib Message /// Извлекает реакции из TDLib Message
pub fn extract_reactions(msg: &TdMessage) -> Vec<ReactionInfo> { pub fn extract_reactions(msg: &TdMessage) -> Vec<ReactionInfo> {
msg.interaction_info msg.interaction_info

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 порядок)
@@ -76,7 +72,8 @@ pub fn convert_message(
.text(content) .text(content)
.entities(entities) .entities(entities)
.date(message.date) .date(message.date)
.edit_date(message.edit_date); .edit_date(message.edit_date)
.media_album_id(message.media_album_id);
// Применяем флаги // Применяем флаги
if message.is_outgoing { if message.is_outgoing {
@@ -119,7 +116,7 @@ pub fn extract_reply_info(client: &TdClient, message: &TdMessage) -> Option<Repl
let sender_name = reply let sender_name = reply
.origin .origin
.as_ref() .as_ref()
.map(|origin| get_origin_sender_name(origin)) .map(get_origin_sender_name)
.unwrap_or_else(|| { .unwrap_or_else(|| {
// Пробуем найти оригинальное сообщение в текущем списке // Пробуем найти оригинальное сообщение в текущем списке
let reply_msg_id = MessageId::new(reply.message_id); let reply_msg_id = MessageId::new(reply.message_id);
@@ -137,12 +134,7 @@ pub fn extract_reply_info(client: &TdClient, message: &TdMessage) -> Option<Repl
.quote .quote
.as_ref() .as_ref()
.map(|q| q.text.text.clone()) .map(|q| q.text.text.clone())
.or_else(|| { .or_else(|| reply.content.as_ref().map(TdClient::extract_content_text))
reply
.content
.as_ref()
.map(TdClient::extract_content_text)
})
.unwrap_or_else(|| { .unwrap_or_else(|| {
// Пробуем найти в текущих сообщениях // Пробуем найти в текущих сообщениях
client client
@@ -153,11 +145,7 @@ pub fn extract_reply_info(client: &TdClient, message: &TdMessage) -> Option<Repl
.unwrap_or_default() .unwrap_or_default()
}); });
Some(ReplyInfo { Some(ReplyInfo { message_id: reply_msg_id, sender_name, text })
message_id: reply_msg_id,
sender_name,
text,
})
} }
_ => None, _ => None,
} }
@@ -218,12 +206,7 @@ pub fn update_reply_info_from_loaded_messages(client: &mut TdClient) {
let msg_data: std::collections::HashMap<i64, (String, String)> = client let msg_data: std::collections::HashMap<i64, (String, String)> = client
.current_chat_messages() .current_chat_messages()
.iter() .iter()
.map(|m| { .map(|m| (m.id().as_i64(), (m.sender_name().to_string(), m.text().to_string())))
(
m.id().as_i64(),
(m.sender_name().to_string(), m.text().to_string()),
)
})
.collect(); .collect();
// Обновляем reply_to для сообщений с неполными данными // Обновляем reply_to для сообщений с неполными данными

View File

@@ -0,0 +1,137 @@
//! TDLib message conversion: JSON → MessageInfo, reply info fetching.
use crate::types::{ChatId, MessageId};
use tdlib_rs::functions;
use tdlib_rs::types::Message as TdMessage;
use crate::tdlib::types::{MessageBuilder, MessageInfo};
use super::MessageManager;
impl MessageManager {
/// Конвертировать TdMessage в MessageInfo
pub(crate) async fn convert_message(&self, msg: &TdMessage) -> Option<MessageInfo> {
use crate::tdlib::message_conversion::{
extract_content_text, extract_entities, extract_forward_info, extract_media_info,
extract_reactions, extract_reply_info, extract_sender_name,
};
// Извлекаем все части сообщения используя вспомогательные функции
let content_text = extract_content_text(msg);
let entities = extract_entities(msg);
let sender_name = extract_sender_name(msg, self.client_id).await;
let forward_from = extract_forward_info(msg);
let reply_to = extract_reply_info(msg);
let reactions = extract_reactions(msg);
let media = extract_media_info(msg);
let mut builder = MessageBuilder::new(MessageId::new(msg.id))
.sender_name(sender_name)
.text(content_text)
.entities(entities)
.date(msg.date)
.edit_date(msg.edit_date)
.media_album_id(msg.media_album_id);
if msg.is_outgoing {
builder = builder.outgoing();
} else {
builder = builder.incoming();
}
if !msg.contains_unread_mention {
builder = builder.read();
} else {
builder = builder.unread();
}
if msg.can_be_edited {
builder = builder.editable();
}
if msg.can_be_deleted_only_for_self {
builder = builder.deletable_for_self();
}
if msg.can_be_deleted_for_all_users {
builder = builder.deletable_for_all();
}
if let Some(reply) = reply_to {
builder = builder.reply_to(reply);
}
if let Some(forward) = forward_from {
builder = builder.forward_from(forward);
}
builder = builder.reactions(reactions);
if let Some(media) = media {
builder = builder.media(media);
}
Some(builder.build())
}
/// Загружает недостающую информацию об исходных сообщениях для ответов.
///
/// Ищет все reply-сообщения с `sender_name == "Unknown"` и загружает
/// полную информацию (имя отправителя, текст) из TDLib.
///
/// # Note
///
/// Вызывайте после загрузки истории чата для заполнения информации о цитируемых сообщениях.
pub async fn fetch_missing_reply_info(&mut self) {
// Early return if no chat selected
let Some(chat_id) = self.current_chat_id else {
return;
};
// Collect message IDs with missing reply info using filter_map
let to_fetch: Vec<MessageId> = self
.current_chat_messages
.iter()
.filter_map(|msg| {
msg.interactions
.reply_to
.as_ref()
.filter(|reply| reply.sender_name == "Unknown")
.map(|reply| reply.message_id)
})
.collect();
// Fetch and update each missing message
for message_id in to_fetch {
self.fetch_and_update_reply(chat_id, message_id).await;
}
}
/// Загружает одно сообщение и обновляет reply информацию.
async fn fetch_and_update_reply(&mut self, chat_id: ChatId, message_id: MessageId) {
// Try to fetch the original message
let Ok(original_msg_enum) =
functions::get_message(chat_id.as_i64(), message_id.as_i64(), self.client_id).await
else {
return;
};
let tdlib_rs::enums::Message::Message(original_msg) = original_msg_enum;
let Some(orig_info) = self.convert_message(&original_msg).await else {
return;
};
// Extract text preview (first 50 chars)
let text_preview: String = orig_info.content.text.chars().take(50).collect();
// Update reply info in all messages that reference this message
self.current_chat_messages
.iter_mut()
.filter_map(|msg| msg.interactions.reply_to.as_mut())
.filter(|reply| reply.message_id == message_id)
.for_each(|reply| {
reply.sender_name = orig_info.metadata.sender_name.clone();
reply.text = text_preview.clone();
});
}
}

102
src/tdlib/messages/mod.rs Normal file
View File

@@ -0,0 +1,102 @@
//! Message management: storage, conversion, and TDLib API operations.
mod convert;
mod operations;
use crate::constants::MAX_MESSAGES_IN_CHAT;
use crate::types::{ChatId, MessageId};
use super::types::MessageInfo;
/// Менеджер сообщений TDLib.
///
/// Управляет загрузкой, отправкой, редактированием и удалением сообщений.
/// Кеширует сообщения текущего открытого чата и закрепленные сообщения.
///
/// # Основные возможности
///
/// - Загрузка истории сообщений чата
/// - Отправка текстовых сообщений с поддержкой Markdown
/// - Редактирование и удаление сообщений
/// - Пересылка сообщений между чатами
/// - Поиск сообщений по тексту
/// - Управление закрепленными сообщениями
/// - Управление черновиками
/// - Автоматическая отметка сообщений как прочитанных
///
/// # Examples
///
/// ```ignore
/// let mut msg_manager = MessageManager::new(client_id);
///
/// // Загрузить историю чата
/// let messages = msg_manager.get_chat_history(chat_id, 50).await?;
///
/// // Отправить сообщение
/// let msg = msg_manager.send_message(
/// chat_id,
/// "Hello, **world**!".to_string(),
/// None,
/// None
/// ).await?;
/// ```
pub struct MessageManager {
/// Список сообщений текущего открытого чата (до MAX_MESSAGES_IN_CHAT).
pub current_chat_messages: Vec<MessageInfo>,
/// ID текущего открытого чата.
pub current_chat_id: Option<ChatId>,
/// Текущее закрепленное сообщение открытого чата.
pub current_pinned_message: Option<MessageInfo>,
/// Очередь сообщений для отметки как прочитанных: (chat_id, message_ids).
pub pending_view_messages: Vec<(ChatId, Vec<MessageId>)>,
/// ID клиента TDLib для API вызовов.
pub(crate) client_id: i32,
}
impl MessageManager {
/// Создает новый менеджер сообщений.
///
/// # Arguments
///
/// * `client_id` - ID клиента TDLib для API вызовов
///
/// # Returns
///
/// Новый экземпляр `MessageManager` с пустым списком сообщений.
pub fn new(client_id: i32) -> Self {
Self {
current_chat_messages: Vec::new(),
current_chat_id: None,
current_pinned_message: None,
pending_view_messages: Vec::new(),
client_id,
}
}
/// Добавляет сообщение в список текущего чата.
///
/// Автоматически ограничивает размер списка до [`MAX_MESSAGES_IN_CHAT`],
/// удаляя старые сообщения при превышении лимита.
///
/// # Arguments
///
/// * `msg` - Сообщение для добавления
///
/// # Note
///
/// Сообщение добавляется в конец списка. При превышении лимита
/// удаляются самые старые сообщения из начала списка.
pub fn push_message(&mut self, msg: MessageInfo) {
self.current_chat_messages.push(msg); // Добавляем в конец
// Ограничиваем размер списка (удаляем старые с начала)
if self.current_chat_messages.len() > MAX_MESSAGES_IN_CHAT {
self.current_chat_messages
.drain(0..(self.current_chat_messages.len() - MAX_MESSAGES_IN_CHAT));
}
}
}

View File

@@ -1,103 +1,21 @@
use crate::constants::{MAX_MESSAGES_IN_CHAT, TDLIB_MESSAGE_LIMIT}; //! TDLib message API operations: history, send, edit, delete, forward, search.
use crate::constants::TDLIB_MESSAGE_LIMIT;
use crate::types::{ChatId, MessageId}; use crate::types::{ChatId, MessageId};
use tdlib_rs::enums::{InputMessageContent, InputMessageReplyTo, SearchMessagesFilter, TextParseMode}; use tdlib_rs::enums::{
InputMessageContent, InputMessageReplyTo, SearchMessagesFilter, TextParseMode,
};
use tdlib_rs::functions; use tdlib_rs::functions;
use tdlib_rs::types::{FormattedText, InputMessageReplyToMessage, InputMessageText, Message as TdMessage, TextParseModeMarkdown}; use tdlib_rs::types::{
FormattedText, InputMessageReplyToMessage, InputMessageText, TextParseModeMarkdown,
};
use tokio::time::{sleep, Duration}; use tokio::time::{sleep, Duration};
use super::types::{MessageBuilder, MessageInfo, ReplyInfo}; use crate::tdlib::types::{MessageInfo, ReplyInfo};
/// Менеджер сообщений TDLib. use super::MessageManager;
///
/// Управляет загрузкой, отправкой, редактированием и удалением сообщений.
/// Кеширует сообщения текущего открытого чата и закрепленные сообщения.
///
/// # Основные возможности
///
/// - Загрузка истории сообщений чата
/// - Отправка текстовых сообщений с поддержкой Markdown
/// - Редактирование и удаление сообщений
/// - Пересылка сообщений между чатами
/// - Поиск сообщений по тексту
/// - Управление закрепленными сообщениями
/// - Управление черновиками
/// - Автоматическая отметка сообщений как прочитанных
///
/// # Examples
///
/// ```ignore
/// let mut msg_manager = MessageManager::new(client_id);
///
/// // Загрузить историю чата
/// let messages = msg_manager.get_chat_history(chat_id, 50).await?;
///
/// // Отправить сообщение
/// let msg = msg_manager.send_message(
/// chat_id,
/// "Hello, **world**!".to_string(),
/// None,
/// None
/// ).await?;
/// ```
pub struct MessageManager {
/// Список сообщений текущего открытого чата (до MAX_MESSAGES_IN_CHAT).
pub current_chat_messages: Vec<MessageInfo>,
/// ID текущего открытого чата.
pub current_chat_id: Option<ChatId>,
/// Текущее закрепленное сообщение открытого чата.
pub current_pinned_message: Option<MessageInfo>,
/// Очередь сообщений для отметки как прочитанных: (chat_id, message_ids).
pub pending_view_messages: Vec<(ChatId, Vec<MessageId>)>,
/// ID клиента TDLib для API вызовов.
client_id: i32,
}
impl MessageManager { impl MessageManager {
/// Создает новый менеджер сообщений.
///
/// # Arguments
///
/// * `client_id` - ID клиента TDLib для API вызовов
///
/// # Returns
///
/// Новый экземпляр `MessageManager` с пустым списком сообщений.
pub fn new(client_id: i32) -> Self {
Self {
current_chat_messages: Vec::new(),
current_chat_id: None,
current_pinned_message: None,
pending_view_messages: Vec::new(),
client_id,
}
}
/// Добавляет сообщение в список текущего чата.
///
/// Автоматически ограничивает размер списка до [`MAX_MESSAGES_IN_CHAT`],
/// удаляя старые сообщения при превышении лимита.
///
/// # Arguments
///
/// * `msg` - Сообщение для добавления
///
/// # Note
///
/// Сообщение добавляется в конец списка. При превышении лимита
/// удаляются самые старые сообщения из начала списка.
pub fn push_message(&mut self, msg: MessageInfo) {
self.current_chat_messages.push(msg); // Добавляем в конец
// Ограничиваем размер списка (удаляем старые с начала)
if self.current_chat_messages.len() > MAX_MESSAGES_IN_CHAT {
self.current_chat_messages.drain(0..(self.current_chat_messages.len() - MAX_MESSAGES_IN_CHAT));
}
}
/// Загружает историю сообщений чата с динамической подгрузкой. /// Загружает историю сообщений чата с динамической подгрузкой.
/// ///
/// Загружает сообщения чанками, ожидая пока TDLib синхронизирует их с сервера. /// Загружает сообщения чанками, ожидая пока TDLib синхронизирует их с сервера.
@@ -172,7 +90,7 @@ impl MessageManager {
}; };
let received_count = messages_obj.messages.len(); let received_count = messages_obj.messages.len();
// Если получили пустой результат // Если получили пустой результат
if messages_obj.messages.is_empty() { if messages_obj.messages.is_empty() {
consecutive_empty_results += 1; consecutive_empty_results += 1;
@@ -183,15 +101,16 @@ impl MessageManager {
// Пробуем еще раз // Пробуем еще раз
continue; continue;
} }
// Получили сообщения - сбрасываем счетчик // Получили сообщения - сбрасываем счетчик
consecutive_empty_results = 0; consecutive_empty_results = 0;
// Если это первая загрузка и получили мало сообщений - продолжаем попытки // Если это первая загрузка и получили мало сообщений - продолжаем попытки
// TDLib может подгружать данные с сервера постепенно // TDLib может подгружать данные с сервера постепенно
if all_messages.is_empty() && if all_messages.is_empty()
received_count < (chunk_size as usize) && && received_count < (chunk_size as usize)
attempt < max_attempts_per_chunk { && attempt < max_attempts_per_chunk
{
// Даём TDLib время на синхронизацию с сервером // Даём TDLib время на синхронизацию с сервером
sleep(Duration::from_millis(100)).await; sleep(Duration::from_millis(100)).await;
continue; continue;
@@ -212,7 +131,7 @@ impl MessageManager {
if !chunk_messages.is_empty() { if !chunk_messages.is_empty() {
// Для следующей итерации: ID самого старого сообщения из текущего чанка // Для следующей итерации: ID самого старого сообщения из текущего чанка
from_message_id = chunk_messages[0].id().as_i64(); from_message_id = chunk_messages[0].id().as_i64();
// ВАЖНО: Вставляем чанк В НАЧАЛО списка! // ВАЖНО: Вставляем чанк В НАЧАЛО списка!
// Первый чанк содержит НОВЫЕ сообщения (например 51-100) // Первый чанк содержит НОВЫЕ сообщения (например 51-100)
// Второй чанк содержит СТАРЫЕ сообщения (например 1-50) // Второй чанк содержит СТАРЫЕ сообщения (например 1-50)
@@ -224,7 +143,7 @@ impl MessageManager {
// Последующие чанки - вставляем в начало // Последующие чанки - вставляем в начало
all_messages.splice(0..0, chunk_messages); all_messages.splice(0..0, chunk_messages);
} }
chunk_loaded = true; chunk_loaded = true;
} }
@@ -241,7 +160,7 @@ impl MessageManager {
break; break;
} }
} }
Ok(all_messages) Ok(all_messages)
} }
@@ -287,11 +206,9 @@ impl MessageManager {
match result { match result {
Ok(tdlib_rs::enums::Messages::Messages(messages_obj)) => { Ok(tdlib_rs::enums::Messages::Messages(messages_obj)) => {
let mut messages = Vec::new(); let mut messages = Vec::new();
for msg_opt in messages_obj.messages.iter().rev() { for msg in messages_obj.messages.iter().rev().flatten() {
if let Some(msg) = msg_opt { if let Some(info) = self.convert_message(msg).await {
if let Some(info) = self.convert_message(msg).await { messages.push(info);
messages.push(info);
}
} }
} }
Ok(messages) Ok(messages)
@@ -319,17 +236,20 @@ impl MessageManager {
/// let pinned = msg_manager.get_pinned_messages(chat_id).await?; /// let pinned = msg_manager.get_pinned_messages(chat_id).await?;
/// println!("Found {} pinned messages", pinned.len()); /// println!("Found {} pinned messages", pinned.len());
/// ``` /// ```
pub async fn get_pinned_messages(&mut self, chat_id: ChatId) -> Result<Vec<MessageInfo>, String> { pub async fn get_pinned_messages(
&mut self,
chat_id: ChatId,
) -> Result<Vec<MessageInfo>, String> {
let result = functions::search_chat_messages( let result = functions::search_chat_messages(
chat_id.as_i64(), chat_id.as_i64(),
String::new(), String::new(),
None, None,
0, // from_message_id 0, // from_message_id
0, // offset 0, // offset
100, // limit 100, // limit
Some(SearchMessagesFilter::Pinned), Some(SearchMessagesFilter::Pinned),
0, // message_thread_id 0, // message_thread_id
0, // saved_messages_topic_id 0, // saved_messages_topic_id
self.client_id, self.client_id,
) )
.await; .await;
@@ -364,13 +284,6 @@ impl MessageManager {
// Нужно использовать getChatPinnedMessage или альтернативный способ. // Нужно использовать getChatPinnedMessage или альтернативный способ.
// Временно отключено. // Временно отключено.
self.current_pinned_message = None; self.current_pinned_message = None;
// match functions::get_chat(chat_id, self.client_id).await {
// Ok(tdlib_rs::enums::Chat::Chat(chat)) => {
// // chat.pinned_message_id больше не существует
// }
// _ => {}
// }
} }
/// Выполняет поиск сообщений по тексту в указанном чате. /// Выполняет поиск сообщений по тексту в указанном чате.
@@ -403,8 +316,8 @@ impl MessageManager {
0, // offset 0, // offset
100, // limit 100, // limit
None, None,
0, // message_thread_id 0, // message_thread_id
0, // saved_messages_topic_id 0, // saved_messages_topic_id
self.client_id, self.client_id,
) )
.await; .await;
@@ -474,15 +387,9 @@ impl MessageManager {
.await .await
{ {
Ok(tdlib_rs::enums::FormattedText::FormattedText(ft)) => { Ok(tdlib_rs::enums::FormattedText::FormattedText(ft)) => {
FormattedText { FormattedText { text: ft.text, entities: ft.entities }
text: ft.text,
entities: ft.entities,
}
} }
Err(_) => FormattedText { Err(_) => FormattedText { text: text.clone(), entities: vec![] },
text: text.clone(),
entities: vec![],
},
}; };
let content = InputMessageContent::InputMessageText(InputMessageText { let content = InputMessageContent::InputMessageText(InputMessageText {
@@ -515,7 +422,7 @@ impl MessageManager {
.convert_message(&msg) .convert_message(&msg)
.await .await
.ok_or_else(|| "Не удалось конвертировать сообщение".to_string())?; .ok_or_else(|| "Не удалось конвертировать сообщение".to_string())?;
// Добавляем reply_info если был передан // Добавляем reply_info если был передан
if let Some(reply) = reply_info { if let Some(reply) = reply_info {
msg_info.interactions.reply_to = Some(reply); msg_info.interactions.reply_to = Some(reply);
@@ -553,15 +460,9 @@ impl MessageManager {
.await .await
{ {
Ok(tdlib_rs::enums::FormattedText::FormattedText(ft)) => { Ok(tdlib_rs::enums::FormattedText::FormattedText(ft)) => {
FormattedText { FormattedText { text: ft.text, entities: ft.entities }
text: ft.text,
entities: ft.entities,
}
} }
Err(_) => FormattedText { Err(_) => FormattedText { text: text.clone(), entities: vec![] },
text: text.clone(),
entities: vec![],
},
}; };
let content = InputMessageContent::InputMessageText(InputMessageText { let content = InputMessageContent::InputMessageText(InputMessageText {
@@ -570,8 +471,13 @@ impl MessageManager {
clear_draft: true, clear_draft: true,
}); });
let result = let result = functions::edit_message_text(
functions::edit_message_text(chat_id.as_i64(), message_id.as_i64(), content, self.client_id).await; chat_id.as_i64(),
message_id.as_i64(),
content,
self.client_id,
)
.await;
match result { match result {
Ok(tdlib_rs::enums::Message::Message(msg)) => self Ok(tdlib_rs::enums::Message::Message(msg)) => self
@@ -602,7 +508,8 @@ impl MessageManager {
) -> Result<(), String> { ) -> Result<(), String> {
let message_ids_i64: Vec<i64> = message_ids.into_iter().map(|id| id.as_i64()).collect(); let message_ids_i64: Vec<i64> = message_ids.into_iter().map(|id| id.as_i64()).collect();
let result = let result =
functions::delete_messages(chat_id.as_i64(), message_ids_i64, revoke, self.client_id).await; functions::delete_messages(chat_id.as_i64(), message_ids_i64, revoke, self.client_id)
.await;
match result { match result {
Ok(_) => Ok(()), Ok(_) => Ok(()),
Err(e) => Err(format!("Ошибка удаления: {:?}", e)), Err(e) => Err(format!("Ошибка удаления: {:?}", e)),
@@ -670,17 +577,15 @@ impl MessageManager {
reply_to: None, reply_to: None,
date: 0, date: 0,
input_message_text: InputMessageContent::InputMessageText(InputMessageText { input_message_text: InputMessageContent::InputMessageText(InputMessageText {
text: FormattedText { text: FormattedText { text: text.clone(), entities: vec![] },
text: text.clone(),
entities: vec![],
},
link_preview_options: None, link_preview_options: None,
clear_draft: false, clear_draft: false,
}), }),
}) })
}; };
let result = functions::set_chat_draft_message(chat_id.as_i64(), 0, draft, self.client_id).await; let result =
functions::set_chat_draft_message(chat_id.as_i64(), 0, draft, self.client_id).await;
match result { match result {
Ok(_) => Ok(()), Ok(_) => Ok(()),
@@ -705,132 +610,8 @@ impl MessageManager {
for (chat_id, message_ids) in batch { for (chat_id, message_ids) in batch {
let ids: Vec<i64> = message_ids.iter().map(|id| id.as_i64()).collect(); let ids: Vec<i64> = message_ids.iter().map(|id| id.as_i64()).collect();
let _ = functions::view_messages(chat_id.as_i64(), ids, None, true, self.client_id).await; let _ =
functions::view_messages(chat_id.as_i64(), ids, None, true, self.client_id).await;
} }
} }
/// Конвертировать TdMessage в MessageInfo
async fn convert_message(&self, msg: &TdMessage) -> Option<MessageInfo> {
use crate::tdlib::message_conversion::{
extract_content_text, extract_entities, extract_forward_info,
extract_reactions, extract_reply_info, extract_sender_name,
};
// Извлекаем все части сообщения используя вспомогательные функции
let content_text = extract_content_text(msg);
let entities = extract_entities(msg);
let sender_name = extract_sender_name(msg, self.client_id).await;
let forward_from = extract_forward_info(msg);
let reply_to = extract_reply_info(msg);
let reactions = extract_reactions(msg);
let mut builder = MessageBuilder::new(MessageId::new(msg.id))
.sender_name(sender_name)
.text(content_text)
.entities(entities)
.date(msg.date)
.edit_date(msg.edit_date);
if msg.is_outgoing {
builder = builder.outgoing();
} else {
builder = builder.incoming();
}
if !msg.contains_unread_mention {
builder = builder.read();
} else {
builder = builder.unread();
}
if msg.can_be_edited {
builder = builder.editable();
}
if msg.can_be_deleted_only_for_self {
builder = builder.deletable_for_self();
}
if msg.can_be_deleted_for_all_users {
builder = builder.deletable_for_all();
}
if let Some(reply) = reply_to {
builder = builder.reply_to(reply);
}
if let Some(forward) = forward_from {
builder = builder.forward_from(forward);
}
builder = builder.reactions(reactions);
Some(builder.build())
}
/// Загружает недостающую информацию об исходных сообщениях для ответов.
///
/// Ищет все reply-сообщения с `sender_name == "Unknown"` и загружает
/// полную информацию (имя отправителя, текст) из TDLib.
///
/// # Note
///
/// Вызывайте после загрузки истории чата для заполнения информации о цитируемых сообщениях.
pub async fn fetch_missing_reply_info(&mut self) {
// Early return if no chat selected
let Some(chat_id) = self.current_chat_id else {
return;
};
// Collect message IDs with missing reply info using filter_map
let to_fetch: Vec<MessageId> = self
.current_chat_messages
.iter()
.filter_map(|msg| {
msg.interactions
.reply_to
.as_ref()
.filter(|reply| reply.sender_name == "Unknown")
.map(|reply| reply.message_id)
})
.collect();
// Fetch and update each missing message
for message_id in to_fetch {
self.fetch_and_update_reply(chat_id, message_id).await;
}
}
/// Загружает одно сообщение и обновляет reply информацию.
async fn fetch_and_update_reply(&mut self, chat_id: ChatId, message_id: MessageId) {
// Try to fetch the original message
let Ok(original_msg_enum) =
functions::get_message(chat_id.as_i64(), message_id.as_i64(), self.client_id).await
else {
return;
};
let tdlib_rs::enums::Message::Message(original_msg) = original_msg_enum;
let Some(orig_info) = self.convert_message(&original_msg).await else {
return;
};
// Extract text preview (first 50 chars)
let text_preview: String = orig_info
.content
.text
.chars()
.take(50)
.collect();
// Update reply info in all messages that reference this message
self.current_chat_messages
.iter_mut()
.filter_map(|msg| msg.interactions.reply_to.as_mut())
.filter(|reply| reply.message_id == message_id)
.for_each(|reply| {
reply.sender_name = orig_info.metadata.sender_name.clone();
reply.text = text_preview.clone();
});
}
} }

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,9 +17,15 @@ pub mod users;
pub use auth::AuthState; pub use auth::AuthState;
pub use client::TdClient; pub use client::TdClient;
pub use r#trait::TdClientTrait; pub use r#trait::TdClientTrait;
#[allow(unused_imports)]
pub use types::{ pub use types::{
ChatInfo, FolderInfo, MessageBuilder, MessageInfo, NetworkState, ProfileInfo, ReplyInfo, UserOnlineStatus, ChatInfo, FolderInfo, MediaInfo, MessageBuilder, MessageInfo, NetworkState, PhotoDownloadState,
PhotoInfo, PlaybackState, PlaybackStatus, ProfileInfo, ReplyInfo, UserOnlineStatus,
VoiceDownloadState, VoiceInfo,
}; };
#[cfg(feature = "images")]
pub use types::ImageModalState;
pub use users::UserCache; pub use users::UserCache;
// Re-export ChatAction для удобства // Re-export ChatAction для удобства

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

@@ -5,6 +5,7 @@
use crate::tdlib::{AuthState, FolderInfo, MessageInfo, ProfileInfo, UserCache, UserOnlineStatus}; use crate::tdlib::{AuthState, FolderInfo, MessageInfo, ProfileInfo, UserCache, UserOnlineStatus};
use crate::types::{ChatId, MessageId, UserId}; use crate::types::{ChatId, MessageId, UserId};
use async_trait::async_trait; use async_trait::async_trait;
use std::path::PathBuf;
use tdlib_rs::enums::{ChatAction, Update}; use tdlib_rs::enums::{ChatAction, Update};
use super::ChatInfo; use super::ChatInfo;
@@ -13,6 +14,7 @@ use super::ChatInfo;
/// ///
/// This trait defines the interface for both real and fake TDLib clients, /// This trait defines the interface for both real and fake TDLib clients,
/// enabling dependency injection and easier testing. /// enabling dependency injection and easier testing.
#[allow(dead_code)]
#[async_trait] #[async_trait]
pub trait TdClientTrait: Send { pub trait TdClientTrait: Send {
// ============ Auth methods ============ // ============ Auth methods ============
@@ -31,11 +33,23 @@ pub trait TdClientTrait: Send {
fn clear_stale_typing_status(&mut self) -> bool; fn clear_stale_typing_status(&mut self) -> bool;
// ============ Message methods ============ // ============ Message methods ============
async fn get_chat_history(&mut self, chat_id: ChatId, limit: i32) -> Result<Vec<MessageInfo>, String>; async fn get_chat_history(
async fn load_older_messages(&mut self, chat_id: ChatId, from_message_id: MessageId) -> Result<Vec<MessageInfo>, String>; &mut self,
chat_id: ChatId,
limit: i32,
) -> Result<Vec<MessageInfo>, String>;
async fn load_older_messages(
&mut self,
chat_id: ChatId,
from_message_id: MessageId,
) -> Result<Vec<MessageInfo>, String>;
async fn get_pinned_messages(&mut self, chat_id: ChatId) -> Result<Vec<MessageInfo>, String>; async fn get_pinned_messages(&mut self, chat_id: ChatId) -> Result<Vec<MessageInfo>, String>;
async fn load_current_pinned_message(&mut self, chat_id: ChatId); async fn load_current_pinned_message(&mut self, chat_id: ChatId);
async fn search_messages(&self, chat_id: ChatId, query: &str) -> Result<Vec<MessageInfo>, String>; async fn search_messages(
&self,
chat_id: ChatId,
query: &str,
) -> Result<Vec<MessageInfo>, String>;
async fn send_message( async fn send_message(
&mut self, &mut self,
@@ -90,6 +104,10 @@ pub trait TdClientTrait: Send {
reaction: String, reaction: String,
) -> Result<(), String>; ) -> Result<(), String>;
// ============ File methods ============
async fn download_file(&self, file_id: i32) -> Result<String, String>;
async fn download_voice_note(&self, file_id: i32) -> Result<String, String>;
// ============ Getters (immutable) ============ // ============ Getters (immutable) ============
fn client_id(&self) -> i32; fn client_id(&self) -> i32;
async fn get_me(&self) -> Result<i64, String>; async fn get_me(&self) -> Result<i64, String>;
@@ -123,6 +141,13 @@ pub trait TdClientTrait: Send {
// ============ Notification methods ============ // ============ Notification methods ============
fn sync_notification_muted_chats(&mut self); fn sync_notification_muted_chats(&mut self);
// ============ Account switching ============
/// Recreates the client with a new database path (for account switching).
///
/// For real TdClient: closes old client, creates new one, inits TDLib parameters.
/// For FakeTdClient: no-op.
async fn recreate_client(&mut self, db_path: PathBuf) -> Result<(), String>;
// ============ Update handling ============ // ============ Update handling ============
fn handle_update(&mut self, update: Update); fn handle_update(&mut self, update: Update);
} }

View File

@@ -54,6 +54,54 @@ pub struct ReactionInfo {
pub is_chosen: bool, pub is_chosen: bool,
} }
/// Информация о медиа-контенте сообщения
#[derive(Debug, Clone)]
pub enum MediaInfo {
Photo(PhotoInfo),
Voice(VoiceInfo),
}
/// Информация о фотографии в сообщении
#[derive(Debug, Clone)]
pub struct PhotoInfo {
pub file_id: i32,
pub width: i32,
pub height: i32,
pub download_state: PhotoDownloadState,
}
/// Состояние загрузки фотографии
#[allow(dead_code)]
#[derive(Debug, Clone)]
pub enum PhotoDownloadState {
NotDownloaded,
Downloading,
Downloaded(String),
Error(String),
}
/// Информация о голосовом сообщении
#[allow(dead_code)]
#[derive(Debug, Clone)]
pub struct VoiceInfo {
pub file_id: i32,
pub duration: i32, // seconds
pub mime_type: String,
/// Waveform данные для визуализации (base64-encoded строка амплитуд)
pub waveform: String,
pub download_state: VoiceDownloadState,
}
/// Состояние загрузки голосового сообщения
#[allow(dead_code)]
#[derive(Debug, Clone)]
pub enum VoiceDownloadState {
NotDownloaded,
Downloading,
Downloaded(String), // path to cached OGG file
Error(String),
}
/// Метаданные сообщения (ID, отправитель, время) /// Метаданные сообщения (ID, отправитель, время)
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct MessageMetadata { pub struct MessageMetadata {
@@ -62,14 +110,18 @@ pub struct MessageMetadata {
pub date: i32, pub date: i32,
/// Дата редактирования (0 если не редактировалось) /// Дата редактирования (0 если не редактировалось)
pub edit_date: i32, pub edit_date: i32,
/// ID медиа-альбома (0 если не часть альбома)
pub media_album_id: i64,
} }
/// Контент сообщения (текст и форматирование) /// Контент сообщения (текст и форматирование)
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone)]
pub struct MessageContent { pub struct MessageContent {
pub text: String, pub text: String,
/// Сущности форматирования (bold, italic, code и т.д.) /// Сущности форматирования (bold, italic, code и т.д.)
pub entities: Vec<TextEntity>, pub entities: Vec<TextEntity>,
/// Медиа-контент (фото, видео и т.д.)
pub media: Option<MediaInfo>,
} }
/// Состояние и права доступа к сообщению /// Состояние и права доступа к сообщению
@@ -106,6 +158,7 @@ pub struct MessageInfo {
impl MessageInfo { impl MessageInfo {
/// Создать новое сообщение /// Создать новое сообщение
#[allow(clippy::too_many_arguments)]
pub fn new( pub fn new(
id: MessageId, id: MessageId,
sender_name: String, sender_name: String,
@@ -128,11 +181,9 @@ impl MessageInfo {
sender_name, sender_name,
date, date,
edit_date, edit_date,
media_album_id: 0,
}, },
content: MessageContent { content: MessageContent { text: content, entities, media: None },
text: content,
entities,
},
state: MessageState { state: MessageState {
is_outgoing, is_outgoing,
is_read, is_read,
@@ -140,11 +191,7 @@ impl MessageInfo {
can_be_deleted_only_for_self, can_be_deleted_only_for_self,
can_be_deleted_for_all_users, can_be_deleted_for_all_users,
}, },
interactions: MessageInteractions { interactions: MessageInteractions { reply_to, forward_from, reactions },
reply_to,
forward_from,
reactions,
},
} }
} }
@@ -165,6 +212,10 @@ impl MessageInfo {
self.metadata.edit_date > 0 self.metadata.edit_date > 0
} }
pub fn media_album_id(&self) -> i64 {
self.metadata.media_album_id
}
pub fn text(&self) -> &str { pub fn text(&self) -> &str {
&self.content.text &self.content.text
} }
@@ -196,13 +247,53 @@ impl MessageInfo {
/// Checks if the message contains a mention (@username or user mention) /// Checks if the message contains a mention (@username or user mention)
pub fn has_mention(&self) -> bool { pub fn has_mention(&self) -> bool {
self.content.entities.iter().any(|entity| { self.content.entities.iter().any(|entity| {
matches!( matches!(entity.r#type, TextEntityType::Mention | TextEntityType::MentionName(_))
entity.r#type,
TextEntityType::Mention | TextEntityType::MentionName(_)
)
}) })
} }
/// Проверяет, содержит ли сообщение фото
pub fn has_photo(&self) -> bool {
matches!(self.content.media, Some(MediaInfo::Photo(_)))
}
/// Возвращает ссылку на PhotoInfo (если есть)
pub fn photo_info(&self) -> Option<&PhotoInfo> {
match &self.content.media {
Some(MediaInfo::Photo(info)) => Some(info),
_ => None,
}
}
/// Возвращает мутабельную ссылку на PhotoInfo (если есть)
pub fn photo_info_mut(&mut self) -> Option<&mut PhotoInfo> {
match &mut self.content.media {
Some(MediaInfo::Photo(info)) => Some(info),
_ => None,
}
}
/// Проверяет, содержит ли сообщение голосовое
pub fn has_voice(&self) -> bool {
matches!(self.content.media, Some(MediaInfo::Voice(_)))
}
/// Возвращает ссылку на VoiceInfo (если есть)
pub fn voice_info(&self) -> Option<&VoiceInfo> {
match &self.content.media {
Some(MediaInfo::Voice(info)) => Some(info),
_ => None,
}
}
/// Возвращает мутабельную ссылку на VoiceInfo (если есть)
#[allow(dead_code)]
pub fn voice_info_mut(&mut self) -> Option<&mut VoiceInfo> {
match &mut self.content.media {
Some(MediaInfo::Voice(info)) => Some(info),
_ => None,
}
}
pub fn reply_to(&self) -> Option<&ReplyInfo> { pub fn reply_to(&self) -> Option<&ReplyInfo> {
self.interactions.reply_to.as_ref() self.interactions.reply_to.as_ref()
} }
@@ -217,13 +308,13 @@ impl MessageInfo {
} }
/// Builder для удобного создания MessageInfo с fluent API /// Builder для удобного создания MessageInfo с fluent API
/// ///
/// # Примеры /// # Примеры
/// ///
/// ``` /// ```
/// use tele_tui::tdlib::MessageBuilder; /// use tele_tui::tdlib::MessageBuilder;
/// use tele_tui::types::MessageId; /// use tele_tui::types::MessageId;
/// ///
/// let message = MessageBuilder::new(MessageId::new(123)) /// let message = MessageBuilder::new(MessageId::new(123))
/// .sender_name("Alice") /// .sender_name("Alice")
/// .text("Hello, world!") /// .text("Hello, world!")
@@ -246,6 +337,8 @@ pub struct MessageBuilder {
reply_to: Option<ReplyInfo>, reply_to: Option<ReplyInfo>,
forward_from: Option<ForwardInfo>, forward_from: Option<ForwardInfo>,
reactions: Vec<ReactionInfo>, reactions: Vec<ReactionInfo>,
media: Option<MediaInfo>,
media_album_id: i64,
} }
impl MessageBuilder { impl MessageBuilder {
@@ -266,6 +359,8 @@ impl MessageBuilder {
reply_to: None, reply_to: None,
forward_from: None, forward_from: None,
reactions: Vec::new(), reactions: Vec::new(),
media: None,
media_album_id: 0,
} }
} }
@@ -363,9 +458,21 @@ impl MessageBuilder {
self self
} }
/// Установить медиа-контент
pub fn media(mut self, media: MediaInfo) -> Self {
self.media = Some(media);
self
}
/// Установить ID медиа-альбома
pub fn media_album_id(mut self, id: i64) -> Self {
self.media_album_id = id;
self
}
/// Построить MessageInfo из данных builder'а /// Построить MessageInfo из данных builder'а
pub fn build(self) -> MessageInfo { pub fn build(self) -> MessageInfo {
MessageInfo::new( let mut msg = MessageInfo::new(
self.id, self.id,
self.sender_name, self.sender_name,
self.is_outgoing, self.is_outgoing,
@@ -380,11 +487,13 @@ impl MessageBuilder {
self.reply_to, self.reply_to,
self.forward_from, self.forward_from,
self.reactions, self.reactions,
) );
msg.content.media = self.media;
msg.metadata.media_album_id = self.media_album_id;
msg
} }
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -452,9 +561,7 @@ mod tests {
#[test] #[test]
fn test_message_builder_with_reactions() { fn test_message_builder_with_reactions() {
let reaction = ReactionInfo { let reaction = ReactionInfo {
emoji: "👍".to_string(), emoji: "👍".to_string(), count: 5, is_chosen: true
count: 5,
is_chosen: true,
}; };
let message = MessageBuilder::new(MessageId::new(300)) let message = MessageBuilder::new(MessageId::new(300))
@@ -512,9 +619,9 @@ mod tests {
.entities(vec![TextEntity { .entities(vec![TextEntity {
offset: 6, offset: 6,
length: 4, length: 4,
r#type: TextEntityType::MentionName( r#type: TextEntityType::MentionName(tdlib_rs::types::TextEntityTypeMentionName {
tdlib_rs::types::TextEntityTypeMentionName { user_id: 123 }, user_id: 123,
), }),
}]) }])
.build(); .build();
assert!(message_with_mention_name.has_mention()); assert!(message_with_mention_name.has_mention());
@@ -574,3 +681,44 @@ pub enum UserOnlineStatus {
/// Оффлайн с указанием времени (unix timestamp) /// Оффлайн с указанием времени (unix timestamp)
Offline(i32), Offline(i32),
} }
/// Состояние модального окна для просмотра изображения
#[cfg(feature = "images")]
#[derive(Debug, Clone)]
pub struct ImageModalState {
/// ID сообщения с фото
pub message_id: MessageId,
/// Путь к файлу изображения
pub photo_path: String,
/// Ширина оригинального изображения
pub photo_width: i32,
/// Высота оригинального изображения
pub photo_height: i32,
}
/// Состояние воспроизведения голосового сообщения
#[allow(dead_code)]
#[derive(Debug, Clone)]
pub struct PlaybackState {
/// ID сообщения, которое воспроизводится
pub message_id: MessageId,
/// Статус воспроизведения
pub status: PlaybackStatus,
/// Текущая позиция (секунды)
pub position: f32,
/// Общая длительность (секунды)
pub duration: f32,
/// Громкость (0.0 - 1.0)
pub volume: f32,
}
/// Статус воспроизведения
#[allow(dead_code)]
#[derive(Debug, Clone, PartialEq)]
pub enum PlaybackStatus {
Playing,
Paused,
Stopped,
Loading,
Error(String),
}

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,4 +1,6 @@
/// Type-safe ID wrappers to prevent mixing up different ID types //! Type-safe ID wrappers to prevent mixing up different ID types.
//!
//! Provides `ChatId` and `MessageId` newtypes for compile-time safety.
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::fmt; use std::fmt;
@@ -134,7 +136,7 @@ mod tests {
// let chat_id = ChatId::new(1); // let chat_id = ChatId::new(1);
// let message_id = MessageId::new(1); // let message_id = MessageId::new(1);
// if chat_id == message_id { } // ERROR: mismatched types // if chat_id == message_id { } // ERROR: mismatched types
// Runtime values can be the same, but types are different // Runtime values can be the same, but types are different
let chat_id = ChatId::new(1); let chat_id = ChatId::new(1);
let message_id = MessageId::new(1); let message_id = MessageId::new(1);

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,3 +1,6 @@
//! Chat list panel: search box, chat items, and user online status.
use crate::app::methods::{compose::ComposeMethods, search::SearchMethods};
use crate::app::App; use crate::app::App;
use crate::tdlib::TdClientTrait; use crate::tdlib::TdClientTrait;
use crate::tdlib::UserOnlineStatus; use crate::tdlib::UserOnlineStatus;
@@ -32,7 +35,7 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &mut App<T>) {
let search_style = if app.is_searching { let search_style = if app.is_searching {
Style::default().fg(Color::Yellow) Style::default().fg(Color::Yellow)
} else { } else {
Style::default().fg(Color::DarkGray) Style::default().fg(Color::Rgb(160, 160, 160))
}; };
let search = Paragraph::new(search_text) let search = Paragraph::new(search_text)
.block(Block::default().borders(Borders::ALL)) .block(Block::default().borders(Borders::ALL))
@@ -68,55 +71,18 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &mut App<T>) {
f.render_stateful_widget(chats_list, chat_chunks[1], &mut app.chat_list_state); f.render_stateful_widget(chats_list, chat_chunks[1], &mut app.chat_list_state);
// User status - показываем статус выбранного чата // User status - показываем статус выбранного или выделенного чата
let (status_text, status_color) = if let Some(chat_id) = app.selected_chat_id { let status_chat_id = if app.selected_chat_id.is_some() {
match app.td_client.get_user_status_by_chat_id(chat_id) { app.selected_chat_id
Some(UserOnlineStatus::Online) => ("● онлайн".to_string(), Color::Green),
Some(UserOnlineStatus::Recently) => ("был(а) недавно".to_string(), Color::Yellow),
Some(UserOnlineStatus::Offline(was_online)) => {
let formatted = format_was_online(*was_online);
(formatted, Color::Gray)
}
Some(UserOnlineStatus::LastWeek) => {
("был(а) на этой неделе".to_string(), Color::DarkGray)
}
Some(UserOnlineStatus::LastMonth) => {
("был(а) в этом месяце".to_string(), Color::DarkGray)
}
Some(UserOnlineStatus::LongTimeAgo) => ("был(а) давно".to_string(), Color::DarkGray),
None => ("".to_string(), Color::DarkGray), // Для групп/каналов
}
} else { } else {
// Показываем статус выделенного в списке чата
let filtered = app.get_filtered_chats(); let filtered = app.get_filtered_chats();
if let Some(i) = app.chat_list_state.selected() { app.chat_list_state
if let Some(chat) = filtered.get(i) { .selected()
match app.td_client.get_user_status_by_chat_id(chat.id) { .and_then(|i| filtered.get(i).map(|c| c.id))
Some(UserOnlineStatus::Online) => ("● онлайн".to_string(), Color::Green), };
Some(UserOnlineStatus::Recently) => { let (status_text, status_color) = match status_chat_id {
("был(а) недавно".to_string(), Color::Yellow) Some(chat_id) => format_user_status(app.td_client.get_user_status_by_chat_id(chat_id)),
} None => ("".to_string(), Color::DarkGray),
Some(UserOnlineStatus::Offline(was_online)) => {
let formatted = format_was_online(*was_online);
(formatted, Color::Gray)
}
Some(UserOnlineStatus::LastWeek) => {
("был(а) на этой неделе".to_string(), Color::DarkGray)
}
Some(UserOnlineStatus::LastMonth) => {
("был(а) в этом месяце".to_string(), Color::DarkGray)
}
Some(UserOnlineStatus::LongTimeAgo) => {
("был(а) давно".to_string(), Color::DarkGray)
}
None => ("".to_string(), Color::DarkGray),
}
} else {
("".to_string(), Color::DarkGray)
}
} else {
("".to_string(), Color::DarkGray)
}
}; };
let status = Paragraph::new(status_text) let status = Paragraph::new(status_text)
@@ -125,7 +91,17 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &mut App<T>) {
f.render_widget(status, chat_chunks[2]); f.render_widget(status, chat_chunks[2]);
} }
/// Форматирование времени "был(а) в ..." /// Форматирует статус пользователя для отображения в статус-баре
fn format_was_online(timestamp: i32) -> String { fn format_user_status(status: Option<&UserOnlineStatus>) -> (String, Color) {
crate::utils::format_was_online(timestamp) match status {
Some(UserOnlineStatus::Online) => ("● онлайн".to_string(), Color::Green),
Some(UserOnlineStatus::Recently) => ("был(а) недавно".to_string(), Color::Yellow),
Some(UserOnlineStatus::Offline(was_online)) => {
(crate::utils::format_was_online(*was_online), Color::Gray)
}
Some(UserOnlineStatus::LastWeek) => ("был(а) на этой неделе".to_string(), Color::DarkGray),
Some(UserOnlineStatus::LastMonth) => ("был(а) в этом месяце".to_string(), Color::DarkGray),
Some(UserOnlineStatus::LongTimeAgo) => ("был(а) давно".to_string(), Color::DarkGray),
None => ("".to_string(), Color::DarkGray),
}
} }

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,7 +7,9 @@
use crate::config::Config; use crate::config::Config;
use crate::formatting; use crate::formatting;
use crate::tdlib::MessageInfo; #[cfg(feature = "images")]
use crate::tdlib::PhotoDownloadState;
use crate::tdlib::{MessageInfo, PlaybackState, PlaybackStatus};
use crate::types::MessageId; use crate::types::MessageId;
use crate::utils::{format_date, format_timestamp_with_tz}; use crate::utils::{format_date, format_timestamp_with_tz};
use ratatui::{ use ratatui::{
@@ -22,19 +24,34 @@ struct WrappedLine {
start_offset: usize, start_offset: usize,
} }
/// Разбивает текст на строки с учётом максимальной ширины /// Разбивает текст на строки с учётом максимальной ширины и `\n`
fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> { fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
let mut all_lines = Vec::new();
let mut char_offset = 0;
for segment in text.split('\n') {
let wrapped = wrap_paragraph(segment, max_width, char_offset);
all_lines.extend(wrapped);
char_offset += segment.chars().count() + 1; // +1 за '\n'
}
if all_lines.is_empty() {
all_lines.push(WrappedLine { text: String::new(), start_offset: 0 });
}
all_lines
}
/// Разбивает один абзац (без `\n`) на строки по ширине
fn wrap_paragraph(text: &str, max_width: usize, base_offset: usize) -> Vec<WrappedLine> {
if max_width == 0 { if max_width == 0 {
return vec![WrappedLine { return vec![WrappedLine { text: text.to_string(), start_offset: base_offset }];
text: text.to_string(),
start_offset: 0,
}];
} }
let mut result = Vec::new(); let mut result = Vec::new();
let mut current_line = String::new(); let mut current_line = String::new();
let mut current_width = 0; let mut current_width = 0;
let mut line_start_offset = 0; let mut line_start_offset = base_offset;
let chars: Vec<char> = text.chars().collect(); let chars: Vec<char> = text.chars().collect();
let mut word_start = 0; let mut word_start = 0;
@@ -49,7 +66,7 @@ fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
if current_width == 0 { if current_width == 0 {
current_line = word; current_line = word;
current_width = word_width; current_width = word_width;
line_start_offset = word_start; line_start_offset = base_offset + word_start;
} else if current_width + 1 + word_width <= max_width { } else if current_width + 1 + word_width <= max_width {
current_line.push(' '); current_line.push(' ');
current_line.push_str(&word); current_line.push_str(&word);
@@ -61,7 +78,7 @@ fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
}); });
current_line = word; current_line = word;
current_width = word_width; current_width = word_width;
line_start_offset = word_start; line_start_offset = base_offset + word_start;
} }
in_word = false; in_word = false;
} }
@@ -77,7 +94,7 @@ fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
if current_width == 0 { if current_width == 0 {
current_line = word; current_line = word;
line_start_offset = word_start; line_start_offset = base_offset + word_start;
} else if current_width + 1 + word_width <= max_width { } else if current_width + 1 + word_width <= max_width {
current_line.push(' '); current_line.push(' ');
current_line.push_str(&word); current_line.push_str(&word);
@@ -87,7 +104,7 @@ fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
start_offset: line_start_offset, start_offset: line_start_offset,
}); });
current_line = word; current_line = word;
line_start_offset = word_start; line_start_offset = base_offset + word_start;
} }
} }
@@ -99,10 +116,7 @@ fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
} }
if result.is_empty() { if result.is_empty() {
result.push(WrappedLine { result.push(WrappedLine { text: String::new(), start_offset: base_offset });
text: String::new(),
start_offset: 0,
});
} }
result result
@@ -115,7 +129,11 @@ fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
/// * `date` - timestamp сообщения /// * `date` - timestamp сообщения
/// * `content_width` - ширина области для центрирования /// * `content_width` - ширина области для центрирования
/// * `is_first` - первый ли это разделитель (если нет, добавляется пустая строка сверху) /// * `is_first` - первый ли это разделитель (если нет, добавляется пустая строка сверху)
pub fn render_date_separator(date: i32, content_width: usize, is_first: bool) -> Vec<Line<'static>> { pub fn render_date_separator(
date: i32,
content_width: usize,
is_first: bool,
) -> Vec<Line<'static>> {
let mut lines = Vec::new(); let mut lines = Vec::new();
if !is_first { if !is_first {
@@ -198,13 +216,14 @@ pub fn render_message_bubble(
config: &Config, config: &Config,
content_width: usize, content_width: usize,
selected_msg_id: Option<MessageId>, selected_msg_id: Option<MessageId>,
playback_state: Option<&PlaybackState>,
) -> Vec<Line<'static>> { ) -> Vec<Line<'static>> {
let mut lines = Vec::new(); let mut lines = Vec::new();
let is_selected = selected_msg_id == Some(msg.id()); let is_selected = selected_msg_id == Some(msg.id());
// Маркер выбора // Маркер выбора (всегда резервируем место для ▶, чтобы текст не сдвигался)
let selection_marker = if is_selected { "" } else { "" }; let selection_marker = if is_selected { "" } else { " " };
let marker_len = selection_marker.chars().count(); let marker_len = 2;
// Цвет сообщения // Цвет сообщения
let msg_color = if is_selected { let msg_color = if is_selected {
@@ -252,10 +271,8 @@ pub fn render_message_bubble(
Span::styled(reply_line, Style::default().fg(Color::Cyan)), Span::styled(reply_line, Style::default().fg(Color::Cyan)),
])); ]));
} else { } else {
lines.push(Line::from(vec![Span::styled( lines
reply_line, .push(Line::from(vec![Span::styled(reply_line, Style::default().fg(Color::Cyan))]));
Style::default().fg(Color::Cyan),
)]));
} }
} }
@@ -277,31 +294,49 @@ pub fn render_message_bubble(
let is_last_line = i == total_wrapped - 1; let is_last_line = i == total_wrapped - 1;
let line_len = wrapped.text.chars().count(); let line_len = wrapped.text.chars().count();
let line_entities = let line_entities = formatting::adjust_entities_for_substring(
formatting::adjust_entities_for_substring(msg.entities(), wrapped.start_offset, line_len); msg.entities(),
let formatted_spans = formatting::format_text_with_entities(&wrapped.text, &line_entities, msg_color); wrapped.start_offset,
line_len,
);
let formatted_spans =
formatting::format_text_with_entities(&wrapped.text, &line_entities, msg_color);
if is_last_line { if is_last_line {
let full_len = line_len + time_mark_len + marker_len; let full_len = line_len + time_mark_len + marker_len;
let padding = content_width.saturating_sub(full_len + 1); let padding = content_width.saturating_sub(full_len + 1);
let mut line_spans = vec![Span::raw(" ".repeat(padding))]; let mut line_spans = vec![Span::raw(" ".repeat(padding))];
if is_selected { if i == 0 {
// Первая (или единственная) строка — маркер
line_spans.push(Span::styled( line_spans.push(Span::styled(
selection_marker, selection_marker,
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD), Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)); ));
} else {
// Остальные строки multi-line — пробелы вместо маркера
line_spans.push(Span::raw(" ".repeat(marker_len)));
} }
line_spans.extend(formatted_spans); line_spans.extend(formatted_spans);
line_spans.push(Span::styled(format!(" {}", time_mark), Style::default().fg(Color::Gray))); line_spans.push(Span::styled(
format!(" {}", time_mark),
Style::default().fg(Color::Gray),
));
lines.push(Line::from(line_spans)); lines.push(Line::from(line_spans));
} else { } else {
let padding = content_width.saturating_sub(line_len + marker_len + 1); let padding = content_width.saturating_sub(line_len + marker_len + 1);
let mut line_spans = vec![Span::raw(" ".repeat(padding))]; let mut line_spans = vec![Span::raw(" ".repeat(padding))];
if i == 0 && is_selected { if i == 0 {
line_spans.push(Span::styled( line_spans.push(Span::styled(
selection_marker, selection_marker,
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD), Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)); ));
} else {
// Средние строки multi-line — пробелы вместо маркера
line_spans.push(Span::raw(" ".repeat(marker_len)));
} }
line_spans.extend(formatted_spans); line_spans.extend(formatted_spans);
lines.push(Line::from(line_spans)); lines.push(Line::from(line_spans));
@@ -319,19 +354,24 @@ pub fn render_message_bubble(
for (i, wrapped) in wrapped_lines.into_iter().enumerate() { for (i, wrapped) in wrapped_lines.into_iter().enumerate() {
let line_len = wrapped.text.chars().count(); let line_len = wrapped.text.chars().count();
let line_entities = let line_entities = formatting::adjust_entities_for_substring(
formatting::adjust_entities_for_substring(msg.entities(), wrapped.start_offset, line_len); msg.entities(),
let formatted_spans = formatting::format_text_with_entities(&wrapped.text, &line_entities, msg_color); wrapped.start_offset,
line_len,
);
let formatted_spans =
formatting::format_text_with_entities(&wrapped.text, &line_entities, msg_color);
if i == 0 { if i == 0 {
let mut line_spans = vec![]; let mut line_spans = vec![];
if is_selected { line_spans.push(Span::styled(
line_spans.push(Span::styled( selection_marker,
selection_marker, Style::default()
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD), .fg(Color::Yellow)
)); .add_modifier(Modifier::BOLD),
} ));
line_spans.push(Span::styled(format!(" {}", time_str), Style::default().fg(Color::Gray))); line_spans
.push(Span::styled(format!(" {}", time_str), Style::default().fg(Color::Gray)));
line_spans.push(Span::raw(" ")); line_spans.push(Span::raw(" "));
line_spans.extend(formatted_spans); line_spans.extend(formatted_spans);
lines.push(Line::from(line_spans)); lines.push(Line::from(line_spans));
@@ -359,12 +399,10 @@ pub fn render_message_bubble(
} else { } else {
format!("[{}]", reaction.emoji) format!("[{}]", reaction.emoji)
} }
} else if reaction.count > 1 {
format!("{} {}", reaction.emoji, reaction.count)
} else { } else {
if reaction.count > 1 { reaction.emoji.clone()
format!("{} {}", reaction.emoji, reaction.count)
} else {
reaction.emoji.clone()
}
}; };
let style = if reaction.is_chosen { let style = if reaction.is_chosen {
@@ -392,5 +430,336 @@ pub fn render_message_bubble(
} }
} }
// Отображаем индикатор воспроизведения голосового
if msg.has_voice() {
if let Some(voice) = msg.voice_info() {
let is_this_playing = playback_state
.map(|ps| ps.message_id == msg.id())
.unwrap_or(false);
let status_line = if is_this_playing {
let ps = playback_state.unwrap();
let icon = match ps.status {
PlaybackStatus::Playing => "",
PlaybackStatus::Paused => "",
PlaybackStatus::Loading => "",
_ => "",
};
let bar = render_progress_bar(ps.position, ps.duration, 20);
format!("{} {} {:.0}s/{:.0}s", icon, bar, ps.position, ps.duration)
} else {
let waveform = render_waveform(&voice.waveform, 20);
format!(" {} {:.0}s", waveform, voice.duration)
};
let status_len = status_line.chars().count();
if msg.is_outgoing() {
let padding = content_width.saturating_sub(status_len + 1);
lines.push(Line::from(vec![
Span::raw(" ".repeat(padding)),
Span::styled(status_line, Style::default().fg(Color::Cyan)),
]));
} else {
lines.push(Line::from(Span::styled(status_line, Style::default().fg(Color::Cyan))));
}
}
}
// Отображаем статус фото (если есть)
#[cfg(feature = "images")]
if let Some(photo) = msg.photo_info() {
match &photo.download_state {
PhotoDownloadState::Downloading => {
let status = "📷 ⏳ Загрузка...";
if msg.is_outgoing() {
let padding = content_width.saturating_sub(status.chars().count() + 1);
lines.push(Line::from(vec![
Span::raw(" ".repeat(padding)),
Span::styled(status, Style::default().fg(Color::Yellow)),
]));
} else {
lines
.push(Line::from(Span::styled(status, Style::default().fg(Color::Yellow))));
}
}
PhotoDownloadState::Error(e) => {
let status = format!("📷 [Ошибка: {}]", e);
if msg.is_outgoing() {
let padding = content_width.saturating_sub(status.chars().count() + 1);
lines.push(Line::from(vec![
Span::raw(" ".repeat(padding)),
Span::styled(status, Style::default().fg(Color::Red)),
]));
} else {
lines.push(Line::from(Span::styled(status, Style::default().fg(Color::Red))));
}
}
PhotoDownloadState::Downloaded(_) => {
// Всегда показываем inline превью для загруженных фото
let inline_width = content_width.min(crate::constants::INLINE_IMAGE_MAX_WIDTH);
let img_height = calculate_image_height(photo.width, photo.height, inline_width);
for _ in 0..img_height {
lines.push(Line::from(""));
}
}
PhotoDownloadState::NotDownloaded => {
// Для незагруженных фото ничего не рендерим,
// текст сообщения уже содержит 📷 prefix
}
}
}
lines lines
} }
/// Информация для отложенного рендеринга изображения поверх placeholder
#[cfg(feature = "images")]
pub struct DeferredImageRender {
pub message_id: MessageId,
/// Путь к файлу изображения
pub photo_path: String,
/// Смещение в строках от начала всего списка сообщений
pub line_offset: usize,
/// Горизонтальное смещение от левого края контента (для сетки альбомов)
pub x_offset: u16,
pub width: u16,
pub height: u16,
}
/// Рендерит bubble для альбома (группы фото с общим media_album_id)
///
/// Фото отображаются в сетке (до 3 в ряд), с общей подписью и timestamp.
#[cfg(feature = "images")]
pub fn render_album_bubble(
messages: &[MessageInfo],
config: &Config,
content_width: usize,
selected_msg_id: Option<MessageId>,
) -> (Vec<Line<'static>>, Vec<DeferredImageRender>) {
use crate::constants::{
ALBUM_GRID_MAX_COLS, ALBUM_PHOTO_GAP, ALBUM_PHOTO_HEIGHT, ALBUM_PHOTO_WIDTH,
};
let mut lines: Vec<Line<'static>> = Vec::new();
let mut deferred: Vec<DeferredImageRender> = Vec::new();
let is_selected = messages.iter().any(|m| selected_msg_id == Some(m.id()));
let is_outgoing = messages.first().is_some_and(|m| m.is_outgoing());
// Selection marker (всегда резервируем место)
let selection_marker = if is_selected { "" } else { " " };
// Фильтруем фото
let photos: Vec<&MessageInfo> = messages.iter().filter(|m| m.has_photo()).collect();
let photo_count = photos.len();
if photo_count == 0 {
// Нет фото — рендерим как обычные сообщения
for msg in messages {
lines.extend(render_message_bubble(msg, config, content_width, selected_msg_id, None));
}
return (lines, deferred);
}
// Grid layout
let cols = photo_count.min(ALBUM_GRID_MAX_COLS);
let rows = photo_count.div_ceil(cols);
// Добавляем маркер выбора на первую строку (всегда — для постоянного отступа)
lines.push(Line::from(vec![Span::styled(
selection_marker,
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)]));
let grid_start_line = lines.len();
// Генерируем placeholder-строки для сетки
for row in 0..rows {
for line_in_row in 0..ALBUM_PHOTO_HEIGHT {
let mut spans = Vec::new();
// Для исходящих — добавляем отступ справа
if is_outgoing {
let grid_width = cols as u16 * ALBUM_PHOTO_WIDTH
+ (cols as u16).saturating_sub(1) * ALBUM_PHOTO_GAP;
let padding = content_width.saturating_sub(grid_width as usize + 1);
spans.push(Span::raw(" ".repeat(padding)));
}
// Для каждого столбца в этом ряду
for col in 0..cols {
let photo_idx = row * cols + col;
if photo_idx >= photo_count {
break;
}
let msg = photos[photo_idx];
if let Some(photo) = msg.photo_info() {
match &photo.download_state {
PhotoDownloadState::Downloaded(path) => {
if line_in_row == 0 {
// Регистрируем deferred render для этого фото
let x_off = if is_outgoing {
let grid_width = cols as u16 * ALBUM_PHOTO_WIDTH
+ (cols as u16).saturating_sub(1) * ALBUM_PHOTO_GAP;
let padding = content_width
.saturating_sub(grid_width as usize + 1)
as u16;
padding + col as u16 * (ALBUM_PHOTO_WIDTH + ALBUM_PHOTO_GAP)
} else {
col as u16 * (ALBUM_PHOTO_WIDTH + ALBUM_PHOTO_GAP)
};
deferred.push(DeferredImageRender {
message_id: msg.id(),
photo_path: path.clone(),
line_offset: grid_start_line
+ row * ALBUM_PHOTO_HEIGHT as usize,
x_offset: x_off,
width: ALBUM_PHOTO_WIDTH,
height: ALBUM_PHOTO_HEIGHT,
});
}
// Пустая строка — placeholder для изображения
}
PhotoDownloadState::Downloading => {
if line_in_row == ALBUM_PHOTO_HEIGHT / 2 {
spans.push(Span::styled(
"⏳ Загрузка...",
Style::default().fg(Color::Yellow),
));
}
}
PhotoDownloadState::Error(e) => {
if line_in_row == ALBUM_PHOTO_HEIGHT / 2 {
let err_text: String = e.chars().take(14).collect();
spans.push(Span::styled(
format!("{}", err_text),
Style::default().fg(Color::Red),
));
}
}
PhotoDownloadState::NotDownloaded => {
if line_in_row == ALBUM_PHOTO_HEIGHT / 2 {
spans.push(Span::styled("📷", Style::default().fg(Color::Gray)));
}
}
}
}
}
lines.push(Line::from(spans));
}
}
// Caption: собираем непустые тексты (без "📷 [Фото]" prefix)
let captions: Vec<&str> = messages
.iter()
.map(|m| m.text())
.filter(|t| !t.is_empty() && !t.starts_with("📷"))
.collect();
let msg_color = if is_selected {
config.parse_color(&config.colors.selected_message)
} else if is_outgoing {
config.parse_color(&config.colors.outgoing_message)
} else {
config.parse_color(&config.colors.incoming_message)
};
// Timestamp из последнего сообщения
let last_msg = messages.last().unwrap();
let time = format_timestamp_with_tz(last_msg.date(), &config.general.timezone);
if !captions.is_empty() {
let caption_text = captions.join(" ");
let time_suffix = format!(" ({})", time);
if is_outgoing {
let total_len = caption_text.chars().count() + time_suffix.chars().count();
let padding = content_width.saturating_sub(total_len + 1);
lines.push(Line::from(vec![
Span::raw(" ".repeat(padding)),
Span::styled(caption_text, Style::default().fg(msg_color)),
Span::styled(time_suffix, Style::default().fg(Color::Gray)),
]));
} else {
lines.push(Line::from(vec![
Span::styled(format!(" ({})", time), Style::default().fg(Color::Gray)),
Span::raw(" "),
Span::styled(caption_text, Style::default().fg(msg_color)),
]));
}
} else {
// Без подписи — только timestamp
let time_text = format!("({})", time);
if is_outgoing {
let padding = content_width.saturating_sub(time_text.chars().count() + 1);
lines.push(Line::from(vec![
Span::raw(" ".repeat(padding)),
Span::styled(time_text, Style::default().fg(Color::Gray)),
]));
} else {
lines.push(Line::from(vec![Span::styled(
format!(" {}", time_text),
Style::default().fg(Color::Gray),
)]));
}
}
(lines, deferred)
}
/// Вычисляет высоту изображения (в строках) с учётом пропорций
#[cfg(feature = "images")]
pub fn calculate_image_height(img_width: i32, img_height: i32, content_width: usize) -> u16 {
use crate::constants::{MAX_IMAGE_HEIGHT, MAX_IMAGE_WIDTH, MIN_IMAGE_HEIGHT};
let display_width = (content_width as u16).min(MAX_IMAGE_WIDTH);
let aspect = img_height as f64 / img_width as f64;
// Терминальные символы ~2:1 по высоте, компенсируем
let raw_height = (display_width as f64 * aspect * 0.5) as u16;
raw_height.clamp(MIN_IMAGE_HEIGHT, MAX_IMAGE_HEIGHT)
}
/// Рендерит progress bar для воспроизведения
fn render_progress_bar(position: f32, duration: f32, width: usize) -> String {
if duration <= 0.0 {
return "".repeat(width);
}
let ratio = (position / duration).clamp(0.0, 1.0);
let filled = (ratio * width as f32) as usize;
let empty = width.saturating_sub(filled + 1);
format!("{}{}", "".repeat(filled), "".repeat(empty))
}
/// Рендерит waveform из base64-encoded данных TDLib
fn render_waveform(waveform_b64: &str, width: usize) -> String {
const BARS: &[char] = &['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
if waveform_b64.is_empty() {
return "".repeat(width);
}
// Декодируем waveform (каждый байт = амплитуда 0-255)
use base64::Engine;
let bytes = base64::engine::general_purpose::STANDARD
.decode(waveform_b64)
.unwrap_or_default();
if bytes.is_empty() {
return "".repeat(width);
}
// Сэмплируем до нужной ширины
let mut result = String::with_capacity(width * 4);
for i in 0..width {
let byte_idx = i * bytes.len() / width;
let amplitude = bytes.get(byte_idx).copied().unwrap_or(0);
let bar_idx = (amplitude as usize * (BARS.len() - 1)) / 255;
result.push(BARS[bar_idx]);
}
result
}

View File

@@ -0,0 +1,117 @@
//! Shared message list rendering for search and pinned modals
use crate::tdlib::MessageInfo;
use ratatui::{
layout::Alignment,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
};
/// Renders a single message item with marker, sender, date, and wrapped text
pub fn render_message_item(
msg: &MessageInfo,
is_selected: bool,
content_width: usize,
max_preview_lines: usize,
) -> Vec<Line<'static>> {
let mut lines = Vec::new();
// Marker, sender name, and date
let marker = if is_selected { "" } else { " " };
let sender_color = if msg.is_outgoing() {
Color::Green
} else {
Color::Cyan
};
let sender_name = if msg.is_outgoing() {
"Вы".to_string()
} else {
msg.sender_name().to_string()
};
lines.push(Line::from(vec![
Span::styled(
marker.to_string(),
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!("{} ", sender_name),
Style::default()
.fg(sender_color)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!("({})", crate::utils::format_datetime(msg.date())),
Style::default().fg(Color::Gray),
),
]));
// Wrapped message text
let msg_color = if is_selected {
Color::Yellow
} else {
Color::White
};
let max_width = content_width.saturating_sub(4);
let wrapped = crate::ui::messages::wrap_text_with_offsets(msg.text(), max_width);
let wrapped_count = wrapped.len();
for wrapped_line in wrapped.into_iter().take(max_preview_lines) {
lines.push(Line::from(vec![
Span::raw(" ".to_string()),
Span::styled(wrapped_line.text, Style::default().fg(msg_color)),
]));
}
if wrapped_count > max_preview_lines {
lines.push(Line::from(vec![
Span::raw(" ".to_string()),
Span::styled("...".to_string(), Style::default().fg(Color::Gray)),
]));
}
lines
}
/// Calculates scroll offset to keep selected item visible
pub fn calculate_scroll_offset(
selected_index: usize,
lines_per_item: usize,
visible_height: u16,
) -> u16 {
let visible = visible_height.saturating_sub(2) as usize;
let selected_line = selected_index * lines_per_item;
if selected_line > visible / 2 {
(selected_line - visible / 2) as u16
} else {
0
}
}
/// Renders a help bar with keyboard shortcuts
pub fn render_help_bar(
shortcuts: &[(&str, &str, Color)],
border_color: Color,
) -> Paragraph<'static> {
let mut spans: Vec<Span<'static>> = Vec::new();
for (i, (key, label, color)) in shortcuts.iter().enumerate() {
if i > 0 {
spans.push(Span::raw(" ".to_string()));
}
spans.push(Span::styled(
format!(" {} ", key),
Style::default().fg(*color).add_modifier(Modifier::BOLD),
));
spans.push(Span::raw(label.to_string()));
}
Paragraph::new(Line::from(spans))
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color)),
)
.alignment(Alignment::Center)
}

View File

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

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("Нет"),
]), ]),
]; ];

194
src/ui/compose_bar.rs Normal file
View File

@@ -0,0 +1,194 @@
//! Compose bar / input box rendering
use crate::app::methods::{compose::ComposeMethods, messages::MessageMethods};
use crate::app::App;
use crate::app::InputMode;
use crate::tdlib::TdClientTrait;
use crate::ui::components;
use ratatui::{
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
Frame,
};
/// Renders input field with cursor at the specified position
fn render_input_with_cursor(
prefix: &str,
text: &str,
cursor_pos: usize,
color: Color,
) -> Line<'static> {
components::render_input_field(prefix, text, cursor_pos, color)
}
/// Renders input box with support for different modes (forward/select/edit/reply/normal)
pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
let (input_line, input_title): (Line, &str) = if app.is_forwarding() {
// Режим пересылки - показываем превью сообщения
let forward_preview = app
.get_forwarding_message()
.map(|m| {
let text_preview: String = m.text().chars().take(40).collect();
let ellipsis = if m.text().chars().count() > 40 {
"..."
} else {
""
};
format!("{}{}", text_preview, ellipsis)
})
.unwrap_or_else(|| "↪ ...".to_string());
let line = Line::from(Span::styled(forward_preview, Style::default().fg(Color::Cyan)));
(line, " Выберите чат ← ")
} else if app.is_selecting_message() {
// Режим выбора сообщения - подсказка зависит от возможностей
let selected_msg = app.get_selected_message();
let can_edit = selected_msg
.as_ref()
.map(|m| m.can_be_edited() && m.is_outgoing())
.unwrap_or(false);
let can_delete = selected_msg
.as_ref()
.map(|m| m.can_be_deleted_only_for_self() || m.can_be_deleted_for_all_users())
.unwrap_or(false);
let hint = match (can_edit, can_delete) {
(true, true) => "↑↓ · Enter ред. · r ответ · f перслть · y копир. · d удал. · Esc",
(true, false) => "↑↓ · Enter ред. · r ответ · f переслть · y копир. · Esc",
(false, true) => "↑↓ · r ответ · f переслать · y копир. · d удалить · Esc",
(false, false) => "↑↓ · r ответить · f переслать · y копировать · Esc",
};
(
Line::from(Span::styled(hint, Style::default().fg(Color::Cyan))),
" Выбор сообщения ",
)
} else if app.is_editing() {
// Режим редактирования
if app.message_input.is_empty() {
let line = Line::from(vec![
Span::raw(""),
Span::styled("", Style::default().fg(Color::Magenta)),
Span::styled(" Введите новый текст...", Style::default().fg(Color::Gray)),
]);
(line, " Редактирование (Esc отмена) ")
} else {
let line = render_input_with_cursor(
"",
&app.message_input,
app.cursor_position,
Color::Magenta,
);
(line, " Редактирование (Esc отмена) ")
}
} else if app.is_replying() {
// Режим ответа на сообщение
let reply_preview = app
.get_replying_to_message()
.map(|m| {
let sender = if m.is_outgoing() {
"Вы"
} else {
m.sender_name()
};
let text_preview: String = m.text().chars().take(30).collect();
let ellipsis = if m.text().chars().count() > 30 {
"..."
} else {
""
};
format!("{}: {}{}", sender, text_preview, ellipsis)
})
.unwrap_or_else(|| "...".to_string());
if app.message_input.is_empty() {
let line = Line::from(vec![
Span::styled("", Style::default().fg(Color::Cyan)),
Span::styled(reply_preview, Style::default().fg(Color::Gray)),
Span::raw(" "),
Span::styled("", Style::default().fg(Color::Yellow)),
]);
(line, " Ответ (Esc отмена) ")
} else {
let short_preview: String = reply_preview.chars().take(15).collect();
let prefix = format!("{} > ", short_preview);
let line = render_input_with_cursor(
&prefix,
&app.message_input,
app.cursor_position,
Color::Yellow,
);
(line, " Ответ (Esc отмена) ")
}
} else if app.input_mode == InputMode::Normal {
// Normal mode — dim, no cursor
if app.message_input.is_empty() {
let line = Line::from(vec![Span::styled(
"> Press i to type...",
Style::default().fg(Color::DarkGray),
)]);
(line, "")
} else {
let draft_preview: String = app.message_input.chars().take(60).collect();
let ellipsis = if app.message_input.chars().count() > 60 {
"..."
} else {
""
};
let line = Line::from(Span::styled(
format!("> {}{}", draft_preview, ellipsis),
Style::default().fg(Color::DarkGray),
));
(line, "")
}
} else {
// Insert mode — active, with cursor
if app.message_input.is_empty() {
let line = Line::from(vec![
Span::raw("> "),
Span::styled("", Style::default().fg(Color::Yellow)),
Span::styled(" Введите сообщение...", Style::default().fg(Color::Gray)),
]);
(line, "")
} else {
let line = render_input_with_cursor(
"> ",
&app.message_input,
app.cursor_position,
Color::Yellow,
);
(line, "")
}
};
let input_block = if input_title.is_empty() {
let border_style = if app.input_mode == InputMode::Insert {
Style::default().fg(Color::Green)
} else {
Style::default().fg(Color::DarkGray)
};
Block::default()
.borders(Borders::ALL)
.border_style(border_style)
} else {
let title_color = if app.is_replying() || app.is_forwarding() {
Color::Cyan
} else {
Color::Magenta
};
Block::default()
.borders(Borders::ALL)
.title(input_title)
.title_style(
Style::default()
.fg(title_color)
.add_modifier(Modifier::BOLD),
)
};
let input = Paragraph::new(input_line)
.block(input_block)
.wrap(ratatui::widgets::Wrap { trim: false });
f.render_widget(input, area);
}

View File

@@ -1,6 +1,7 @@
use crate::app::App; use crate::app::App;
use crate::tdlib::TdClientTrait; use crate::app::InputMode;
use crate::tdlib::NetworkState; use crate::tdlib::NetworkState;
use crate::tdlib::TdClientTrait;
use ratatui::{ use ratatui::{
layout::Rect, layout::Rect,
style::{Color, Style}, style::{Color, Style},
@@ -18,18 +19,27 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
NetworkState::Updating => "⏳ Обновление... | ", NetworkState::Updating => "⏳ Обновление... | ",
}; };
let account_indicator = format!("[{}] ", app.current_account_name);
let status = if let Some(msg) = &app.status_message { let status = if let Some(msg) = &app.status_message {
format!(" {}{} ", network_indicator, msg) format!(" {}{}{} ", account_indicator, network_indicator, msg)
} else if let Some(err) = &app.error_message { } else if let Some(err) = &app.error_message {
format!(" {}Error: {} ", network_indicator, err) format!(" {}{}Error: {} ", account_indicator, network_indicator, err)
} else if app.is_searching { } else if app.is_searching {
format!(" {}↑/↓: Navigate | Enter: Select | Esc: Cancel ", network_indicator) format!(
" {}{}↑/↓: Navigate | Enter: Select | Esc: Cancel ",
account_indicator, network_indicator
)
} else if app.selected_chat_id.is_some() { } else if app.selected_chat_id.is_some() {
format!(" {}↑/↓: Scroll | Ctrl+U: Profile | Enter: Send | Esc: Close | Ctrl+R: Refresh | Ctrl+C: Quit ", network_indicator) let mode_str = match app.input_mode {
InputMode::Normal => "[NORMAL] j/k: Nav | i: Insert | d/r/f/y: Actions | Esc: Close",
InputMode::Insert => "[INSERT] Type message | Esc: Normal mode",
};
format!(" {}{}{} | Ctrl+C: Quit ", account_indicator, network_indicator, mode_str)
} else { } else {
format!( format!(
" {}↑/↓: Navigate | Enter: Open | Ctrl+S: Search | Ctrl+R: Refresh | Ctrl+C: Quit ", " {}{}↑/↓: Navigate | Enter: Open | Ctrl+S: Search | Ctrl+R: Refresh | Ctrl+C: Quit ",
network_indicator account_indicator, network_indicator
) )
}; };
@@ -42,7 +52,7 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
} else if app.status_message.is_some() { } else if app.status_message.is_some() {
Style::default().fg(Color::Yellow) Style::default().fg(Color::Yellow)
} else { } else {
Style::default().fg(Color::DarkGray) Style::default().fg(Color::Rgb(160, 160, 160))
}; };
let footer = Paragraph::new(status).style(style); let footer = Paragraph::new(status).style(style);

View File

@@ -1,7 +1,14 @@
//! Chat message area rendering.
//!
//! Renders message bubbles grouped by date/sender, pinned bar, and delegates
//! to modals (search, pinned, reactions, delete) and compose_bar.
use crate::app::methods::{messages::MessageMethods, modal::ModalMethods, search::SearchMethods};
use crate::app::App; use crate::app::App;
use crate::tdlib::TdClientTrait;
use crate::message_grouping::{group_messages, MessageGroup}; use crate::message_grouping::{group_messages, MessageGroup};
use crate::tdlib::TdClientTrait;
use crate::ui::components; use crate::ui::components;
use crate::ui::{compose_bar, modals};
use ratatui::{ use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect}, layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style}, style::{Color, Modifier, Style},
@@ -11,7 +18,12 @@ use ratatui::{
}; };
/// Рендерит заголовок чата с typing status /// Рендерит заголовок чата с typing status
fn render_chat_header<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>, chat: &crate::tdlib::ChatInfo) { fn render_chat_header<T: TdClientTrait>(
f: &mut Frame,
area: Rect,
app: &App<T>,
chat: &crate::tdlib::ChatInfo,
) {
let typing_action = app let typing_action = app
.td_client .td_client
.typing_status() .typing_status()
@@ -27,10 +39,7 @@ fn render_chat_header<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>,
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
)]; )];
if let Some(username) = &chat.username { if let Some(username) = &chat.username {
spans.push(Span::styled( spans.push(Span::styled(format!(" {}", username), Style::default().fg(Color::Gray)));
format!(" {}", username),
Style::default().fg(Color::Gray),
));
} }
spans.push(Span::styled( spans.push(Span::styled(
format!(" {}", action), format!(" {}", action),
@@ -83,33 +92,20 @@ fn render_pinned_bar<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>)
Span::raw(" ".repeat(padding)), Span::raw(" ".repeat(padding)),
Span::styled(pinned_hint, Style::default().fg(Color::Gray)), Span::styled(pinned_hint, Style::default().fg(Color::Gray)),
]); ]);
let pinned_bar = let pinned_bar = Paragraph::new(pinned_line).style(Style::default().bg(Color::Rgb(40, 20, 40)));
Paragraph::new(pinned_line).style(Style::default().bg(Color::Rgb(40, 20, 40)));
f.render_widget(pinned_bar, area); f.render_widget(pinned_bar, area);
} }
fn render_input_with_cursor(
prefix: &str,
text: &str,
cursor_pos: usize,
color: Color,
) -> Line<'static> {
// Используем компонент input_field
components::render_input_field(prefix, text, cursor_pos, color)
}
/// Информация о строке после переноса: текст и позиция в оригинале /// Информация о строке после переноса: текст и позиция в оригинале
struct WrappedLine { pub(super) struct WrappedLine {
text: String, pub text: String,
} }
/// Разбивает текст на строки с учётом максимальной ширины /// Разбивает текст на строки с учётом максимальной ширины
/// (используется только для search/pinned режимов, основной рендеринг через message_bubble) /// (используется только для search/pinned режимов, основной рендеринг через message_bubble)
fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> { pub(super) fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
if max_width == 0 { if max_width == 0 {
return vec![WrappedLine { return vec![WrappedLine { text: text.to_string() }];
text: text.to_string(),
}];
} }
let mut result = Vec::new(); let mut result = Vec::new();
@@ -134,9 +130,7 @@ fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
current_line.push_str(&word); current_line.push_str(&word);
current_width += 1 + word_width; current_width += 1 + word_width;
} else { } else {
result.push(WrappedLine { result.push(WrappedLine { text: current_line });
text: current_line,
});
current_line = word; current_line = word;
current_width = word_width; current_width = word_width;
} }
@@ -158,29 +152,23 @@ fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
current_line.push(' '); current_line.push(' ');
current_line.push_str(&word); current_line.push_str(&word);
} else { } else {
result.push(WrappedLine { result.push(WrappedLine { text: current_line });
text: current_line,
});
current_line = word; current_line = word;
} }
} }
if !current_line.is_empty() { if !current_line.is_empty() {
result.push(WrappedLine { result.push(WrappedLine { text: current_line });
text: current_line,
});
} }
if result.is_empty() { if result.is_empty() {
result.push(WrappedLine { result.push(WrappedLine { text: String::new() });
text: String::new(),
});
} }
result result
} }
/// Рендерит список сообщений с группировкой по дате/отправителю и автоскроллом /// Рендерит список сообщений с группировкой по дате/отправителю и автоскроллом
fn render_message_list<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) { fn render_message_list<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &mut App<T>) {
let content_width = area.width.saturating_sub(2) as usize; let content_width = area.width.saturating_sub(2) as usize;
// Messages с группировкой по дате и отправителю // Messages с группировкой по дате и отправителю
@@ -191,6 +179,13 @@ fn render_message_list<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>
// Номер строки, где начинается выбранное сообщение (для автоскролла) // Номер строки, где начинается выбранное сообщение (для автоскролла)
let mut selected_msg_line: Option<usize> = None; let mut selected_msg_line: Option<usize> = None;
// ОПТИМИЗАЦИЯ: Убрали массовый preloading всех изображений.
// Теперь загружаем только видимые изображения во втором проходе (см. ниже).
// Собираем информацию о развёрнутых изображениях (для второго прохода)
#[cfg(feature = "images")]
let mut deferred_images: Vec<components::DeferredImageRender> = Vec::new();
// Используем message_grouping для группировки сообщений // Используем message_grouping для группировки сообщений
let grouped = group_messages(&app.td_client.current_chat_messages()); let grouped = group_messages(&app.td_client.current_chat_messages());
let mut is_first_date = true; let mut is_first_date = true;
@@ -204,10 +199,7 @@ fn render_message_list<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>
is_first_date = false; is_first_date = false;
is_first_sender = true; // Сбрасываем счётчик заголовков после даты is_first_sender = true; // Сбрасываем счётчик заголовков после даты
} }
MessageGroup::SenderHeader { MessageGroup::SenderHeader { is_outgoing, sender_name } => {
is_outgoing,
sender_name,
} => {
// Рендерим заголовок отправителя // Рендерим заголовок отправителя
lines.extend(components::render_sender_header( lines.extend(components::render_sender_header(
is_outgoing, is_outgoing,
@@ -225,12 +217,85 @@ fn render_message_list<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>
} }
// Рендерим сообщение // Рендерим сообщение
lines.extend(components::render_message_bubble( let bubble_lines = components::render_message_bubble(
&msg, &msg,
app.config(), app.config(),
content_width, content_width,
selected_msg_id, selected_msg_id,
)); app.playback_state.as_ref(),
);
// Собираем deferred image renders для всех загруженных фото
#[cfg(feature = "images")]
if let Some(photo) = msg.photo_info() {
if let crate::tdlib::PhotoDownloadState::Downloaded(path) =
&photo.download_state
{
let inline_width =
content_width.min(crate::constants::INLINE_IMAGE_MAX_WIDTH);
let img_height = components::calculate_image_height(
photo.width,
photo.height,
inline_width,
);
let img_width = inline_width as u16;
let bubble_len = bubble_lines.len();
let placeholder_start = lines.len() + bubble_len - img_height as usize;
deferred_images.push(components::DeferredImageRender {
message_id: msg.id(),
photo_path: path.clone(),
line_offset: placeholder_start,
x_offset: 0,
width: img_width,
height: img_height,
});
}
}
lines.extend(bubble_lines);
}
MessageGroup::Album(album_messages) => {
#[cfg(feature = "images")]
{
let is_selected = album_messages
.iter()
.any(|m| selected_msg_id == Some(m.id()));
if is_selected {
selected_msg_line = Some(lines.len());
}
let (bubble_lines, album_deferred) = components::render_album_bubble(
&album_messages,
app.config(),
content_width,
selected_msg_id,
);
for mut d in album_deferred {
d.line_offset += lines.len();
deferred_images.push(d);
}
lines.extend(bubble_lines);
}
#[cfg(not(feature = "images"))]
{
// Fallback: рендерим каждое сообщение отдельно
for msg in &album_messages {
let is_selected = selected_msg_id == Some(msg.id());
if is_selected {
selected_msg_line = Some(lines.len());
}
lines.extend(components::render_message_bubble(
msg,
app.config(),
content_width,
selected_msg_id,
app.playback_state.as_ref(),
));
}
}
} }
} }
} }
@@ -244,11 +309,7 @@ fn render_message_list<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>
let total_lines = lines.len(); let total_lines = lines.len();
// Базовый скролл (показываем последние сообщения) // Базовый скролл (показываем последние сообщения)
let base_scroll = if total_lines > visible_height { let base_scroll = total_lines.saturating_sub(visible_height);
total_lines - visible_height
} else {
0
};
// Если выбрано сообщение, автоскроллим к нему // Если выбрано сообщение, автоскроллим к нему
let scroll_offset = if app.is_selecting_message() { let scroll_offset = if app.is_selecting_message() {
@@ -275,156 +336,67 @@ fn render_message_list<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>
.block(Block::default().borders(Borders::ALL)) .block(Block::default().borders(Borders::ALL))
.scroll((scroll_offset, 0)); .scroll((scroll_offset, 0));
f.render_widget(messages_widget, area); f.render_widget(messages_widget, area);
// Второй проход: рендерим изображения поверх placeholder-ов
#[cfg(feature = "images")]
{
use ratatui_image::StatefulImage;
// THROTTLING: Рендерим изображения максимум 15 FPS (каждые 66ms)
let should_render_images = app
.last_image_render_time
.map(|t| t.elapsed() > std::time::Duration::from_millis(66))
.unwrap_or(true);
if !deferred_images.is_empty() && should_render_images {
let content_x = area.x + 1;
let content_y = area.y + 1;
for d in &deferred_images {
let y_in_content = d.line_offset as i32 - scroll_offset as i32;
// Пропускаем изображения, которые полностью за пределами видимости
if y_in_content < 0 || y_in_content as usize >= visible_height {
continue;
}
let img_y = content_y + y_in_content as u16;
let remaining_height = (content_y + visible_height as u16).saturating_sub(img_y);
// ВАЖНО: Не рендерим частично видимые изображения (убирает сжатие и мигание)
if d.height > remaining_height {
continue;
}
// Рендерим с ПОЛНОЙ высотой (не сжимаем)
let img_rect = Rect::new(content_x + d.x_offset, img_y, d.width, d.height);
// ОПТИМИЗАЦИЯ: Загружаем только видимые изображения (не все сразу)
// Используем inline_renderer с Halfblocks для скорости
if let Some(renderer) = &mut app.inline_image_renderer {
// Загружаем только если видимо (early return если уже в кеше)
let _ = renderer.load_image(d.message_id, &d.photo_path);
if let Some(protocol) = renderer.get_protocol(&d.message_id) {
f.render_stateful_widget(StatefulImage::default(), img_rect, protocol);
}
}
}
// Обновляем время последнего рендеринга (для throttling)
app.last_image_render_time = Some(std::time::Instant::now());
}
}
} }
/// Рендерит input box с поддержкой разных режимов (forward/select/edit/reply/normal) pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &mut App<T>) {
fn render_input_box<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) { // Модальное окно просмотра изображения (приоритет выше всех)
let (input_line, input_title) = if app.is_forwarding() { #[cfg(feature = "images")]
// Режим пересылки - показываем превью сообщения if let Some(modal_state) = app.image_modal.clone() {
let forward_preview = app modals::render_image_viewer(f, app, &modal_state);
.get_forwarding_message() return;
.map(|m| { }
let text_preview: String = m.text().chars().take(40).collect();
let ellipsis = if m.text().chars().count() > 40 {
"..."
} else {
""
};
format!("{}{}", text_preview, ellipsis)
})
.unwrap_or_else(|| "↪ ...".to_string());
let line = Line::from(Span::styled(forward_preview, Style::default().fg(Color::Cyan)));
(line, " Выберите чат ← ")
} else if app.is_selecting_message() {
// Режим выбора сообщения - подсказка зависит от возможностей
let selected_msg = app.get_selected_message();
let can_edit = selected_msg
.as_ref()
.map(|m| m.can_be_edited() && m.is_outgoing())
.unwrap_or(false);
let can_delete = selected_msg
.as_ref()
.map(|m| m.can_be_deleted_only_for_self() || m.can_be_deleted_for_all_users())
.unwrap_or(false);
let hint = match (can_edit, can_delete) {
(true, true) => "↑↓ · Enter ред. · r ответ · f перслть · y копир. · d удал. · Esc",
(true, false) => "↑↓ · Enter ред. · r ответ · f переслть · y копир. · Esc",
(false, true) => "↑↓ · r ответ · f переслать · y копир. · d удалить · Esc",
(false, false) => "↑↓ · r ответить · f переслать · y копировать · Esc",
};
(
Line::from(Span::styled(hint, Style::default().fg(Color::Cyan))),
" Выбор сообщения ",
)
} else if app.is_editing() {
// Режим редактирования
if app.message_input.is_empty() {
// Пустой инпут - показываем курсор и placeholder
let line = Line::from(vec![
Span::raw(""),
Span::styled("", Style::default().fg(Color::Magenta)),
Span::styled(" Введите новый текст...", Style::default().fg(Color::Gray)),
]);
(line, " Редактирование (Esc отмена) ")
} else {
// Текст с курсором
let line = render_input_with_cursor(
"",
&app.message_input,
app.cursor_position,
Color::Magenta,
);
(line, " Редактирование (Esc отмена) ")
}
} else if app.is_replying() {
// Режим ответа на сообщение
let reply_preview = app
.get_replying_to_message()
.map(|m| {
let sender = if m.is_outgoing() {
"Вы"
} else {
m.sender_name()
};
let text_preview: String = m.text().chars().take(30).collect();
let ellipsis = if m.text().chars().count() > 30 {
"..."
} else {
""
};
format!("{}: {}{}", sender, text_preview, ellipsis)
})
.unwrap_or_else(|| "...".to_string());
if app.message_input.is_empty() {
let line = Line::from(vec![
Span::styled("", Style::default().fg(Color::Cyan)),
Span::styled(reply_preview, Style::default().fg(Color::Gray)),
Span::raw(" "),
Span::styled("", Style::default().fg(Color::Yellow)),
]);
(line, " Ответ (Esc отмена) ")
} else {
let short_preview: String = reply_preview.chars().take(15).collect();
let prefix = format!("{} > ", short_preview);
let line = render_input_with_cursor(
&prefix,
&app.message_input,
app.cursor_position,
Color::Yellow,
);
(line, " Ответ (Esc отмена) ")
}
} else {
// Обычный режим
if app.message_input.is_empty() {
// Пустой инпут - показываем курсор и placeholder
let line = Line::from(vec![
Span::raw("> "),
Span::styled("", Style::default().fg(Color::Yellow)),
Span::styled(" Введите сообщение...", Style::default().fg(Color::Gray)),
]);
(line, "")
} else {
// Текст с курсором
let line = render_input_with_cursor(
"> ",
&app.message_input,
app.cursor_position,
Color::Yellow,
);
(line, "")
}
};
let input_block = if input_title.is_empty() {
Block::default().borders(Borders::ALL)
} else {
let title_color = if app.is_replying() || app.is_forwarding() {
Color::Cyan
} else {
Color::Magenta
};
Block::default()
.borders(Borders::ALL)
.title(input_title)
.title_style(
Style::default()
.fg(title_color)
.add_modifier(Modifier::BOLD),
)
};
let input = Paragraph::new(input_line)
.block(input_block)
.wrap(ratatui::widgets::Wrap { trim: false });
f.render_widget(input, area);
}
pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
// Режим профиля // Режим профиля
if app.is_profile_mode() { if app.is_profile_mode() {
if let Some(profile) = app.get_profile_info() { if let Some(profile) = app.get_profile_info() {
@@ -435,27 +407,27 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
// Режим поиска по сообщениям // Режим поиска по сообщениям
if app.is_message_search_mode() { if app.is_message_search_mode() {
render_search_mode(f, area, app); modals::render_search(f, area, app);
return; return;
} }
// Режим просмотра закреплённых сообщений // Режим просмотра закреплённых сообщений
if app.is_pinned_mode() { if app.is_pinned_mode() {
render_pinned_mode(f, area, app); modals::render_pinned(f, area, app);
return; return;
} }
if let Some(chat) = app.get_selected_chat() { if let Some(chat) = app.get_selected_chat().cloned() {
// Вычисляем динамическую высоту инпута на основе длины текста // Вычисляем динамическую высоту инпута на основе длины текста
let input_width = area.width.saturating_sub(4) as usize; // -2 для рамок, -2 для "> " let input_width = area.width.saturating_sub(4) as usize; // -2 для рамок, -2 для "> "
let input_text_len = app.message_input.chars().count() + 2; // +2 для "> " let input_lines: u16 = if input_width > 0 {
let input_lines = if input_width > 0 { let len = app.message_input.chars().count() + 2; // +2 для "> "
((input_text_len as f32 / input_width as f32).ceil() as u16).max(1) ((len as f32 / input_width as f32).ceil() as u16).max(1)
} else { } else {
1 1
}; };
// Минимум 3 строки (1 контент + 2 рамки), максимум 10 // Минимум 3 строки (1 контент + 2 рамки), максимум 10
let input_height = (input_lines + 2).min(10).max(3); let input_height = (input_lines + 2).clamp(3, 10);
// Проверяем, есть ли закреплённое сообщение // Проверяем, есть ли закреплённое сообщение
let has_pinned = app.td_client.current_pinned_message().is_some(); let has_pinned = app.td_client.current_pinned_message().is_some();
@@ -483,7 +455,7 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
}; };
// Chat header с typing status // Chat header с typing status
render_chat_header(f, message_chunks[0], app, chat); render_chat_header(f, message_chunks[0], app, &chat);
// Pinned bar (если есть закреплённое сообщение) // Pinned bar (если есть закреплённое сообщение)
render_pinned_bar(f, message_chunks[1], app); render_pinned_bar(f, message_chunks[1], app);
@@ -492,7 +464,7 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
render_message_list(f, message_chunks[2], app); render_message_list(f, message_chunks[2], app);
// Input box с wrap для длинного текста и блочным курсором // Input box с wrap для длинного текста и блочным курсором
render_input_box(f, message_chunks[3], app); compose_bar::render(f, message_chunks[3], app);
} else { } else {
let empty = Paragraph::new("Выберите чат") let empty = Paragraph::new("Выберите чат")
.block(Block::default().borders(Borders::ALL)) .block(Block::default().borders(Borders::ALL))
@@ -503,391 +475,13 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
// Модалка подтверждения удаления // Модалка подтверждения удаления
if app.is_confirm_delete_shown() { if app.is_confirm_delete_shown() {
render_delete_confirm_modal(f, area); modals::render_delete_confirm(f, area);
} }
// Модалка выбора реакции // Модалка выбора реакции
if let crate::app::ChatState::ReactionPicker { if let crate::app::ChatState::ReactionPicker { available_reactions, selected_index, .. } =
available_reactions, &app.chat_state
selected_index,
..
} = &app.chat_state
{ {
render_reaction_picker_modal(f, area, available_reactions, *selected_index); modals::render_reaction_picker(f, area, available_reactions, *selected_index);
} }
} }
/// Рендерит режим поиска по сообщениям
fn render_search_mode<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
// Извлекаем данные из ChatState
let (query, results, selected_index) =
if let crate::app::ChatState::SearchInChat {
query,
results,
selected_index,
} = &app.chat_state
{
(query.as_str(), results.as_slice(), *selected_index)
} else {
return; // Некорректное состояние, не рендерим
};
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Search input
Constraint::Min(0), // Search results
Constraint::Length(3), // Help bar
])
.split(area);
// Search input
let total = results.len();
let current = if total > 0 {
selected_index + 1
} else {
0
};
let input_line = if query.is_empty() {
Line::from(vec![
Span::styled("🔍 ", Style::default().fg(Color::Yellow)),
Span::styled("", Style::default().fg(Color::Yellow)),
Span::styled(" Введите текст для поиска...", Style::default().fg(Color::Gray)),
])
} else {
Line::from(vec![
Span::styled("🔍 ", Style::default().fg(Color::Yellow)),
Span::styled(query, Style::default().fg(Color::White)),
Span::styled("", Style::default().fg(Color::Yellow)),
Span::styled(format!(" ({}/{})", current, total), Style::default().fg(Color::Gray)),
])
};
let search_input = Paragraph::new(input_line).block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow))
.title(" Поиск по сообщениям ")
.title_style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
);
f.render_widget(search_input, chunks[0]);
// Search results
let content_width = chunks[1].width.saturating_sub(2) as usize;
let mut lines: Vec<Line> = Vec::new();
if results.is_empty() {
if !query.is_empty() {
lines.push(Line::from(Span::styled(
"Ничего не найдено",
Style::default().fg(Color::Gray),
)));
}
} else {
for (idx, msg) in results.iter().enumerate() {
let is_selected = idx == selected_index;
// Пустая строка между результатами
if idx > 0 {
lines.push(Line::from(""));
}
// Маркер выбора, имя и дата
let marker = if is_selected { "" } else { " " };
let sender_color = if msg.is_outgoing() {
Color::Green
} else {
Color::Cyan
};
let sender_name = if msg.is_outgoing() {
"Вы".to_string()
} else {
msg.sender_name().to_string()
};
lines.push(Line::from(vec![
Span::styled(
marker,
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!("{} ", sender_name),
Style::default()
.fg(sender_color)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!("({})", crate::utils::format_datetime(msg.date())),
Style::default().fg(Color::Gray),
),
]));
// Текст сообщения (с переносом)
let msg_color = if is_selected {
Color::Yellow
} else {
Color::White
};
let max_width = content_width.saturating_sub(4);
let wrapped = wrap_text_with_offsets(msg.text(), max_width);
let wrapped_count = wrapped.len();
for wrapped_line in wrapped.into_iter().take(2) {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(wrapped_line.text, Style::default().fg(msg_color)),
]));
}
if wrapped_count > 2 {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled("...", Style::default().fg(Color::Gray)),
]));
}
}
}
// Скролл к выбранному результату
let visible_height = chunks[1].height.saturating_sub(2) as usize;
let lines_per_result = 4;
let selected_line = selected_index * lines_per_result;
let scroll_offset = if selected_line > visible_height / 2 {
(selected_line - visible_height / 2) as u16
} else {
0
};
let results_widget = Paragraph::new(lines)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow)),
)
.scroll((scroll_offset, 0));
f.render_widget(results_widget, chunks[1]);
// Help bar
let help_line = Line::from(vec![
Span::styled(
" ↑↓ ",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::raw("навигация"),
Span::raw(" "),
Span::styled(
" n/N ",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::raw("след./пред."),
Span::raw(" "),
Span::styled(
" Enter ",
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
Span::raw("перейти"),
Span::raw(" "),
Span::styled(" Esc ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
Span::raw("выход"),
]);
let help = Paragraph::new(help_line)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow)),
)
.alignment(Alignment::Center);
f.render_widget(help, chunks[2]);
}
/// Рендерит режим просмотра закреплённых сообщений
fn render_pinned_mode<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
// Извлекаем данные из ChatState
let (messages, selected_index) = if let crate::app::ChatState::PinnedMessages {
messages,
selected_index,
} = &app.chat_state
{
(messages.as_slice(), *selected_index)
} else {
return; // Некорректное состояние
};
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Header
Constraint::Min(0), // Pinned messages list
Constraint::Length(3), // Help bar
])
.split(area);
// Header
let total = messages.len();
let current = selected_index + 1;
let header_text = format!("📌 ЗАКРЕПЛЁННЫЕ ({}/{})", current, total);
let header = Paragraph::new(header_text)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Magenta)),
)
.style(
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD),
);
f.render_widget(header, chunks[0]);
// Pinned messages list
let content_width = chunks[1].width.saturating_sub(2) as usize;
let mut lines: Vec<Line> = Vec::new();
for (idx, msg) in messages.iter().enumerate() {
let is_selected = idx == selected_index;
// Пустая строка между сообщениями
if idx > 0 {
lines.push(Line::from(""));
}
// Маркер выбора и имя отправителя
let marker = if is_selected { "" } else { " " };
let sender_color = if msg.is_outgoing() {
Color::Green
} else {
Color::Cyan
};
let sender_name = if msg.is_outgoing() {
"Вы".to_string()
} else {
msg.sender_name().to_string()
};
lines.push(Line::from(vec![
Span::styled(
marker,
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!("{} ", sender_name),
Style::default()
.fg(sender_color)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!("({})", crate::utils::format_datetime(msg.date())),
Style::default().fg(Color::Gray),
),
]));
// Текст сообщения (с переносом)
let msg_color = if is_selected {
Color::Yellow
} else {
Color::White
};
let max_width = content_width.saturating_sub(4);
let wrapped = wrap_text_with_offsets(msg.text(), max_width);
let wrapped_count = wrapped.len();
for wrapped_line in wrapped.into_iter().take(3) {
// Максимум 3 строки на сообщение
lines.push(Line::from(vec![
Span::raw(" "), // Отступ
Span::styled(wrapped_line.text, Style::default().fg(msg_color)),
]));
}
if wrapped_count > 3 {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled("...", Style::default().fg(Color::Gray)),
]));
}
}
if lines.is_empty() {
lines.push(Line::from(Span::styled(
"Нет закреплённых сообщений",
Style::default().fg(Color::Gray),
)));
}
// Скролл к выбранному сообщению
let visible_height = chunks[1].height.saturating_sub(2) as usize;
let lines_per_msg = 5; // Примерно строк на сообщение
let selected_line = selected_index * lines_per_msg;
let scroll_offset = if selected_line > visible_height / 2 {
(selected_line - visible_height / 2) as u16
} else {
0
};
let messages_widget = Paragraph::new(lines)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Magenta)),
)
.scroll((scroll_offset, 0));
f.render_widget(messages_widget, chunks[1]);
// Help bar
let help_line = Line::from(vec![
Span::styled(
" ↑↓ ",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::raw("навигация"),
Span::raw(" "),
Span::styled(
" Enter ",
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
Span::raw("перейти"),
Span::raw(" "),
Span::styled(" Esc ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
Span::raw("выход"),
]);
let help = Paragraph::new(help_line)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Magenta)),
)
.alignment(Alignment::Center);
f.render_widget(help, chunks[2]);
}
/// Рендерит модалку подтверждения удаления
fn render_delete_confirm_modal(f: &mut Frame, area: Rect) {
components::modal::render_delete_confirm_modal(f, area);
}
/// Рендерит модалку выбора реакции
fn render_reaction_picker_modal(
f: &mut Frame,
area: Rect,
available_reactions: &[String],
selected_index: usize,
) {
components::render_emoji_picker(f, area, available_reactions, selected_index);
}

View File

@@ -1,10 +1,16 @@
//! UI rendering module.
//!
//! Routes rendering by screen (Loading → Auth → Main) and checks terminal size.
mod auth; mod auth;
pub mod chat_list; pub mod chat_list;
pub mod components; pub mod components;
mod compose_bar;
pub mod footer; pub mod footer;
mod loading; mod loading;
mod main_screen; mod main_screen;
pub mod messages; pub mod messages;
mod modals;
pub mod profile; pub mod profile;
use crate::app::{App, AppScreen}; use crate::app::{App, AppScreen};
@@ -33,6 +39,11 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, app: &mut App<T>) {
AppScreen::Auth => auth::render(f, app), AppScreen::Auth => auth::render(f, app),
AppScreen::Main => main_screen::render(f, app), AppScreen::Main => main_screen::render(f, app),
} }
// Global overlay: account switcher (renders on top of ANY screen)
if app.account_switcher.is_some() {
modals::render_account_switcher(f, area, app);
}
} }
fn render_size_warning(f: &mut Frame, width: u16, height: u16) { fn render_size_warning(f: &mut Frame, width: u16, height: u16) {

View File

@@ -0,0 +1,190 @@
//! Account switcher modal
//!
//! Renders a centered popup with account list (SelectAccount) or
//! new account name input (AddAccount).
use crate::app::{AccountSwitcherState, App};
use crate::tdlib::TdClientTrait;
use ratatui::{
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, Paragraph},
Frame,
};
/// Renders the account switcher modal overlay.
pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
let Some(state) = &app.account_switcher else {
return;
};
match state {
AccountSwitcherState::SelectAccount { accounts, selected_index, current_account } => {
render_select_account(f, area, accounts, *selected_index, current_account);
}
AccountSwitcherState::AddAccount { name_input, cursor_position, error } => {
render_add_account(f, area, name_input, *cursor_position, error.as_deref());
}
}
}
fn render_select_account(
f: &mut Frame,
area: Rect,
accounts: &[crate::accounts::AccountProfile],
selected_index: usize,
current_account: &str,
) {
let mut lines: Vec<Line> = Vec::new();
lines.push(Line::from(""));
for (idx, account) in accounts.iter().enumerate() {
let is_selected = idx == selected_index;
let is_current = account.name == current_account;
let marker = if is_current { "" } else { " " };
let suffix = if is_current { " (текущий)" } else { "" };
let display = format!("{}{} ({}){}", marker, account.name, account.display_name, suffix);
let style = if is_selected {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else if is_current {
Style::default().fg(Color::Green)
} else {
Style::default().fg(Color::White)
};
lines.push(Line::from(Span::styled(format!(" {}", display), style)));
}
// Separator
lines.push(Line::from(Span::styled(
" ──────────────────────",
Style::default().fg(Color::DarkGray),
)));
// Add account item
let add_selected = selected_index == accounts.len();
let add_style = if add_selected {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Cyan)
};
lines.push(Line::from(Span::styled(" + Добавить аккаунт", add_style)));
lines.push(Line::from(""));
// Help bar
lines.push(Line::from(vec![
Span::styled(" j/k ", Style::default().fg(Color::Yellow)),
Span::styled("Nav", Style::default().fg(Color::DarkGray)),
Span::raw(" "),
Span::styled(" Enter ", Style::default().fg(Color::Green)),
Span::styled("Select", Style::default().fg(Color::DarkGray)),
Span::raw(" "),
Span::styled(" a ", Style::default().fg(Color::Cyan)),
Span::styled("Add", Style::default().fg(Color::DarkGray)),
Span::raw(" "),
Span::styled(" Esc ", Style::default().fg(Color::Red)),
Span::styled("Close", Style::default().fg(Color::DarkGray)),
]));
// Calculate dynamic height: header(3) + accounts + separator(1) + add(1) + empty(1) + help(1) + footer(1)
let content_height = (accounts.len() as u16) + 7;
let height = content_height.min(area.height.saturating_sub(4));
let width = 40u16.min(area.width.saturating_sub(4));
let x = area.x + (area.width.saturating_sub(width)) / 2;
let y = area.y + (area.height.saturating_sub(height)) / 2;
let modal_area = Rect::new(x, y, width, height);
f.render_widget(Clear, modal_area);
let modal = Paragraph::new(lines).block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan))
.title(" АККАУНТЫ ")
.title_style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
);
f.render_widget(modal, modal_area);
}
fn render_add_account(
f: &mut Frame,
area: Rect,
name_input: &str,
_cursor_position: usize,
error: Option<&str>,
) {
let mut lines: Vec<Line> = Vec::new();
lines.push(Line::from(""));
// Input field
let input_display = if name_input.is_empty() {
Span::styled("_", Style::default().fg(Color::DarkGray))
} else {
Span::styled(format!("{}_", name_input), Style::default().fg(Color::White))
};
lines.push(Line::from(vec![
Span::styled(" Имя: ", Style::default().fg(Color::Cyan)),
input_display,
]));
// Hint
lines.push(Line::from(Span::styled(
" (a-z, 0-9, -, _)",
Style::default().fg(Color::DarkGray),
)));
lines.push(Line::from(""));
// Error
if let Some(err) = error {
lines.push(Line::from(Span::styled(format!(" {}", err), Style::default().fg(Color::Red))));
lines.push(Line::from(""));
}
// Help bar
lines.push(Line::from(vec![
Span::styled(" Enter ", Style::default().fg(Color::Green)),
Span::styled("Create", Style::default().fg(Color::DarkGray)),
Span::raw(" "),
Span::styled(" Esc ", Style::default().fg(Color::Red)),
Span::styled("Back", Style::default().fg(Color::DarkGray)),
]));
let height = if error.is_some() { 10 } else { 8 };
let height = (height as u16).min(area.height.saturating_sub(4));
let width = 40u16.min(area.width.saturating_sub(4));
let x = area.x + (area.width.saturating_sub(width)) / 2;
let y = area.y + (area.height.saturating_sub(height)) / 2;
let modal_area = Rect::new(x, y, width, height);
f.render_widget(Clear, modal_area);
let modal = Paragraph::new(lines).block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan))
.title(" НОВЫЙ АККАУНТ ")
.title_style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
);
f.render_widget(modal, modal_area);
}

View File

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

View File

@@ -0,0 +1,178 @@
//! Модальное окно для полноэкранного просмотра изображений.
//!
//! Поддерживает:
//! - Автоматическое масштабирование с сохранением aspect ratio
//! - Максимизация по ширине/высоте терминала
//! - Затемнение фона
//! - Hotkeys: Esc/q для закрытия, ←/→ для навигации между фото
use crate::app::App;
use crate::tdlib::r#trait::TdClientTrait;
use crate::tdlib::ImageModalState;
use ratatui::{
layout::{Alignment, Rect},
style::{Color, Style},
text::{Line, Span},
widgets::{Block, Clear, Paragraph},
Frame,
};
use ratatui_image::StatefulImage;
/// Рендерит модальное окно с полноэкранным изображением
pub fn render<T: TdClientTrait>(f: &mut Frame, app: &mut App<T>, modal_state: &ImageModalState) {
let area = f.area();
// Затемняем весь фон
f.render_widget(Clear, area);
f.render_widget(Block::default().style(Style::default().bg(Color::Black)), area);
// Резервируем место для подсказок (2 строки внизу)
let image_area_height = area.height.saturating_sub(2);
// Вычисляем размер изображения с сохранением aspect ratio
let (img_width, img_height) = calculate_modal_size(
modal_state.photo_width,
modal_state.photo_height,
area.width,
image_area_height,
);
// Центрируем изображение
let img_x = (area.width.saturating_sub(img_width)) / 2;
let img_y = (image_area_height.saturating_sub(img_height)) / 2;
let img_rect = Rect::new(img_x, img_y, img_width, img_height);
// Рендерим изображение (используем modal_renderer для высокого качества)
if let Some(renderer) = &mut app.modal_image_renderer {
// Проверяем есть ли протокол уже в кеше
if let Some(protocol) = renderer.get_protocol(&modal_state.message_id) {
// Протокол готов - рендерим изображение (iTerm2/Sixel - высокое качество)
f.render_stateful_widget(StatefulImage::default(), img_rect, protocol);
} else {
// Протокола нет - показываем индикатор загрузки
let loading_text = vec![
Line::from(""),
Line::from(Span::styled(
"⏳ Загрузка изображения...",
Style::default().fg(Color::Gray),
)),
Line::from(""),
Line::from(Span::styled(
"(декодирование в высоком качестве)",
Style::default().fg(Color::DarkGray),
)),
];
let loading = Paragraph::new(loading_text)
.alignment(Alignment::Center)
.block(Block::default());
f.render_widget(loading, img_rect);
// Загружаем изображение (может занять время для iTerm2/Sixel)
let _ = renderer.load_image(modal_state.message_id, &modal_state.photo_path);
// Триггерим перерисовку для показа загруженного изображения
app.needs_redraw = true;
}
}
// Подсказки внизу
let hint = "[Esc/q] Закрыть [←/→] Пред/След фото";
let hint_y = area.height.saturating_sub(1);
let hint_rect = Rect::new(0, hint_y, area.width, 1);
f.render_widget(
Paragraph::new(Span::styled(hint, Style::default().fg(Color::Gray)))
.alignment(Alignment::Center),
hint_rect,
);
// Информация о размере (опционально)
let info = format!(
"{}x{} | {:.1}%",
modal_state.photo_width,
modal_state.photo_height,
(img_width as f64 / modal_state.photo_width as f64) * 100.0
);
let info_y = area.height.saturating_sub(2);
let info_rect = Rect::new(0, info_y, area.width, 1);
f.render_widget(
Paragraph::new(Span::styled(info, Style::default().fg(Color::DarkGray)))
.alignment(Alignment::Center),
info_rect,
);
}
/// Вычисляет размер изображения для модалки с сохранением aspect ratio.
///
/// # Логика масштабирования:
/// - Если изображение меньше терминала → показываем как есть
/// - Если ширина больше → масштабируем по ширине
/// - Если высота больше → масштабируем по высоте
/// - Сохраняем aspect ratio
fn calculate_modal_size(
img_width: i32,
img_height: i32,
term_width: u16,
term_height: u16,
) -> (u16, u16) {
let aspect_ratio = img_width as f64 / img_height as f64;
// Если изображение помещается целиком
if img_width <= term_width as i32 && img_height <= term_height as i32 {
return (img_width as u16, img_height as u16);
}
// Начинаем с максимального размера терминала
let mut width = term_width as f64;
let mut height = term_height as f64;
// Подгоняем по aspect ratio
let term_aspect = width / height;
if term_aspect > aspect_ratio {
// Терминал шире → ограничены по высоте
width = height * aspect_ratio;
} else {
// Терминал выше → ограничены по ширине
height = width / aspect_ratio;
}
(width as u16, height as u16)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_calculate_modal_size_fits() {
// Изображение помещается целиком
let (w, h) = calculate_modal_size(50, 30, 100, 50);
assert_eq!(w, 50);
assert_eq!(h, 30);
}
#[test]
fn test_calculate_modal_size_scale_width() {
// Ограничены по ширине (изображение шире терминала)
let (w, h) = calculate_modal_size(200, 100, 100, 100);
assert_eq!(w, 100);
assert_eq!(h, 50); // aspect ratio 2:1
}
#[test]
fn test_calculate_modal_size_scale_height() {
// Ограничены по высоте (изображение выше терминала)
let (w, h) = calculate_modal_size(100, 200, 100, 100);
assert_eq!(w, 50); // aspect ratio 1:2
assert_eq!(h, 100);
}
#[test]
fn test_calculate_modal_size_aspect_ratio() {
// Проверка сохранения aspect ratio
let (w, h) = calculate_modal_size(1920, 1080, 100, 100);
let aspect = w as f64 / h as f64;
let expected_aspect = 1920.0 / 1080.0;
assert!((aspect - expected_aspect).abs() < 0.01);
}
}

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