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

This commit is contained in:
Mikhail Kilin
2026-02-14 17:57:37 +03:00
parent 6639dc876c
commit 8bd08318bb
24 changed files with 1700 additions and 60 deletions

View File

@@ -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<ChatId>`
- `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<T>`
- **`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 <name>`**: выбор аккаунта при запуске
- **Параметризация `db_path`**: `TdClient::new(db_path)`, `App::new(config, db_path)`
---
## Предыдущий статус: Multiline Message Display (DONE)
### Multiline в сообщениях

View File

@@ -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 <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)
- Параллельный polling updates со всех аккаунтов

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

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

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

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

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

@@ -82,6 +82,7 @@ impl<T: TdClientTrait> NavigationMethods<T> for App<T> {
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;

View File

@@ -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<AccountProfile>,
selected_index: usize,
current_account: String,
},
/// Input for new account name.
AddAccount {
name_input: String,
cursor_position: usize,
error: Option<String>,
},
}
/// 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<T: TdClientTrait = TdClient> {
/// Время последнего рендеринга изображений (для throttling до 15 FPS)
#[cfg(feature = "images")]
pub last_image_render_time: Option<std::time::Instant>,
// Account switcher
/// Account switcher modal state (global overlay)
pub account_switcher: Option<AccountSwitcherState>,
/// 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<ChatId>,
// Voice playback
/// Аудиопроигрыватель для голосовых сообщений (rodio)
pub audio_player: Option<crate::audio::AudioPlayer>,
@@ -164,6 +192,11 @@ impl<T: TdClientTrait> App<T> {
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<T: TdClientTrait> App<T> {
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<TdClient> {
/// 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<TdClient>` instance ready to start authentication.
pub fn new(config: crate::config::Config) -> App<TdClient> {
App::with_client(config, TdClient::new())
pub fn new(config: crate::config::Config, db_path: std::path::PathBuf) -> App<TdClient> {
App::with_client(config, TdClient::new(db_path))
}
}

View File

@@ -591,15 +591,20 @@ async fn handle_view_image<T: TdClientTrait>(app: &mut App<T>) {
}
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<T: TdClientTrait>(app: &mut App<T>) {
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(_) => {
// Повторная попытка загрузки
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;
}
}
PhotoDownloadState::Error(e) => {
app.error_message = Some(format!("Ошибка загрузки: {}", e));
}
}
}

View File

@@ -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<T: TdClientTrait>(app: &mut App<T>, 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<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(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<T: TdClientTrait>(app: &mut App<T>, 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);

View File

@@ -58,6 +58,11 @@ pub async fn handle_global_commands<T: TdClientTrait>(app: &mut App<T>, key: Key
handle_pinned_messages(app).await;
true
}
KeyCode::Char('a') if has_ctrl => {
// Ctrl+A - переключение аккаунтов
app.open_account_switcher();
true
}
_ => false,
}
}

View File

@@ -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<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;
}
}
_ => {}
}
}
}
}
/// Обработка режима профиля пользователя/чата
///
/// Обрабатывает:

View File

@@ -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<T: TdClientTrait>(app: &mut App<T>) {
pub async fn handle<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent) {
let command = app.get_command(key);
// 0. Account switcher (глобальный оверлей — highest priority)
if app.account_switcher.is_some() {
handle_account_switcher(app, key, command).await;
return;
}
// 1. Insert mode + чат открыт → только текст, Enter, Esc
// (Ctrl+C обрабатывается в main.rs до вызова router)
if app.selected_chat_id.is_some() && app.input_mode == InputMode::Insert {

View File

@@ -2,6 +2,7 @@
//!
//! Library interface exposing modules for integration testing.
pub mod accounts;
pub mod app;
pub mod audio;
pub mod config;

View File

@@ -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 <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" {
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,6 +269,16 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
return Ok(());
}
// 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 => {
// В состоянии загрузки игнорируем ввод
@@ -240,6 +286,7 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
AppScreen::Auth => handle_auth_input(app, key.code).await,
AppScreen::Main => handle_main_input(app, key).await,
}
}
// Любой ввод требует перерисовки
app.needs_redraw = true;
@@ -251,6 +298,97 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
_ => {}
}
}
// Process pending chat initialization (reply info, pinned, photos)
if let Some(chat_id) = app.pending_chat_init.take() {
// Загружаем недостающие reply info (игнорируем ошибки)
with_timeout_ignore(
Duration::from_secs(5),
app.td_client.fetch_missing_reply_info(),
)
.await;
// Загружаем последнее закреплённое сообщение (игнорируем ошибки)
with_timeout_ignore(
Duration::from_secs(2),
app.td_client.load_current_pinned_message(chat_id),
)
.await;
// Авто-загрузка фото (последние 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<i32> = 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;
}
}
}

View File

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

View File

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

View File

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

View File

@@ -19,22 +19,29 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
NetworkState::Updating => "⏳ Обновление... | ",
};
// Account indicator (shown if not "default")
let account_indicator = if app.current_account_name != "default" {
format!("[{}] ", app.current_account_name)
} else {
String::new()
};
let 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
)
};

View File

@@ -39,6 +39,11 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, app: &mut App<T>) {
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) {

View File

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

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

191
tests/account_switcher.rs Normal file
View File

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

182
tests/accounts.rs Normal file
View File

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

View File

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