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 в сообщениях
|
### Multiline в сообщениях
|
||||||
|
|
||||||
|
|||||||
77
ROADMAP.md
77
ROADMAP.md
@@ -14,7 +14,7 @@
|
|||||||
| 8 | Дополнительные фичи | Markdown, edit/delete, reply/forward, блочный курсор |
|
| 8 | Дополнительные фичи | Markdown, edit/delete, reply/forward, блочный курсор |
|
||||||
| 9 | Расширенные возможности | Typing, pinned, поиск в чате, черновики, профиль, копирование, реакции, конфиг |
|
| 9 | Расширенные возможности | Typing, pinned, поиск в чате, черновики, профиль, копирование, реакции, конфиг |
|
||||||
| 10 | Desktop уведомления (83%) | notify-rust, muted фильтр, mentions, медиа. TODO: кастомные звуки |
|
| 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 |
|
| 12 | Голосовые сообщения | ffplay player, pause/resume with seek, VoiceCache, AudioConfig, progress bar + waveform UI |
|
||||||
| 13 | Глубокий рефакторинг | 5 файлов (4582->модули), 5 traits, shared components, docs |
|
| 13 | Глубокий рефакторинг | 5 файлов (4582->модули), 5 traits, shared components, docs |
|
||||||
|
|
||||||
@@ -48,6 +48,11 @@
|
|||||||
- [x] **UI модули**:
|
- [x] **UI модули**:
|
||||||
- `modals/image_viewer.rs`: fullscreen modal
|
- `modals/image_viewer.rs`: fullscreen modal
|
||||||
- `messages.rs`: throttled second-pass rendering
|
- `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 в режиме выбора сообщения
|
- **Keybinding conflict:** Left/Right привязаны к MoveLeft/MoveRight и SeekBackward/SeekForward; HashMap iteration order не гарантирован → оба варианта обрабатываются как seek в режиме выбора сообщения
|
||||||
- **Платформы:** macOS, Linux (везде где есть ffmpeg)
|
- **Платформы:** macOS, Linux (везде где есть ffmpeg)
|
||||||
- **Хоткеи:** Space (play/pause), ←/→ (seek ±5s)
|
- **Хоткеи:** 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.cursor_position = 0;
|
||||||
self.message_scroll_offset = 0;
|
self.message_scroll_offset = 0;
|
||||||
self.last_typing_sent = None;
|
self.last_typing_sent = None;
|
||||||
|
self.pending_chat_init = None;
|
||||||
// Сбрасываем состояние чата в нормальный режим
|
// Сбрасываем состояние чата в нормальный режим
|
||||||
self.chat_state = ChatState::Normal;
|
self.chat_state = ChatState::Normal;
|
||||||
self.input_mode = InputMode::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 state::AppScreen;
|
||||||
pub use methods::*;
|
pub use methods::*;
|
||||||
|
|
||||||
|
use crate::accounts::AccountProfile;
|
||||||
use crate::tdlib::{ChatInfo, TdClient, TdClientTrait};
|
use crate::tdlib::{ChatInfo, TdClient, TdClientTrait};
|
||||||
use crate::types::ChatId;
|
use crate::types::ChatId;
|
||||||
use ratatui::widgets::ListState;
|
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.
|
/// Main application state for the Telegram TUI client.
|
||||||
///
|
///
|
||||||
@@ -44,7 +63,7 @@ use ratatui::widgets::ListState;
|
|||||||
/// use tele_tui::config::Config;
|
/// use tele_tui::config::Config;
|
||||||
///
|
///
|
||||||
/// let config = Config::default();
|
/// 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
|
/// // Navigate through chats
|
||||||
/// app.next_chat();
|
/// app.next_chat();
|
||||||
@@ -102,6 +121,15 @@ pub struct App<T: TdClientTrait = TdClient> {
|
|||||||
/// Время последнего рендеринга изображений (для throttling до 15 FPS)
|
/// Время последнего рендеринга изображений (для throttling до 15 FPS)
|
||||||
#[cfg(feature = "images")]
|
#[cfg(feature = "images")]
|
||||||
pub last_image_render_time: Option<std::time::Instant>,
|
pub last_image_render_time: Option<std::time::Instant>,
|
||||||
|
// 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
|
// Voice playback
|
||||||
/// Аудиопроигрыватель для голосовых сообщений (rodio)
|
/// Аудиопроигрыватель для голосовых сообщений (rodio)
|
||||||
pub audio_player: Option<crate::audio::AudioPlayer>,
|
pub audio_player: Option<crate::audio::AudioPlayer>,
|
||||||
@@ -164,6 +192,11 @@ impl<T: TdClientTrait> App<T> {
|
|||||||
search_query: String::new(),
|
search_query: String::new(),
|
||||||
needs_redraw: true,
|
needs_redraw: true,
|
||||||
last_typing_sent: None,
|
last_typing_sent: None,
|
||||||
|
// Account switcher
|
||||||
|
account_switcher: None,
|
||||||
|
current_account_name: "default".to_string(),
|
||||||
|
pending_account_switch: None,
|
||||||
|
pending_chat_init: None,
|
||||||
#[cfg(feature = "images")]
|
#[cfg(feature = "images")]
|
||||||
image_cache,
|
image_cache,
|
||||||
#[cfg(feature = "images")]
|
#[cfg(feature = "images")]
|
||||||
@@ -210,6 +243,123 @@ impl<T: TdClientTrait> App<T> {
|
|||||||
self.status_message = None;
|
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
|
/// Get the selected chat info
|
||||||
pub fn get_selected_chat(&self) -> Option<&ChatInfo> {
|
pub fn get_selected_chat(&self) -> Option<&ChatInfo> {
|
||||||
self.selected_chat_id
|
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.
|
/// 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
|
/// 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
|
/// # Arguments
|
||||||
///
|
///
|
||||||
/// * `config` - Application configuration loaded from config.toml
|
/// * `config` - Application configuration loaded from config.toml
|
||||||
|
/// * `db_path` - Path to the TDLib database directory for this account
|
||||||
///
|
///
|
||||||
/// # Returns
|
/// # Returns
|
||||||
///
|
///
|
||||||
/// A new `App<TdClient>` instance ready to start authentication.
|
/// A new `App<TdClient>` instance ready to start authentication.
|
||||||
pub fn new(config: crate::config::Config) -> App<TdClient> {
|
pub fn new(config: crate::config::Config, db_path: std::path::PathBuf) -> App<TdClient> {
|
||||||
App::with_client(config, TdClient::new())
|
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 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) => {
|
PhotoDownloadState::Downloaded(path) => {
|
||||||
// Открываем модальное окно
|
// Открываем модальное окно
|
||||||
app.image_modal = Some(ImageModalState {
|
app.image_modal = Some(ImageModalState {
|
||||||
message_id: msg.id(),
|
message_id: msg_id,
|
||||||
photo_path: path.clone(),
|
photo_path: path,
|
||||||
photo_width: photo.width,
|
photo_width,
|
||||||
photo_height: photo.height,
|
photo_height,
|
||||||
});
|
});
|
||||||
app.needs_redraw = true;
|
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());
|
app.status_message = Some("Загрузка фото...".to_string());
|
||||||
}
|
}
|
||||||
PhotoDownloadState::NotDownloaded => {
|
PhotoDownloadState::NotDownloaded => {
|
||||||
app.status_message = Some("Фото не загружено".to_string());
|
// Скачиваем фото и открываем
|
||||||
|
app.status_message = Some("Загрузка фото...".to_string());
|
||||||
|
app.needs_redraw = true;
|
||||||
|
match app.td_client.download_file(file_id).await {
|
||||||
|
Ok(path) => {
|
||||||
|
// Обновляем состояние загрузки в сообщении
|
||||||
|
for msg in app.td_client.current_chat_messages_mut() {
|
||||||
|
if let Some(photo) = msg.photo_info_mut() {
|
||||||
|
if photo.file_id == file_id {
|
||||||
|
photo.download_state =
|
||||||
|
PhotoDownloadState::Downloaded(path.clone());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Открываем модалку
|
||||||
|
app.image_modal = Some(ImageModalState {
|
||||||
|
message_id: msg_id,
|
||||||
|
photo_path: path,
|
||||||
|
photo_width,
|
||||||
|
photo_height,
|
||||||
|
});
|
||||||
|
app.status_message = None;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
for msg in app.td_client.current_chat_messages_mut() {
|
||||||
|
if let Some(photo) = msg.photo_info_mut() {
|
||||||
|
if photo.file_id == file_id {
|
||||||
|
photo.download_state =
|
||||||
|
PhotoDownloadState::Error(e.clone());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
app.error_message = Some(format!("Ошибка загрузки фото: {}", e));
|
||||||
|
app.status_message = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
PhotoDownloadState::Error(e) => {
|
PhotoDownloadState::Error(_) => {
|
||||||
app.error_message = Some(format!("Ошибка загрузки: {}", e));
|
// Повторная попытка загрузки
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use crate::app::InputMode;
|
|||||||
use crate::app::methods::{compose::ComposeMethods, messages::MessageMethods, navigation::NavigationMethods};
|
use crate::app::methods::{compose::ComposeMethods, messages::MessageMethods, navigation::NavigationMethods};
|
||||||
use crate::tdlib::TdClientTrait;
|
use crate::tdlib::TdClientTrait;
|
||||||
use crate::types::{ChatId, MessageId};
|
use crate::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 crossterm::event::KeyEvent;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
@@ -75,24 +75,21 @@ pub async fn select_folder<T: TdClientTrait>(app: &mut App<T>, folder_idx: usize
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Открывает чат и загружает все необходимые данные.
|
/// Открывает чат и загружает последние сообщения (быстро).
|
||||||
///
|
///
|
||||||
/// Выполняет:
|
/// Загружает только 50 последних сообщений для мгновенного отображения.
|
||||||
/// - Загрузку истории сообщений (с timeout)
|
/// Фоновые задачи (reply info, pinned, photos) откладываются в `pending_chat_init`
|
||||||
/// - Установку current_chat_id (после загрузки, чтобы избежать race condition)
|
/// и выполняются на следующем тике main loop.
|
||||||
/// - Загрузку reply info (с timeout)
|
|
||||||
/// - Загрузку закреплённого сообщения (с timeout)
|
|
||||||
/// - Загрузку черновика
|
|
||||||
///
|
///
|
||||||
/// При ошибке устанавливает error_message и очищает status_message.
|
/// При ошибке устанавливает error_message и очищает status_message.
|
||||||
pub async fn open_chat_and_load_data<T: TdClientTrait>(app: &mut App<T>, chat_id: i64) {
|
pub async fn open_chat_and_load_data<T: TdClientTrait>(app: &mut App<T>, chat_id: i64) {
|
||||||
app.status_message = Some("Загрузка сообщений...".to_string());
|
app.status_message = Some("Загрузка сообщений...".to_string());
|
||||||
app.message_scroll_offset = 0;
|
app.message_scroll_offset = 0;
|
||||||
|
|
||||||
// Загружаем все доступные сообщения (без лимита)
|
// Загружаем только 50 последних сообщений (один запрос к TDLib)
|
||||||
match with_timeout_msg(
|
match with_timeout_msg(
|
||||||
Duration::from_secs(30),
|
Duration::from_secs(10),
|
||||||
app.td_client.get_chat_history(ChatId::new(chat_id), i32::MAX),
|
app.td_client.get_chat_history(ChatId::new(chat_id), 50),
|
||||||
"Таймаут загрузки сообщений",
|
"Таймаут загрузки сообщений",
|
||||||
)
|
)
|
||||||
.await
|
.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
|
// Это предотвращает race condition с Update::NewMessage
|
||||||
app.td_client.set_current_chat_id(Some(ChatId::new(chat_id)));
|
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.load_draft();
|
||||||
app.status_message = None;
|
|
||||||
|
|
||||||
// Vim mode: Normal + MessageSelection по умолчанию
|
// Показываем чат СРАЗУ
|
||||||
|
app.status_message = None;
|
||||||
app.input_mode = InputMode::Normal;
|
app.input_mode = InputMode::Normal;
|
||||||
app.start_message_selection();
|
app.start_message_selection();
|
||||||
|
|
||||||
|
// Фоновые задачи (reply info, pinned, photos) — на следующем тике main loop
|
||||||
|
app.pending_chat_init = Some(ChatId::new(chat_id));
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
app.error_message = Some(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;
|
handle_pinned_messages(app).await;
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
KeyCode::Char('a') if has_ctrl => {
|
||||||
|
// Ctrl+A - переключение аккаунтов
|
||||||
|
app.open_account_switcher();
|
||||||
|
true
|
||||||
|
}
|
||||||
_ => false,
|
_ => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,114 @@
|
|||||||
//! Modal dialog handlers
|
//! Modal dialog handlers
|
||||||
//!
|
//!
|
||||||
//! Handles keyboard input for modal dialogs, including:
|
//! Handles keyboard input for modal dialogs, including:
|
||||||
|
//! - Account switcher (global overlay)
|
||||||
//! - Delete confirmation
|
//! - Delete confirmation
|
||||||
//! - Reaction picker (emoji selector)
|
//! - Reaction picker (emoji selector)
|
||||||
//! - Pinned messages view
|
//! - Pinned messages view
|
||||||
//! - Profile information modal
|
//! - Profile information modal
|
||||||
|
|
||||||
use crate::app::App;
|
use crate::app::{AccountSwitcherState, App};
|
||||||
use crate::app::methods::{modal::ModalMethods, navigation::NavigationMethods};
|
use crate::app::methods::{modal::ModalMethods, navigation::NavigationMethods};
|
||||||
use crate::tdlib::TdClientTrait;
|
use crate::tdlib::TdClientTrait;
|
||||||
use crate::types::{ChatId, MessageId};
|
use crate::types::{ChatId, MessageId};
|
||||||
use crate::utils::{with_timeout_msg, modal_handler::handle_yes_no};
|
use crate::utils::{with_timeout_msg, modal_handler::handle_yes_no};
|
||||||
use crate::input::handlers::get_available_actions_count;
|
use crate::input::handlers::get_available_actions_count;
|
||||||
use super::scroll_to_message;
|
use super::scroll_to_message;
|
||||||
use crossterm::event::KeyEvent;
|
use crossterm::event::{KeyCode, KeyEvent};
|
||||||
use std::time::Duration;
|
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::{
|
use crate::input::handlers::{
|
||||||
handle_global_commands,
|
handle_global_commands,
|
||||||
modal::{
|
modal::{
|
||||||
|
handle_account_switcher,
|
||||||
handle_profile_mode, handle_profile_open, handle_delete_confirmation,
|
handle_profile_mode, handle_profile_open, handle_delete_confirmation,
|
||||||
handle_reaction_picker_mode, handle_pinned_mode,
|
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) {
|
pub async fn handle<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent) {
|
||||||
let command = app.get_command(key);
|
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
|
// 1. Insert mode + чат открыт → только текст, Enter, Esc
|
||||||
// (Ctrl+C обрабатывается в main.rs до вызова router)
|
// (Ctrl+C обрабатывается в main.rs до вызова router)
|
||||||
if app.selected_chat_id.is_some() && app.input_mode == InputMode::Insert {
|
if app.selected_chat_id.is_some() && app.input_mode == InputMode::Insert {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
//!
|
//!
|
||||||
//! Library interface exposing modules for integration testing.
|
//! Library interface exposing modules for integration testing.
|
||||||
|
|
||||||
|
pub mod accounts;
|
||||||
pub mod app;
|
pub mod app;
|
||||||
pub mod audio;
|
pub mod audio;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
|||||||
154
src/main.rs
154
src/main.rs
@@ -1,3 +1,4 @@
|
|||||||
|
mod accounts;
|
||||||
mod app;
|
mod app;
|
||||||
mod audio;
|
mod audio;
|
||||||
mod config;
|
mod config;
|
||||||
@@ -31,6 +32,21 @@ use input::{handle_auth_input, handle_main_input};
|
|||||||
use tdlib::AuthState;
|
use tdlib::AuthState;
|
||||||
use utils::{disable_tdlib_logs, with_timeout_ignore};
|
use utils::{disable_tdlib_logs, with_timeout_ignore};
|
||||||
|
|
||||||
|
/// Parses `--account <name>` from CLI arguments.
|
||||||
|
fn parse_account_arg() -> Option<String> {
|
||||||
|
let args: Vec<String> = std::env::args().collect();
|
||||||
|
let mut i = 1;
|
||||||
|
while i < args.len() {
|
||||||
|
if args[i] == "--account" {
|
||||||
|
if i + 1 < args.len() {
|
||||||
|
return Some(args[i + 1].clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<(), io::Error> {
|
async fn main() -> Result<(), io::Error> {
|
||||||
// Загружаем переменные окружения из .env
|
// Загружаем переменные окружения из .env
|
||||||
@@ -48,6 +64,24 @@ async fn main() -> Result<(), io::Error> {
|
|||||||
// Загружаем конфигурацию (создаёт дефолтный если отсутствует)
|
// Загружаем конфигурацию (создаёт дефолтный если отсутствует)
|
||||||
let config = config::Config::load();
|
let config = config::Config::load();
|
||||||
|
|
||||||
|
// Загружаем/создаём accounts.toml + миграция legacy ./tdlib_data/
|
||||||
|
let accounts_config = accounts::load_or_create();
|
||||||
|
|
||||||
|
// Резолвим аккаунт из CLI или default
|
||||||
|
let account_arg = parse_account_arg();
|
||||||
|
let (account_name, db_path) =
|
||||||
|
accounts::resolve_account(&accounts_config, account_arg.as_deref())
|
||||||
|
.unwrap_or_else(|e| {
|
||||||
|
eprintln!("Error: {}", e);
|
||||||
|
std::process::exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Создаём директорию аккаунта если её нет
|
||||||
|
let db_path = accounts::ensure_account_dir(
|
||||||
|
account_arg.as_deref().unwrap_or(&accounts_config.default_account),
|
||||||
|
)
|
||||||
|
.unwrap_or(db_path);
|
||||||
|
|
||||||
// Отключаем логи TDLib ДО создания клиента
|
// Отключаем логи TDLib ДО создания клиента
|
||||||
disable_tdlib_logs();
|
disable_tdlib_logs();
|
||||||
|
|
||||||
@@ -66,18 +100,20 @@ async fn main() -> Result<(), io::Error> {
|
|||||||
panic_hook(info);
|
panic_hook(info);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Create app state
|
// Create app state with account-specific db_path
|
||||||
let mut app = App::new(config);
|
let mut app = App::new(config, db_path);
|
||||||
|
app.current_account_name = account_name;
|
||||||
|
|
||||||
// Запускаем инициализацию TDLib в фоне (только для реального клиента)
|
// Запускаем инициализацию TDLib в фоне (только для реального клиента)
|
||||||
let client_id = app.td_client.client_id();
|
let client_id = app.td_client.client_id();
|
||||||
let api_id = app.td_client.api_id;
|
let api_id = app.td_client.api_id;
|
||||||
let api_hash = app.td_client.api_hash.clone();
|
let api_hash = app.td_client.api_hash.clone();
|
||||||
|
let db_path_str = app.td_client.db_path.to_string_lossy().to_string();
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let _ = tdlib_rs::functions::set_tdlib_parameters(
|
let _ = tdlib_rs::functions::set_tdlib_parameters(
|
||||||
false, // use_test_dc
|
false, // use_test_dc
|
||||||
"tdlib_data".to_string(), // database_directory
|
db_path_str, // database_directory
|
||||||
"".to_string(), // files_directory
|
"".to_string(), // files_directory
|
||||||
"".to_string(), // database_encryption_key
|
"".to_string(), // database_encryption_key
|
||||||
true, // use_file_database
|
true, // use_file_database
|
||||||
@@ -233,12 +269,23 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
match app.screen {
|
// Ctrl+A opens account switcher from any screen
|
||||||
AppScreen::Loading => {
|
if key.code == KeyCode::Char('a')
|
||||||
// В состоянии загрузки игнорируем ввод
|
&& key.modifiers.contains(KeyModifiers::CONTROL)
|
||||||
|
&& app.account_switcher.is_none()
|
||||||
|
{
|
||||||
|
app.open_account_switcher();
|
||||||
|
} else if app.account_switcher.is_some() {
|
||||||
|
// Route to main input handler when account switcher is open
|
||||||
|
handle_main_input(app, key).await;
|
||||||
|
} else {
|
||||||
|
match app.screen {
|
||||||
|
AppScreen::Loading => {
|
||||||
|
// В состоянии загрузки игнорируем ввод
|
||||||
|
}
|
||||||
|
AppScreen::Auth => handle_auth_input(app, key.code).await,
|
||||||
|
AppScreen::Main => handle_main_input(app, key).await,
|
||||||
}
|
}
|
||||||
AppScreen::Auth => handle_auth_input(app, key.code).await,
|
|
||||||
AppScreen::Main => handle_main_input(app, key).await,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Любой ввод требует перерисовки
|
// Любой ввод требует перерисовки
|
||||||
@@ -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 crate::types::{ChatId, MessageId, UserId};
|
||||||
use std::env;
|
use std::env;
|
||||||
|
use std::path::PathBuf;
|
||||||
use tdlib_rs::enums::{
|
use tdlib_rs::enums::{
|
||||||
ChatList, ConnectionState, Update, UserStatus,
|
ChatList, ConnectionState, Update, UserStatus,
|
||||||
Chat as TdChat
|
Chat as TdChat
|
||||||
@@ -32,7 +33,7 @@ use crate::notifications::NotificationManager;
|
|||||||
/// ```ignore
|
/// ```ignore
|
||||||
/// use tele_tui::tdlib::TdClient;
|
/// use tele_tui::tdlib::TdClient;
|
||||||
///
|
///
|
||||||
/// let mut client = TdClient::new();
|
/// let mut client = TdClient::new(std::path::PathBuf::from("tdlib_data"));
|
||||||
///
|
///
|
||||||
/// // Start authorization
|
/// // Start authorization
|
||||||
/// client.send_phone_number("+1234567890".to_string()).await?;
|
/// client.send_phone_number("+1234567890".to_string()).await?;
|
||||||
@@ -45,6 +46,7 @@ use crate::notifications::NotificationManager;
|
|||||||
pub struct TdClient {
|
pub struct TdClient {
|
||||||
pub api_id: i32,
|
pub api_id: i32,
|
||||||
pub api_hash: String,
|
pub api_hash: String,
|
||||||
|
pub db_path: PathBuf,
|
||||||
client_id: i32,
|
client_id: i32,
|
||||||
|
|
||||||
// Менеджеры (делегируем им функциональность)
|
// Менеджеры (делегируем им функциональность)
|
||||||
@@ -71,7 +73,7 @@ impl TdClient {
|
|||||||
/// # Returns
|
/// # Returns
|
||||||
///
|
///
|
||||||
/// A new `TdClient` instance ready for authentication.
|
/// A new `TdClient` instance ready for authentication.
|
||||||
pub fn new() -> Self {
|
pub fn new(db_path: PathBuf) -> Self {
|
||||||
// Пробуем загрузить credentials из Config (файл или env)
|
// Пробуем загрузить credentials из Config (файл или env)
|
||||||
let (api_id, api_hash) = crate::config::Config::load_credentials()
|
let (api_id, api_hash) = crate::config::Config::load_credentials()
|
||||||
.unwrap_or_else(|_| {
|
.unwrap_or_else(|_| {
|
||||||
@@ -89,6 +91,7 @@ impl TdClient {
|
|||||||
Self {
|
Self {
|
||||||
api_id,
|
api_id,
|
||||||
api_hash,
|
api_hash,
|
||||||
|
db_path,
|
||||||
client_id,
|
client_id,
|
||||||
auth: AuthManager::new(client_id),
|
auth: AuthManager::new(client_id),
|
||||||
chat_manager: ChatManager::new(client_id),
|
chat_manager: ChatManager::new(client_id),
|
||||||
@@ -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 {
|
pub fn extract_content_text(content: &tdlib_rs::enums::MessageContent) -> String {
|
||||||
use tdlib_rs::enums::MessageContent;
|
use tdlib_rs::enums::MessageContent;
|
||||||
match content {
|
match content {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use super::r#trait::TdClientTrait;
|
|||||||
use super::{AuthState, ChatInfo, FolderInfo, MessageInfo, ProfileInfo, ReplyInfo, UserCache, UserOnlineStatus};
|
use super::{AuthState, ChatInfo, FolderInfo, MessageInfo, ProfileInfo, ReplyInfo, UserCache, UserOnlineStatus};
|
||||||
use crate::types::{ChatId, MessageId, UserId};
|
use crate::types::{ChatId, MessageId, UserId};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
use std::path::PathBuf;
|
||||||
use tdlib_rs::enums::{ChatAction, Update};
|
use tdlib_rs::enums::{ChatAction, Update};
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -278,6 +279,11 @@ impl TdClientTrait for TdClient {
|
|||||||
self.notification_manager.sync_muted_chats(&self.chat_manager.chats);
|
self.notification_manager.sync_muted_chats(&self.chat_manager.chats);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============ Account switching ============
|
||||||
|
async fn recreate_client(&mut self, db_path: PathBuf) -> Result<(), String> {
|
||||||
|
TdClient::recreate_client(self, db_path).await
|
||||||
|
}
|
||||||
|
|
||||||
// ============ Update handling ============
|
// ============ Update handling ============
|
||||||
fn handle_update(&mut self, update: Update) {
|
fn handle_update(&mut self, update: Update) {
|
||||||
// Delegate to the real implementation
|
// Delegate to the real implementation
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
use crate::tdlib::{AuthState, FolderInfo, MessageInfo, ProfileInfo, UserCache, UserOnlineStatus};
|
use crate::tdlib::{AuthState, FolderInfo, MessageInfo, ProfileInfo, UserCache, UserOnlineStatus};
|
||||||
use crate::types::{ChatId, MessageId, UserId};
|
use crate::types::{ChatId, MessageId, UserId};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
use std::path::PathBuf;
|
||||||
use tdlib_rs::enums::{ChatAction, Update};
|
use tdlib_rs::enums::{ChatAction, Update};
|
||||||
|
|
||||||
use super::ChatInfo;
|
use super::ChatInfo;
|
||||||
@@ -127,6 +128,13 @@ pub trait TdClientTrait: Send {
|
|||||||
// ============ Notification methods ============
|
// ============ Notification methods ============
|
||||||
fn sync_notification_muted_chats(&mut self);
|
fn sync_notification_muted_chats(&mut self);
|
||||||
|
|
||||||
|
// ============ Account switching ============
|
||||||
|
/// Recreates the client with a new database path (for account switching).
|
||||||
|
///
|
||||||
|
/// For real TdClient: closes old client, creates new one, inits TDLib parameters.
|
||||||
|
/// For FakeTdClient: no-op.
|
||||||
|
async fn recreate_client(&mut self, db_path: PathBuf) -> Result<(), String>;
|
||||||
|
|
||||||
// ============ Update handling ============
|
// ============ Update handling ============
|
||||||
fn handle_update(&mut self, update: Update);
|
fn handle_update(&mut self, update: Update);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,22 +19,29 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
|
|||||||
NetworkState::Updating => "⏳ Обновление... | ",
|
NetworkState::Updating => "⏳ Обновление... | ",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Account indicator (shown if not "default")
|
||||||
|
let account_indicator = if app.current_account_name != "default" {
|
||||||
|
format!("[{}] ", app.current_account_name)
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
|
||||||
let status = if let Some(msg) = &app.status_message {
|
let status = if let Some(msg) = &app.status_message {
|
||||||
format!(" {}{} ", network_indicator, msg)
|
format!(" {}{}{} ", account_indicator, network_indicator, msg)
|
||||||
} else if let Some(err) = &app.error_message {
|
} else if let Some(err) = &app.error_message {
|
||||||
format!(" {}Error: {} ", network_indicator, err)
|
format!(" {}{}Error: {} ", account_indicator, network_indicator, err)
|
||||||
} else if app.is_searching {
|
} else if app.is_searching {
|
||||||
format!(" {}↑/↓: Navigate | Enter: Select | Esc: Cancel ", network_indicator)
|
format!(" {}{}↑/↓: Navigate | Enter: Select | Esc: Cancel ", account_indicator, network_indicator)
|
||||||
} else if app.selected_chat_id.is_some() {
|
} else if app.selected_chat_id.is_some() {
|
||||||
let mode_str = match app.input_mode {
|
let mode_str = match app.input_mode {
|
||||||
InputMode::Normal => "[NORMAL] j/k: Nav | i: Insert | d/r/f/y: Actions | Esc: Close",
|
InputMode::Normal => "[NORMAL] j/k: Nav | i: Insert | d/r/f/y: Actions | Esc: Close",
|
||||||
InputMode::Insert => "[INSERT] Type message | Esc: Normal mode",
|
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 {
|
} else {
|
||||||
format!(
|
format!(
|
||||||
" {}↑/↓: Navigate | Enter: Open | Ctrl+S: Search | Ctrl+R: Refresh | Ctrl+C: Quit ",
|
" {}{}↑/↓: Navigate | Enter: Open | Ctrl+S: Search | Ctrl+R: Refresh | Ctrl+C: Quit ",
|
||||||
network_indicator
|
account_indicator, network_indicator
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,11 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, app: &mut App<T>) {
|
|||||||
AppScreen::Auth => auth::render(f, app),
|
AppScreen::Auth => auth::render(f, app),
|
||||||
AppScreen::Main => main_screen::render(f, app),
|
AppScreen::Main => main_screen::render(f, app),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Global overlay: account switcher (renders on top of ANY screen)
|
||||||
|
if app.account_switcher.is_some() {
|
||||||
|
modals::render_account_switcher(f, area, app);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_size_warning(f: &mut Frame, width: u16, height: u16) {
|
fn render_size_warning(f: &mut Frame, width: u16, height: u16) {
|
||||||
|
|||||||
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
|
//! Modal dialog rendering modules
|
||||||
//!
|
//!
|
||||||
//! Contains UI rendering for various modal dialogs:
|
//! Contains UI rendering for various modal dialogs:
|
||||||
|
//! - account_switcher: Account switcher modal (global overlay)
|
||||||
//! - delete_confirm: Delete confirmation modal
|
//! - delete_confirm: Delete confirmation modal
|
||||||
//! - reaction_picker: Emoji reaction picker modal
|
//! - reaction_picker: Emoji reaction picker modal
|
||||||
//! - search: Message search modal
|
//! - search: Message search modal
|
||||||
//! - pinned: Pinned messages viewer modal
|
//! - pinned: Pinned messages viewer modal
|
||||||
//! - image_viewer: Full-screen image viewer modal (images feature)
|
//! - image_viewer: Full-screen image viewer modal (images feature)
|
||||||
|
|
||||||
|
pub mod account_switcher;
|
||||||
pub mod delete_confirm;
|
pub mod delete_confirm;
|
||||||
pub mod reaction_picker;
|
pub mod reaction_picker;
|
||||||
pub mod search;
|
pub mod search;
|
||||||
@@ -15,6 +17,7 @@ pub mod pinned;
|
|||||||
#[cfg(feature = "images")]
|
#[cfg(feature = "images")]
|
||||||
pub mod image_viewer;
|
pub mod image_viewer;
|
||||||
|
|
||||||
|
pub use account_switcher::render as render_account_switcher;
|
||||||
pub use delete_confirm::render as render_delete_confirm;
|
pub use delete_confirm::render as render_delete_confirm;
|
||||||
pub use reaction_picker::render as render_reaction_picker;
|
pub use reaction_picker::render as render_reaction_picker;
|
||||||
pub use search::render as render_search;
|
pub use search::render as render_search;
|
||||||
|
|||||||
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 super::fake_tdclient::FakeTdClient;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
use std::path::PathBuf;
|
||||||
use tdlib_rs::enums::{ChatAction, Update};
|
use tdlib_rs::enums::{ChatAction, Update};
|
||||||
use tele_tui::tdlib::{AuthState, ChatInfo, FolderInfo, MessageInfo, ProfileInfo, ReplyInfo, UserCache, UserOnlineStatus};
|
use tele_tui::tdlib::{AuthState, ChatInfo, FolderInfo, MessageInfo, ProfileInfo, ReplyInfo, UserCache, UserOnlineStatus};
|
||||||
use tele_tui::tdlib::TdClientTrait;
|
use tele_tui::tdlib::TdClientTrait;
|
||||||
@@ -314,6 +315,12 @@ impl TdClientTrait for FakeTdClient {
|
|||||||
// Not implemented for fake client (notifications are not tested)
|
// 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 ============
|
// ============ Update handling ============
|
||||||
fn handle_update(&mut self, _update: Update) {
|
fn handle_update(&mut self, _update: Update) {
|
||||||
// Not implemented for fake client
|
// Not implemented for fake client
|
||||||
|
|||||||
Reference in New Issue
Block a user