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
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:
85
CONTEXT.md
85
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<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 в сообщениях
|
||||
|
||||
|
||||
77
ROADMAP.md
77
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 <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
209
src/accounts/manager.rs
Normal 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
11
src/accounts/mod.rs
Normal 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
147
src/accounts/profile.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
159
src/app/mod.rs
159
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<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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Обработка режима профиля пользователя/чата
|
||||
///
|
||||
/// Обрабатывает:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//!
|
||||
//! Library interface exposing modules for integration testing.
|
||||
|
||||
pub mod accounts;
|
||||
pub mod app;
|
||||
pub mod audio;
|
||||
pub mod config;
|
||||
|
||||
144
src/main.rs
144
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 <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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
};
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
210
src/ui/modals/account_switcher.rs
Normal file
210
src/ui/modals/account_switcher.rs
Normal 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);
|
||||
}
|
||||
@@ -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
191
tests/account_switcher.rs
Normal 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
182
tests/accounts.rs
Normal 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());
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user