From 8bd08318bbd5f026d3725c53cd0f92c205311544 Mon Sep 17 00:00:00 2001 From: Mikhail Kilin Date: Sat, 14 Feb 2026 17:57:37 +0300 Subject: [PATCH] fixes --- CONTEXT.md | 85 ++++++++++- ROADMAP.md | 77 +++++++++- src/accounts/manager.rs | 209 +++++++++++++++++++++++++++ src/accounts/mod.rs | 11 ++ src/accounts/profile.rs | 147 +++++++++++++++++++ src/app/methods/navigation.rs | 1 + src/app/mod.rs | 159 ++++++++++++++++++++- src/input/handlers/chat.rs | 84 +++++++++-- src/input/handlers/chat_list.rs | 42 ++---- src/input/handlers/global.rs | 5 + src/input/handlers/modal.rs | 97 ++++++++++++- src/input/main_input.rs | 7 + src/lib.rs | 1 + src/main.rs | 154 ++++++++++++++++++-- src/tdlib/client.rs | 50 ++++++- src/tdlib/client_impl.rs | 6 + src/tdlib/trait.rs | 8 ++ src/ui/footer.rs | 19 ++- src/ui/mod.rs | 5 + src/ui/modals/account_switcher.rs | 210 ++++++++++++++++++++++++++++ src/ui/modals/mod.rs | 3 + tests/account_switcher.rs | 191 +++++++++++++++++++++++++ tests/accounts.rs | 182 ++++++++++++++++++++++++ tests/helpers/fake_tdclient_impl.rs | 7 + 24 files changed, 1700 insertions(+), 60 deletions(-) create mode 100644 src/accounts/manager.rs create mode 100644 src/accounts/mod.rs create mode 100644 src/accounts/profile.rs create mode 100644 src/ui/modals/account_switcher.rs create mode 100644 tests/account_switcher.rs create mode 100644 tests/accounts.rs diff --git a/CONTEXT.md b/CONTEXT.md index 7d7bc59..d10fbc7 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -1,6 +1,89 @@ # Текущий контекст проекта -## Статус: Multiline Message Display (DONE) +## Статус: Фаза 14 — Мультиаккаунт (IN PROGRESS) + +### Оптимизация: Ленивая загрузка сообщений при открытии чата (DONE) + +Чат открывается мгновенно (< 1 сек) вместо 5-30 сек для больших чатов. + +**Проблема**: `open_chat_and_load_data()` блокировал UI до полной загрузки ВСЕХ сообщений (`get_chat_history(chat_id, i32::MAX)`). Для чата с 500+ сообщениями это 10+ запросов к TDLib. + +**Решение**: +- Загрузка только 50 последних сообщений (один запрос) → чат виден сразу +- Фоновые задачи (reply info, pinned, photos) — на следующем тике main loop через `pending_chat_init` +- Старые сообщения подгружаются при скролле вверх (существующий `load_older_messages_if_needed`) + +**Модифицированные файлы:** +- `src/app/mod.rs` — поле `pending_chat_init: Option` +- `src/input/handlers/chat_list.rs` — `open_chat_and_load_data()`: 50 сообщений + `pending_chat_init` +- `src/main.rs` — обработка `pending_chat_init` в main loop (reply info, pinned, photos) +- `src/app/methods/navigation.rs` — сброс `pending_chat_init` в `close_chat()` + +--- + +### Bugfix: Авто-загрузка фото в чате (DONE) + +Фото не отображались — отсутствовал код загрузки файлов после открытия чата. + +**Проблема**: `extract_media_info()` создавал `PhotoInfo` с `PhotoDownloadState::NotDownloaded`, но никакой код не инициировал `download_file()`. Фото оставались в состоянии "📷 [Фото]" без inline превью. + +**Исправление:** +- **Авто-загрузка при открытии чата**: после загрузки истории сообщений скачиваются фото из последних 30 сообщений (если `auto_download_images = true` и `show_images = true`). Каждый файл — с таймаутом 5 сек. +- **Загрузка по `v`**: вместо "Фото не загружено" — скачивание + открытие модалки. Также повторная попытка при `Error`. +- Обновление `PhotoDownloadState` в сообщении после успешной/неуспешной загрузки. + +**Модифицированные файлы:** +- `src/input/handlers/chat_list.rs` — авто-загрузка фото в `open_chat_and_load_data()` +- `src/input/handlers/chat.rs` — `handle_view_image()`: download on NotDownloaded + retry on Error + +--- + +### Этап 2+3: Account Switcher Modal + Переключение аккаунтов (DONE) + +Реализована модалка переключения аккаунтов и механизм переключения: + +- **Модалка `Ctrl+A`**: глобальный оверлей поверх любого экрана (Loading/Auth/Main) +- **Навигация**: `j/k` по списку, `Enter` выбор, `a` добавление, `Esc` закрытие +- **Переключение**: close TDLib → `recreate_client(new_db_path)` → auth flow +- **Добавление аккаунта**: ввод имени в модалке → валидация → `add_account()` → переключение +- **Footer индикатор**: `[account_name]` если не "default" +- **`AccountSwitcherState`**: enum `SelectAccount` / `AddAccount` — глобальный оверлей в `App` +- **`recreate_client()`**: новый метод в `TdClientTrait` — close old → new TdClient → spawn set_tdlib_parameters + +**Новые файлы:** +- `src/ui/modals/account_switcher.rs` — UI рендеринг (SelectAccount + AddAccount) +- `tests/account_switcher.rs` — 12 тестов + +**Модифицированные файлы:** +- `src/app/mod.rs` — `AccountSwitcherState` enum, 3 поля (`account_switcher`, `current_account_name`, `pending_account_switch`), 8 методов +- `src/accounts/manager.rs` — `add_account()` (validate + save + ensure_dir) +- `src/accounts/mod.rs` — re-export `add_account` +- `src/tdlib/trait.rs` — `recreate_client(&mut self, db_path)` в TdClientTrait +- `src/tdlib/client.rs` — реализация `recreate_client` (close → new → set_tdlib_parameters) +- `src/tdlib/client_impl.rs` — trait impl делегирование +- `tests/helpers/fake_tdclient_impl.rs` — no-op `recreate_client` +- `src/input/main_input.rs` — account_switcher роутинг (highest priority) +- `src/input/handlers/global.rs` — `Ctrl+A` → open_account_switcher +- `src/input/handlers/modal.rs` — `handle_account_switcher()` (SelectAccount + AddAccount input) +- `src/ui/modals/mod.rs` — `pub mod account_switcher;` +- `src/ui/mod.rs` — overlay поверх любого экрана +- `src/ui/footer.rs` — `[account_name]` индикатор +- `src/main.rs` — `pending_account_switch` check в run_app, `Ctrl+A` из любого экрана + +### Этап 1: Инфраструктура профилей аккаунтов (DONE) + +Реализована инфраструктура для мультиаккаунта: + +- **Модуль `accounts/`**: `profile.rs` (типы + валидация) + `manager.rs` (загрузка/сохранение/миграция) +- **`accounts.toml`**: конфиг списка аккаунтов в `~/.config/tele-tui/accounts.toml` +- **XDG data dir**: БД TDLib хранится в `~/.local/share/tele-tui/accounts/{name}/tdlib_data/` +- **Автомиграция**: `./tdlib_data/` → XDG path при первом запуске +- **CLI флаг `--account `**: выбор аккаунта при запуске +- **Параметризация `db_path`**: `TdClient::new(db_path)`, `App::new(config, db_path)` + +--- + +## Предыдущий статус: Multiline Message Display (DONE) ### Multiline в сообщениях diff --git a/ROADMAP.md b/ROADMAP.md index 61b3c58..7a252a5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -14,7 +14,7 @@ | 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 | +| 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 | @@ -48,6 +48,11 @@ - [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]` --- @@ -93,3 +98,73 @@ - **Keybinding conflict:** Left/Right привязаны к MoveLeft/MoveRight и SeekBackward/SeekForward; HashMap iteration order не гарантирован → оба варианта обрабатываются как seek в режиме выбора сообщения - **Платформы:** macOS, Linux (везде где есть ffmpeg) - **Хоткеи:** Space (play/pause), ←/→ (seek ±5s) + +--- + +## Фаза 14: Мультиаккаунт + +**Цель**: поддержка нескольких Telegram-аккаунтов с мгновенным переключением внутри приложения. + +### UI: Индикатор в footer + хоткеи + +``` +┌──────────────┬───────────────────────────┐ +│ Saved Msgs │ Привет! │ +│ Иван Петров │ Как дела? │ +│ Работа чат │ │ +├──────────────┴───────────────────────────┤ +│ [NORMAL] Михаил ⟨1/2⟩ Work(3) │ Ctrl+A │ +└──────────────────────────────────────────┘ +``` + +- **Footer**: текущий аккаунт + номер `⟨1/2⟩` + бейджи непрочитанных с других аккаунтов +- **Быстрое переключение**: `Ctrl+1`..`Ctrl+9` — мгновенный switch без модалки +- **Модалка управления** (`Ctrl+A`): список аккаунтов, добавление/удаление, выбор активного + +### Модалка переключения аккаунтов + +``` +┌──────────────────────────────────┐ +│ Аккаунты │ +│ │ +│ 1. Михаил (+7 900 ...) ● │ ← активный +│ 2. Work (+7 911 ...) (3) │ ← 3 непрочитанных +│ 3. + Добавить аккаунт │ +│ │ +│ [j/k навигация, Enter выбор] │ +│ [d — удалить аккаунт] │ +└──────────────────────────────────┘ +``` + +### Техническая реализация: все клиенты одновременно + +- **Несколько TdClient**: каждый аккаунт — отдельный `TdClient` со своим `database_directory` + - Аккаунт 1: `~/.local/share/tele-tui/accounts/1/tdlib_data/` + - Аккаунт 2: `~/.local/share/tele-tui/accounts/2/tdlib_data/` +- **Все клиенты активны**: polling updates со всех аккаунтов одновременно (уведомления, непрочитанные) +- **Мгновенное переключение**: swap активного `App.td_client` — чаты и сообщения уже загружены +- **Общий конфиг**: `~/.config/tele-tui/config.toml` (один для всех аккаунтов) +- **Профили аккаунтов**: `~/.config/tele-tui/accounts.toml` — список аккаунтов (имя, путь к БД) + +### Этапы + +- [x] **Этап 1: Инфраструктура профилей** (DONE) + - Структура `AccountProfile` (name, display_name, db_path) + - `accounts.toml` — хранение списка аккаунтов + - Миграция `tdlib_data/` → `accounts/default/tdlib_data/` (обратная совместимость) + - CLI: `--account ` для запуска конкретного аккаунта + +- [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) + - Параллельный polling updates со всех аккаунтов diff --git a/src/accounts/manager.rs b/src/accounts/manager.rs new file mode 100644 index 0000000..ee4e0ab --- /dev/null +++ b/src/accounts/manager.rs @@ -0,0 +1,209 @@ +//! 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 { + 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::(&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 { + 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 { + 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) +} diff --git a/src/accounts/mod.rs b/src/accounts/mod.rs new file mode 100644 index 0000000..f4164ca --- /dev/null +++ b/src/accounts/mod.rs @@ -0,0 +1,11 @@ +//! 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 manager; +pub mod profile; + +pub use manager::{add_account, ensure_account_dir, load_or_create, resolve_account, save}; +pub use profile::{account_db_path, validate_account_name, AccountProfile, AccountsConfig}; diff --git a/src/accounts/profile.rs b/src/accounts/profile.rs new file mode 100644 index 0000000..568a9c3 --- /dev/null +++ b/src/accounts/profile.rs @@ -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, +} + +/// 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")); + } +} diff --git a/src/app/methods/navigation.rs b/src/app/methods/navigation.rs index 7e66a97..8099550 100644 --- a/src/app/methods/navigation.rs +++ b/src/app/methods/navigation.rs @@ -82,6 +82,7 @@ impl NavigationMethods for App { self.cursor_position = 0; self.message_scroll_offset = 0; self.last_typing_sent = None; + self.pending_chat_init = None; // Сбрасываем состояние чата в нормальный режим self.chat_state = ChatState::Normal; self.input_mode = InputMode::Normal; diff --git a/src/app/mod.rs b/src/app/mod.rs index b1c8fb7..c510a86 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -13,9 +13,28 @@ pub use chat_state::{ChatState, InputMode}; pub use state::AppScreen; pub use methods::*; +use crate::accounts::AccountProfile; use crate::tdlib::{ChatInfo, TdClient, TdClientTrait}; use crate::types::ChatId; use ratatui::widgets::ListState; +use std::path::PathBuf; + +/// State of the account switcher modal overlay. +#[derive(Debug, Clone)] +pub enum AccountSwitcherState { + /// List of accounts with navigation. + SelectAccount { + accounts: Vec, + selected_index: usize, + current_account: String, + }, + /// Input for new account name. + AddAccount { + name_input: String, + cursor_position: usize, + error: Option, + }, +} /// Main application state for the Telegram TUI client. /// @@ -44,7 +63,7 @@ use ratatui::widgets::ListState; /// use tele_tui::config::Config; /// /// let config = Config::default(); -/// let mut app = App::new(config); +/// let mut app = App::new(config, std::path::PathBuf::from("tdlib_data")); /// /// // Navigate through chats /// app.next_chat(); @@ -102,6 +121,15 @@ pub struct App { /// Время последнего рендеринга изображений (для throttling до 15 FPS) #[cfg(feature = "images")] pub last_image_render_time: Option, + // Account switcher + /// Account switcher modal state (global overlay) + pub account_switcher: Option, + /// Name of the currently active account + pub current_account_name: String, + /// Pending account switch: (account_name, db_path) + pub pending_account_switch: Option<(String, PathBuf)>, + /// Pending background chat init (reply info, pinned, photos) after fast open + pub pending_chat_init: Option, // Voice playback /// Аудиопроигрыватель для голосовых сообщений (rodio) pub audio_player: Option, @@ -164,6 +192,11 @@ impl App { search_query: String::new(), needs_redraw: true, last_typing_sent: None, + // Account switcher + account_switcher: None, + current_account_name: "default".to_string(), + pending_account_switch: None, + pending_chat_init: None, #[cfg(feature = "images")] image_cache, #[cfg(feature = "images")] @@ -210,6 +243,123 @@ impl App { self.status_message = None; } + /// Opens the account switcher modal, loading accounts from config. + pub fn open_account_switcher(&mut self) { + let config = crate::accounts::load_or_create(); + self.account_switcher = Some(AccountSwitcherState::SelectAccount { + accounts: config.accounts, + selected_index: 0, + current_account: self.current_account_name.clone(), + }); + } + + /// Closes the account switcher modal. + pub fn close_account_switcher(&mut self) { + self.account_switcher = None; + } + + /// Navigate to previous item in account switcher list. + pub fn account_switcher_select_prev(&mut self) { + if let Some(AccountSwitcherState::SelectAccount { selected_index, .. }) = + &mut self.account_switcher + { + *selected_index = selected_index.saturating_sub(1); + } + } + + /// Navigate to next item in account switcher list. + pub fn account_switcher_select_next(&mut self) { + if let Some(AccountSwitcherState::SelectAccount { + accounts, + selected_index, + .. + }) = &mut self.account_switcher + { + // +1 for the "Add account" item at the end + let max_index = accounts.len(); + if *selected_index < max_index { + *selected_index += 1; + } + } + } + + /// Confirm selection in account switcher. + /// If on an account: sets pending_account_switch. + /// If on "+ Add": transitions to AddAccount state. + pub fn account_switcher_confirm(&mut self) { + let state = self.account_switcher.take(); + match state { + Some(AccountSwitcherState::SelectAccount { + accounts, + selected_index, + current_account, + }) => { + if selected_index < accounts.len() { + // Selected an existing account + let account = &accounts[selected_index]; + if account.name == current_account { + // Already on this account, just close + self.account_switcher = None; + return; + } + let db_path = account.db_path(); + self.pending_account_switch = Some((account.name.clone(), db_path)); + self.account_switcher = None; + } else { + // Selected "+ Add account" + self.account_switcher = Some(AccountSwitcherState::AddAccount { + name_input: String::new(), + cursor_position: 0, + error: None, + }); + } + } + other => { + self.account_switcher = other; + } + } + } + + /// Switch to AddAccount state from SelectAccount. + pub fn account_switcher_start_add(&mut self) { + self.account_switcher = Some(AccountSwitcherState::AddAccount { + name_input: String::new(), + cursor_position: 0, + error: None, + }); + } + + /// Confirm adding a new account. Validates, saves, and sets pending switch. + pub fn account_switcher_confirm_add(&mut self) { + let state = self.account_switcher.take(); + match state { + Some(AccountSwitcherState::AddAccount { name_input, .. }) => { + match crate::accounts::manager::add_account(&name_input, &name_input) { + Ok(db_path) => { + self.pending_account_switch = Some((name_input, db_path)); + self.account_switcher = None; + } + Err(e) => { + let cursor_pos = name_input.chars().count(); + self.account_switcher = Some(AccountSwitcherState::AddAccount { + name_input, + cursor_position: cursor_pos, + error: Some(e), + }); + } + } + } + other => { + self.account_switcher = other; + } + } + } + + /// Go back from AddAccount to SelectAccount. + pub fn account_switcher_back(&mut self) { + self.open_account_switcher(); + } + /// Get the selected chat info pub fn get_selected_chat(&self) -> Option<&ChatInfo> { self.selected_chat_id @@ -425,16 +575,17 @@ impl App { /// Creates a new App instance with the given configuration and a real TDLib client. /// /// This is a convenience method for production use that automatically creates - /// a new TdClient instance. + /// a new TdClient instance with the specified database path. /// /// # Arguments /// /// * `config` - Application configuration loaded from config.toml + /// * `db_path` - Path to the TDLib database directory for this account /// /// # Returns /// /// A new `App` instance ready to start authentication. - pub fn new(config: crate::config::Config) -> App { - App::with_client(config, TdClient::new()) + pub fn new(config: crate::config::Config, db_path: std::path::PathBuf) -> App { + App::with_client(config, TdClient::new(db_path)) } } diff --git a/src/input/handlers/chat.rs b/src/input/handlers/chat.rs index fd876c1..defeaee 100644 --- a/src/input/handlers/chat.rs +++ b/src/input/handlers/chat.rs @@ -591,15 +591,20 @@ async fn handle_view_image(app: &mut App) { } 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 &photo.download_state { + match download_state { PhotoDownloadState::Downloaded(path) => { // Открываем модальное окно app.image_modal = Some(ImageModalState { - message_id: msg.id(), - photo_path: path.clone(), - photo_width: photo.width, - photo_height: photo.height, + message_id: msg_id, + photo_path: path, + photo_width, + photo_height, }); app.needs_redraw = true; } @@ -607,10 +612,73 @@ async fn handle_view_image(app: &mut App) { app.status_message = Some("Загрузка фото...".to_string()); } PhotoDownloadState::NotDownloaded => { - app.status_message = Some("Фото не загружено".to_string()); + // Скачиваем фото и открываем + 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) => { + for msg in app.td_client.current_chat_messages_mut() { + if let Some(photo) = msg.photo_info_mut() { + if photo.file_id == file_id { + photo.download_state = + PhotoDownloadState::Error(e.clone()); + break; + } + } + } + app.error_message = Some(format!("Ошибка загрузки фото: {}", e)); + app.status_message = None; + } + } } - PhotoDownloadState::Error(e) => { - app.error_message = Some(format!("Ошибка загрузки: {}", e)); + 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; + } + } } } } diff --git a/src/input/handlers/chat_list.rs b/src/input/handlers/chat_list.rs index 0bd8fbf..81dbab2 100644 --- a/src/input/handlers/chat_list.rs +++ b/src/input/handlers/chat_list.rs @@ -10,7 +10,7 @@ use crate::app::InputMode; use crate::app::methods::{compose::ComposeMethods, messages::MessageMethods, navigation::NavigationMethods}; use crate::tdlib::TdClientTrait; use crate::types::{ChatId, MessageId}; -use crate::utils::{with_timeout, with_timeout_msg, with_timeout_ignore}; +use crate::utils::{with_timeout, with_timeout_msg}; use crossterm::event::KeyEvent; use std::time::Duration; @@ -75,24 +75,21 @@ pub async fn select_folder(app: &mut App, folder_idx: usize } } -/// Открывает чат и загружает все необходимые данные. +/// Открывает чат и загружает последние сообщения (быстро). /// -/// Выполняет: -/// - Загрузку истории сообщений (с timeout) -/// - Установку current_chat_id (после загрузки, чтобы избежать race condition) -/// - Загрузку reply info (с timeout) -/// - Загрузку закреплённого сообщения (с timeout) -/// - Загрузку черновика +/// Загружает только 50 последних сообщений для мгновенного отображения. +/// Фоновые задачи (reply info, pinned, photos) откладываются в `pending_chat_init` +/// и выполняются на следующем тике main loop. /// /// При ошибке устанавливает error_message и очищает status_message. pub async fn open_chat_and_load_data(app: &mut App, chat_id: i64) { app.status_message = Some("Загрузка сообщений...".to_string()); app.message_scroll_offset = 0; - // Загружаем все доступные сообщения (без лимита) + // Загружаем только 50 последних сообщений (один запрос к TDLib) match with_timeout_msg( - Duration::from_secs(30), - app.td_client.get_chat_history(ChatId::new(chat_id), i32::MAX), + Duration::from_secs(10), + app.td_client.get_chat_history(ChatId::new(chat_id), 50), "Таймаут загрузки сообщений", ) .await @@ -119,27 +116,16 @@ pub async fn open_chat_and_load_data(app: &mut App, chat_id // Это предотвращает race condition с Update::NewMessage app.td_client.set_current_chat_id(Some(ChatId::new(chat_id))); - // Загружаем недостающие reply info (игнорируем ошибки) - with_timeout_ignore( - Duration::from_secs(5), - app.td_client.fetch_missing_reply_info(), - ) - .await; - - // Загружаем последнее закреплённое сообщение (игнорируем ошибки) - with_timeout_ignore( - Duration::from_secs(2), - app.td_client.load_current_pinned_message(ChatId::new(chat_id)), - ) - .await; - - // Загружаем черновик + // Загружаем черновик (локальная операция, мгновенно) app.load_draft(); - app.status_message = None; - // Vim mode: Normal + MessageSelection по умолчанию + // Показываем чат СРАЗУ + 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); diff --git a/src/input/handlers/global.rs b/src/input/handlers/global.rs index 39ccf61..9799778 100644 --- a/src/input/handlers/global.rs +++ b/src/input/handlers/global.rs @@ -58,6 +58,11 @@ pub async fn handle_global_commands(app: &mut App, key: Key handle_pinned_messages(app).await; true } + KeyCode::Char('a') if has_ctrl => { + // Ctrl+A - переключение аккаунтов + app.open_account_switcher(); + true + } _ => false, } } diff --git a/src/input/handlers/modal.rs b/src/input/handlers/modal.rs index 12616ad..3bf61b6 100644 --- a/src/input/handlers/modal.rs +++ b/src/input/handlers/modal.rs @@ -1,21 +1,114 @@ //! 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 crate::app::App; +use crate::app::{AccountSwitcherState, App}; use crate::app::methods::{modal::ModalMethods, navigation::NavigationMethods}; use crate::tdlib::TdClientTrait; use crate::types::{ChatId, MessageId}; use crate::utils::{with_timeout_msg, modal_handler::handle_yes_no}; use crate::input::handlers::get_available_actions_count; use super::scroll_to_message; -use crossterm::event::KeyEvent; +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( + app: &mut App, + key: KeyEvent, + command: Option, +) { + 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 = 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 = name_input.chars().collect(); + chars.insert(*cursor_position, c); + *name_input = chars.into_iter().collect(); + *cursor_position += 1; + *error = None; + } + } + _ => {} + } + } + } +} + /// Обработка режима профиля пользователя/чата /// /// Обрабатывает: diff --git a/src/input/main_input.rs b/src/input/main_input.rs index 950eb8d..696ac91 100644 --- a/src/input/main_input.rs +++ b/src/input/main_input.rs @@ -16,6 +16,7 @@ use crate::tdlib::TdClientTrait; use crate::input::handlers::{ handle_global_commands, modal::{ + handle_account_switcher, handle_profile_mode, handle_profile_open, handle_delete_confirmation, handle_reaction_picker_mode, handle_pinned_mode, }, @@ -78,6 +79,12 @@ fn handle_escape_insert(app: &mut App) { pub async fn handle(app: &mut App, key: KeyEvent) { let command = app.get_command(key); + // 0. Account switcher (глобальный оверлей — highest priority) + if app.account_switcher.is_some() { + handle_account_switcher(app, key, command).await; + return; + } + // 1. Insert mode + чат открыт → только текст, Enter, Esc // (Ctrl+C обрабатывается в main.rs до вызова router) if app.selected_chat_id.is_some() && app.input_mode == InputMode::Insert { diff --git a/src/lib.rs b/src/lib.rs index bc6361f..4ca43b4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,6 +2,7 @@ //! //! Library interface exposing modules for integration testing. +pub mod accounts; pub mod app; pub mod audio; pub mod config; diff --git a/src/main.rs b/src/main.rs index 912d019..a4fe205 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,4 @@ +mod accounts; mod app; mod audio; mod config; @@ -31,6 +32,21 @@ use input::{handle_auth_input, handle_main_input}; use tdlib::AuthState; use utils::{disable_tdlib_logs, with_timeout_ignore}; +/// Parses `--account ` from CLI arguments. +fn parse_account_arg() -> Option { + let args: Vec = std::env::args().collect(); + let mut i = 1; + while i < args.len() { + if args[i] == "--account" { + if i + 1 < args.len() { + return Some(args[i + 1].clone()); + } + } + i += 1; + } + None +} + #[tokio::main] async fn main() -> Result<(), io::Error> { // Загружаем переменные окружения из .env @@ -48,6 +64,24 @@ async fn main() -> Result<(), io::Error> { // Загружаем конфигурацию (создаёт дефолтный если отсутствует) 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); + // Отключаем логи TDLib ДО создания клиента disable_tdlib_logs(); @@ -66,18 +100,20 @@ async fn main() -> Result<(), io::Error> { panic_hook(info); })); - // Create app state - let mut app = App::new(config); + // Create app state with account-specific db_path + let mut app = App::new(config, db_path); + app.current_account_name = account_name; // Запускаем инициализацию TDLib в фоне (только для реального клиента) let client_id = app.td_client.client_id(); let api_id = app.td_client.api_id; let api_hash = app.td_client.api_hash.clone(); + let db_path_str = app.td_client.db_path.to_string_lossy().to_string(); tokio::spawn(async move { let _ = tdlib_rs::functions::set_tdlib_parameters( false, // use_test_dc - "tdlib_data".to_string(), // database_directory + db_path_str, // database_directory "".to_string(), // files_directory "".to_string(), // database_encryption_key true, // use_file_database @@ -233,12 +269,23 @@ async fn run_app( return Ok(()); } - match app.screen { - AppScreen::Loading => { - // В состоянии загрузки игнорируем ввод + // Ctrl+A opens account switcher from any screen + if key.code == KeyCode::Char('a') + && key.modifiers.contains(KeyModifiers::CONTROL) + && app.account_switcher.is_none() + { + app.open_account_switcher(); + } else if app.account_switcher.is_some() { + // Route to main input handler when account switcher is open + handle_main_input(app, key).await; + } else { + match app.screen { + AppScreen::Loading => { + // В состоянии загрузки игнорируем ввод + } + AppScreen::Auth => handle_auth_input(app, key.code).await, + AppScreen::Main => handle_main_input(app, key).await, } - AppScreen::Auth => handle_auth_input(app, key.code).await, - AppScreen::Main => handle_main_input(app, key).await, } // Любой ввод требует перерисовки @@ -251,6 +298,97 @@ async fn run_app( _ => {} } } + + // Process pending chat initialization (reply info, pinned, photos) + if let Some(chat_id) = app.pending_chat_init.take() { + // Загружаем недостающие reply info (игнорируем ошибки) + with_timeout_ignore( + Duration::from_secs(5), + app.td_client.fetch_missing_reply_info(), + ) + .await; + + // Загружаем последнее закреплённое сообщение (игнорируем ошибки) + with_timeout_ignore( + Duration::from_secs(2), + app.td_client.load_current_pinned_message(chat_id), + ) + .await; + + // Авто-загрузка фото (последние 30 сообщений) + #[cfg(feature = "images")] + { + use crate::tdlib::PhotoDownloadState; + + if app.config().images.auto_download_images && app.config().images.show_images { + let photo_file_ids: Vec = app + .td_client + .current_chat_messages() + .iter() + .rev() + .take(30) + .filter_map(|msg| { + msg.photo_info().and_then(|p| { + matches!(p.download_state, PhotoDownloadState::NotDownloaded) + .then_some(p.file_id) + }) + }) + .collect(); + + for file_id in &photo_file_ids { + if let Ok(Ok(path)) = tokio::time::timeout( + Duration::from_secs(5), + app.td_client.download_file(*file_id), + ) + .await + { + 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); + break; + } + } + } + } + } + } + } + + app.needs_redraw = true; + } + + // Check pending account switch + if let Some((account_name, new_db_path)) = app.pending_account_switch.take() { + // 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; + app.screen = AppScreen::Loading; + 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; + } } } diff --git a/src/tdlib/client.rs b/src/tdlib/client.rs index 4a273d9..2c5d4c7 100644 --- a/src/tdlib/client.rs +++ b/src/tdlib/client.rs @@ -1,5 +1,6 @@ use crate::types::{ChatId, MessageId, UserId}; use std::env; +use std::path::PathBuf; use tdlib_rs::enums::{ ChatList, ConnectionState, Update, UserStatus, Chat as TdChat @@ -32,7 +33,7 @@ use crate::notifications::NotificationManager; /// ```ignore /// use tele_tui::tdlib::TdClient; /// -/// let mut client = TdClient::new(); +/// let mut client = TdClient::new(std::path::PathBuf::from("tdlib_data")); /// /// // Start authorization /// client.send_phone_number("+1234567890".to_string()).await?; @@ -45,6 +46,7 @@ use crate::notifications::NotificationManager; pub struct TdClient { pub api_id: i32, pub api_hash: String, + pub db_path: PathBuf, client_id: i32, // Менеджеры (делегируем им функциональность) @@ -71,7 +73,7 @@ impl TdClient { /// # Returns /// /// A new `TdClient` instance ready for authentication. - pub fn new() -> Self { + pub fn new(db_path: PathBuf) -> Self { // Пробуем загрузить credentials из Config (файл или env) let (api_id, api_hash) = crate::config::Config::load_credentials() .unwrap_or_else(|_| { @@ -89,6 +91,7 @@ impl TdClient { Self { api_id, api_hash, + db_path, client_id, auth: AuthManager::new(client_id), chat_manager: ChatManager::new(client_id), @@ -624,6 +627,49 @@ impl TdClient { } } + /// 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 { + let _ = 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; + }); + + // 4. Replace self + *self = new_client; + + Ok(()) + } + pub fn extract_content_text(content: &tdlib_rs::enums::MessageContent) -> String { use tdlib_rs::enums::MessageContent; match content { diff --git a/src/tdlib/client_impl.rs b/src/tdlib/client_impl.rs index dde71ef..ce8bb28 100644 --- a/src/tdlib/client_impl.rs +++ b/src/tdlib/client_impl.rs @@ -7,6 +7,7 @@ use super::r#trait::TdClientTrait; use super::{AuthState, ChatInfo, FolderInfo, MessageInfo, ProfileInfo, ReplyInfo, UserCache, UserOnlineStatus}; use crate::types::{ChatId, MessageId, UserId}; use async_trait::async_trait; +use std::path::PathBuf; use tdlib_rs::enums::{ChatAction, Update}; #[async_trait] @@ -278,6 +279,11 @@ impl TdClientTrait for TdClient { self.notification_manager.sync_muted_chats(&self.chat_manager.chats); } + // ============ Account switching ============ + async fn recreate_client(&mut self, db_path: PathBuf) -> Result<(), String> { + TdClient::recreate_client(self, db_path).await + } + // ============ Update handling ============ fn handle_update(&mut self, update: Update) { // Delegate to the real implementation diff --git a/src/tdlib/trait.rs b/src/tdlib/trait.rs index 087dc19..826e522 100644 --- a/src/tdlib/trait.rs +++ b/src/tdlib/trait.rs @@ -5,6 +5,7 @@ use crate::tdlib::{AuthState, FolderInfo, MessageInfo, ProfileInfo, UserCache, UserOnlineStatus}; use crate::types::{ChatId, MessageId, UserId}; use async_trait::async_trait; +use std::path::PathBuf; use tdlib_rs::enums::{ChatAction, Update}; use super::ChatInfo; @@ -127,6 +128,13 @@ pub trait TdClientTrait: Send { // ============ Notification methods ============ fn sync_notification_muted_chats(&mut self); + // ============ Account switching ============ + /// Recreates the client with a new database path (for account switching). + /// + /// For real TdClient: closes old client, creates new one, inits TDLib parameters. + /// For FakeTdClient: no-op. + async fn recreate_client(&mut self, db_path: PathBuf) -> Result<(), String>; + // ============ Update handling ============ fn handle_update(&mut self, update: Update); } diff --git a/src/ui/footer.rs b/src/ui/footer.rs index d3837fa..2daed6e 100644 --- a/src/ui/footer.rs +++ b/src/ui/footer.rs @@ -19,22 +19,29 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { NetworkState::Updating => "⏳ Обновление... | ", }; + // Account indicator (shown if not "default") + let account_indicator = if app.current_account_name != "default" { + format!("[{}] ", app.current_account_name) + } else { + String::new() + }; + let 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 { - format!(" {}Error: {} ", network_indicator, err) + format!(" {}{}Error: {} ", account_indicator, network_indicator, err) } 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() { 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 ", network_indicator, mode_str) + format!(" {}{}{} | Ctrl+C: Quit ", account_indicator, network_indicator, mode_str) } else { format!( - " {}↑/↓: Navigate | Enter: Open | Ctrl+S: Search | Ctrl+R: Refresh | Ctrl+C: Quit ", - network_indicator + " {}{}↑/↓: Navigate | Enter: Open | Ctrl+S: Search | Ctrl+R: Refresh | Ctrl+C: Quit ", + account_indicator, network_indicator ) }; diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 7423ee1..ff0b766 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -39,6 +39,11 @@ pub fn render(f: &mut Frame, app: &mut App) { AppScreen::Auth => auth::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) { diff --git a/src/ui/modals/account_switcher.rs b/src/ui/modals/account_switcher.rs new file mode 100644 index 0000000..106c711 --- /dev/null +++ b/src/ui/modals/account_switcher.rs @@ -0,0 +1,210 @@ +//! 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(f: &mut Frame, area: Rect, app: &App) { + 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 = 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 = 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); +} diff --git a/src/ui/modals/mod.rs b/src/ui/modals/mod.rs index 84e0b81..25f5337 100644 --- a/src/ui/modals/mod.rs +++ b/src/ui/modals/mod.rs @@ -1,12 +1,14 @@ //! Modal dialog rendering modules //! //! Contains UI rendering for various modal dialogs: +//! - account_switcher: Account switcher modal (global overlay) //! - delete_confirm: Delete confirmation modal //! - reaction_picker: Emoji reaction picker modal //! - search: Message search modal //! - pinned: Pinned messages viewer modal //! - image_viewer: Full-screen image viewer modal (images feature) +pub mod account_switcher; pub mod delete_confirm; pub mod reaction_picker; pub mod search; @@ -15,6 +17,7 @@ pub mod pinned; #[cfg(feature = "images")] pub mod image_viewer; +pub use account_switcher::render as render_account_switcher; pub use delete_confirm::render as render_delete_confirm; pub use reaction_picker::render as render_reaction_picker; pub use search::render as render_search; diff --git a/tests/account_switcher.rs b/tests/account_switcher.rs new file mode 100644 index 0000000..16522f0 --- /dev/null +++ b/tests/account_switcher.rs @@ -0,0 +1,191 @@ +// Integration tests for account switcher modal + +mod helpers; + +use helpers::app_builder::TestAppBuilder; +use helpers::test_data::create_test_chat; +use tele_tui::app::AccountSwitcherState; + +// ============ Open/Close Tests ============ + +#[test] +fn test_open_account_switcher() { + let mut app = TestAppBuilder::new().build(); + assert!(app.account_switcher.is_none()); + + app.open_account_switcher(); + + assert!(app.account_switcher.is_some()); + match &app.account_switcher { + Some(AccountSwitcherState::SelectAccount { + accounts, + selected_index, + current_account, + }) => { + assert!(!accounts.is_empty()); + assert_eq!(*selected_index, 0); + assert_eq!(current_account, "default"); + } + _ => panic!("Expected SelectAccount state"), + } +} + +#[test] +fn test_close_account_switcher() { + let mut app = TestAppBuilder::new().build(); + app.open_account_switcher(); + assert!(app.account_switcher.is_some()); + + app.close_account_switcher(); + assert!(app.account_switcher.is_none()); +} + +// ============ Navigation Tests ============ + +#[test] +fn test_account_switcher_navigate_down() { + let mut app = TestAppBuilder::new().build(); + app.open_account_switcher(); + + // Initially at 0, navigate down to "Add account" item + app.account_switcher_select_next(); + + match &app.account_switcher { + Some(AccountSwitcherState::SelectAccount { + selected_index, + accounts, + .. + }) => { + // Should be at index 1 (the "Add account" item, since default config has 1 account) + assert_eq!(*selected_index, accounts.len()); + } + _ => panic!("Expected SelectAccount state"), + } +} + +#[test] +fn test_account_switcher_navigate_up() { + let mut app = TestAppBuilder::new().build(); + app.open_account_switcher(); + + // Navigate down first + app.account_switcher_select_next(); + // Navigate back up + app.account_switcher_select_prev(); + + match &app.account_switcher { + Some(AccountSwitcherState::SelectAccount { selected_index, .. }) => { + assert_eq!(*selected_index, 0); + } + _ => panic!("Expected SelectAccount state"), + } +} + +#[test] +fn test_account_switcher_navigate_up_at_top() { + let mut app = TestAppBuilder::new().build(); + app.open_account_switcher(); + + // Already at 0, navigate up should stay at 0 + app.account_switcher_select_prev(); + + match &app.account_switcher { + Some(AccountSwitcherState::SelectAccount { selected_index, .. }) => { + assert_eq!(*selected_index, 0); + } + _ => panic!("Expected SelectAccount state"), + } +} + +// ============ Confirm Tests ============ + +#[test] +fn test_confirm_current_account_closes_modal() { + let mut app = TestAppBuilder::new().build(); + app.open_account_switcher(); + + // Confirm on the current account (default) should just close + app.account_switcher_confirm(); + + assert!(app.account_switcher.is_none()); + assert!(app.pending_account_switch.is_none()); +} + +#[test] +fn test_confirm_add_account_transitions_to_add_state() { + let mut app = TestAppBuilder::new().build(); + app.open_account_switcher(); + + // Navigate to "+ Add account" + app.account_switcher_select_next(); + + // Confirm should transition to AddAccount + app.account_switcher_confirm(); + + match &app.account_switcher { + Some(AccountSwitcherState::AddAccount { + name_input, + cursor_position, + error, + }) => { + assert!(name_input.is_empty()); + assert_eq!(*cursor_position, 0); + assert!(error.is_none()); + } + _ => panic!("Expected AddAccount state"), + } +} + +// ============ Add Account State Tests ============ + +#[test] +fn test_start_add_from_select() { + let mut app = TestAppBuilder::new().build(); + app.open_account_switcher(); + + // Use quick shortcut + app.account_switcher_start_add(); + + match &app.account_switcher { + Some(AccountSwitcherState::AddAccount { .. }) => {} + _ => panic!("Expected AddAccount state"), + } +} + +#[test] +fn test_back_from_add_to_select() { + let mut app = TestAppBuilder::new().build(); + app.open_account_switcher(); + app.account_switcher_start_add(); + + // Go back + app.account_switcher_back(); + + match &app.account_switcher { + Some(AccountSwitcherState::SelectAccount { .. }) => {} + _ => panic!("Expected SelectAccount state after back"), + } +} + +// ============ Footer Tests ============ + +#[test] +fn test_default_account_name() { + let app = TestAppBuilder::new().build(); + assert_eq!(app.current_account_name, "default"); +} + +#[test] +fn test_custom_account_name() { + let mut app = TestAppBuilder::new().build(); + app.current_account_name = "work".to_string(); + assert_eq!(app.current_account_name, "work"); +} + +// ============ Pending Switch Tests ============ + +#[test] +fn test_pending_switch_initially_none() { + let app = TestAppBuilder::new().build(); + assert!(app.pending_account_switch.is_none()); +} diff --git a/tests/accounts.rs b/tests/accounts.rs new file mode 100644 index 0000000..6eea1f7 --- /dev/null +++ b/tests/accounts.rs @@ -0,0 +1,182 @@ +// Integration tests for accounts module + +use tele_tui::accounts::{ + account_db_path, validate_account_name, AccountProfile, AccountsConfig, +}; + +#[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"); + assert_eq!(config.accounts[0].display_name, "Default"); +} + +#[test] +fn test_find_account_exists() { + let config = AccountsConfig::default_single(); + let account = config.find_account("default"); + assert!(account.is_some()); + assert_eq!(account.unwrap().name, "default"); +} + +#[test] +fn test_find_account_not_found() { + let config = AccountsConfig::default_single(); + assert!(config.find_account("work").is_none()); + assert!(config.find_account("").is_none()); +} + +#[test] +fn test_db_path_structure() { + let path = account_db_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("tdlib_data")); +} + +#[test] +fn test_db_path_per_account() { + let path_default = account_db_path("default"); + let path_work = account_db_path("work"); + + assert_ne!(path_default, path_work); + assert!(path_default.to_string_lossy().contains("default")); + assert!(path_work.to_string_lossy().contains("work")); +} + +#[test] +fn test_account_profile_db_path() { + let profile = AccountProfile { + name: "test-account".to_string(), + display_name: "Test".to_string(), + }; + let path = profile.db_path(); + assert!(path.to_string_lossy().contains("test-account")); + assert!(path.to_string_lossy().ends_with("tdlib_data")); +} + +#[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("account123").is_ok()); + assert!(validate_account_name("test_account").is_ok()); + assert!(validate_account_name("a").is_ok()); +} + +#[test] +fn test_validate_account_name_empty() { + let err = validate_account_name("").unwrap_err(); + assert!(err.contains("empty")); +} + +#[test] +fn test_validate_account_name_too_long() { + let long_name = "a".repeat(33); + let err = validate_account_name(&long_name).unwrap_err(); + assert!(err.contains("32")); +} + +#[test] +fn test_validate_account_name_uppercase() { + assert!(validate_account_name("MyAccount").is_err()); + assert!(validate_account_name("WORK").is_err()); +} + +#[test] +fn test_validate_account_name_spaces() { + assert!(validate_account_name("my account").is_err()); +} + +#[test] +fn test_validate_account_name_starts_with_dash() { + assert!(validate_account_name("-bad").is_err()); +} + +#[test] +fn test_validate_account_name_starts_with_underscore() { + assert!(validate_account_name("_bad").is_err()); +} + +#[test] +fn test_validate_account_name_special_chars() { + assert!(validate_account_name("foo@bar").is_err()); + assert!(validate_account_name("foo.bar").is_err()); + assert!(validate_account_name("foo/bar").is_err()); +} + +#[test] +fn test_resolve_account_default() { + let config = AccountsConfig::default_single(); + let result = tele_tui::accounts::resolve_account(&config, None); + assert!(result.is_ok()); + let (name, path) = result.unwrap(); + assert_eq!(name, "default"); + assert!(path.to_string_lossy().contains("default")); +} + +#[test] +fn test_resolve_account_explicit() { + let config = AccountsConfig::default_single(); + let result = tele_tui::accounts::resolve_account(&config, Some("default")); + assert!(result.is_ok()); + let (name, _) = result.unwrap(); + assert_eq!(name, "default"); +} + +#[test] +fn test_resolve_account_not_found() { + let config = AccountsConfig::default_single(); + let result = tele_tui::accounts::resolve_account(&config, Some("work")); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(err.contains("work")); + assert!(err.contains("not found")); +} + +#[test] +fn test_resolve_account_invalid_name() { + let config = AccountsConfig::default_single(); + let result = tele_tui::accounts::resolve_account(&config, Some("BAD NAME")); + assert!(result.is_err()); +} + +#[test] +fn test_accounts_config_serde_roundtrip() { + let config = AccountsConfig::default_single(); + let toml_str = toml::to_string_pretty(&config).unwrap(); + let parsed: AccountsConfig = toml::from_str(&toml_str).unwrap(); + + assert_eq!(parsed.default_account, config.default_account); + assert_eq!(parsed.accounts.len(), config.accounts.len()); + assert_eq!(parsed.accounts[0].name, config.accounts[0].name); +} + +#[test] +fn test_accounts_config_multi_account_serde() { + let config = AccountsConfig { + default_account: "default".to_string(), + accounts: vec![ + AccountProfile { + name: "default".to_string(), + display_name: "Default".to_string(), + }, + AccountProfile { + name: "work".to_string(), + display_name: "Work".to_string(), + }, + ], + }; + + let toml_str = toml::to_string_pretty(&config).unwrap(); + let parsed: AccountsConfig = toml::from_str(&toml_str).unwrap(); + + assert_eq!(parsed.accounts.len(), 2); + assert!(parsed.find_account("work").is_some()); +} diff --git a/tests/helpers/fake_tdclient_impl.rs b/tests/helpers/fake_tdclient_impl.rs index 550d512..4a27238 100644 --- a/tests/helpers/fake_tdclient_impl.rs +++ b/tests/helpers/fake_tdclient_impl.rs @@ -2,6 +2,7 @@ use super::fake_tdclient::FakeTdClient; use async_trait::async_trait; +use std::path::PathBuf; use tdlib_rs::enums::{ChatAction, Update}; use tele_tui::tdlib::{AuthState, ChatInfo, FolderInfo, MessageInfo, ProfileInfo, ReplyInfo, UserCache, UserOnlineStatus}; use tele_tui::tdlib::TdClientTrait; @@ -314,6 +315,12 @@ impl TdClientTrait for FakeTdClient { // Not implemented for fake client (notifications are not tested) } + // ============ Account switching ============ + async fn recreate_client(&mut self, _db_path: PathBuf) -> Result<(), String> { + // No-op for fake client + Ok(()) + } + // ============ Update handling ============ fn handle_update(&mut self, _update: Update) { // Not implemented for fake client