Group photos with shared media_album_id into single album bubbles with grid layout (up to 3x cols). Album navigation treats grouped photos as one unit (j/k skip entire album). Persist selected account to accounts.toml so it survives app restart. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
507 lines
21 KiB
Rust
507 lines
21 KiB
Rust
mod accounts;
|
||
mod app;
|
||
mod audio;
|
||
mod config;
|
||
mod constants;
|
||
mod formatting;
|
||
mod input;
|
||
#[cfg(feature = "images")]
|
||
mod media;
|
||
mod message_grouping;
|
||
mod notifications;
|
||
mod tdlib;
|
||
mod types;
|
||
mod ui;
|
||
mod utils;
|
||
|
||
use crossterm::{
|
||
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers},
|
||
execute,
|
||
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||
};
|
||
use ratatui::{backend::CrosstermBackend, Terminal};
|
||
use std::io;
|
||
use std::sync::atomic::{AtomicBool, Ordering};
|
||
use std::sync::Arc;
|
||
use std::time::Duration;
|
||
use tdlib_rs::enums::Update;
|
||
|
||
use app::{App, AppScreen};
|
||
use constants::{POLL_TIMEOUT_MS, SHUTDOWN_TIMEOUT_SECS};
|
||
use input::{handle_auth_input, handle_main_input};
|
||
use tdlib::AuthState;
|
||
use utils::{disable_tdlib_logs, with_timeout_ignore};
|
||
|
||
/// Parses `--account <name>` from CLI arguments.
|
||
fn parse_account_arg() -> Option<String> {
|
||
let args: Vec<String> = std::env::args().collect();
|
||
let mut i = 1;
|
||
while i < args.len() {
|
||
if args[i] == "--account" {
|
||
if i + 1 < args.len() {
|
||
return Some(args[i + 1].clone());
|
||
}
|
||
}
|
||
i += 1;
|
||
}
|
||
None
|
||
}
|
||
|
||
#[tokio::main]
|
||
async fn main() -> Result<(), io::Error> {
|
||
// Загружаем переменные окружения из .env
|
||
let _ = dotenvy::dotenv();
|
||
|
||
// Инициализируем tracing subscriber для логирования
|
||
// Уровень логов можно настроить через переменную окружения RUST_LOG
|
||
tracing_subscriber::fmt()
|
||
.with_env_filter(
|
||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn"))
|
||
)
|
||
.init();
|
||
|
||
// Загружаем конфигурацию (создаёт дефолтный если отсутствует)
|
||
let config = config::Config::load();
|
||
|
||
// Загружаем/создаём accounts.toml + миграция legacy ./tdlib_data/
|
||
let accounts_config = accounts::load_or_create();
|
||
|
||
// Резолвим аккаунт из CLI или default
|
||
let account_arg = parse_account_arg();
|
||
let (account_name, db_path) =
|
||
accounts::resolve_account(&accounts_config, account_arg.as_deref())
|
||
.unwrap_or_else(|e| {
|
||
eprintln!("Error: {}", e);
|
||
std::process::exit(1);
|
||
});
|
||
|
||
// Создаём директорию аккаунта если её нет
|
||
let db_path = accounts::ensure_account_dir(
|
||
account_arg.as_deref().unwrap_or(&accounts_config.default_account),
|
||
)
|
||
.unwrap_or(db_path);
|
||
|
||
// Отключаем логи TDLib ДО создания клиента
|
||
disable_tdlib_logs();
|
||
|
||
// Setup terminal
|
||
enable_raw_mode()?;
|
||
let mut stdout = io::stdout();
|
||
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
|
||
let backend = CrosstermBackend::new(stdout);
|
||
let mut terminal = Terminal::new(backend)?;
|
||
|
||
// Ensure terminal restoration on panic
|
||
let panic_hook = std::panic::take_hook();
|
||
std::panic::set_hook(Box::new(move |info| {
|
||
let _ = disable_raw_mode();
|
||
let _ = execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture);
|
||
panic_hook(info);
|
||
}));
|
||
|
||
// Create app state with account-specific db_path
|
||
let mut app = App::new(config, db_path);
|
||
app.current_account_name = account_name;
|
||
|
||
// Запускаем инициализацию TDLib в фоне (только для реального клиента)
|
||
let client_id = app.td_client.client_id();
|
||
let api_id = app.td_client.api_id;
|
||
let api_hash = app.td_client.api_hash.clone();
|
||
let db_path_str = app.td_client.db_path.to_string_lossy().to_string();
|
||
|
||
tokio::spawn(async move {
|
||
let _ = tdlib_rs::functions::set_tdlib_parameters(
|
||
false, // use_test_dc
|
||
db_path_str, // database_directory
|
||
"".to_string(), // files_directory
|
||
"".to_string(), // database_encryption_key
|
||
true, // use_file_database
|
||
true, // use_chat_info_database
|
||
true, // use_message_database
|
||
false, // use_secret_chats
|
||
api_id,
|
||
api_hash,
|
||
"en".to_string(), // system_language_code
|
||
"Desktop".to_string(), // device_model
|
||
"".to_string(), // system_version
|
||
env!("CARGO_PKG_VERSION").to_string(), // application_version
|
||
client_id,
|
||
)
|
||
.await;
|
||
});
|
||
|
||
let res = run_app(&mut terminal, &mut app).await;
|
||
|
||
// Restore terminal
|
||
disable_raw_mode()?;
|
||
execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?;
|
||
terminal.show_cursor()?;
|
||
|
||
if let Err(err) = res {
|
||
println!("Error: {:?}", err);
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
|
||
terminal: &mut Terminal<B>,
|
||
app: &mut App<T>,
|
||
) -> io::Result<()> {
|
||
// Флаг для остановки polling задачи
|
||
let should_stop = Arc::new(AtomicBool::new(false));
|
||
let should_stop_clone = should_stop.clone();
|
||
|
||
// Канал для передачи updates из polling задачи в main loop
|
||
let (update_tx, mut update_rx) = tokio::sync::mpsc::unbounded_channel::<Update>();
|
||
|
||
// Запускаем polling TDLib receive() в отдельной задаче
|
||
let polling_handle = tokio::spawn(async move {
|
||
while !should_stop_clone.load(Ordering::Relaxed) {
|
||
// receive() с таймаутом 0.1 сек чтобы периодически проверять флаг
|
||
let result = tokio::task::spawn_blocking(|| tdlib_rs::receive()).await;
|
||
if let Ok(Some((update, _client_id))) = result {
|
||
if update_tx.send(update).is_err() {
|
||
break; // Канал закрыт, выходим
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
loop {
|
||
// Обрабатываем updates от TDLib из канала (неблокирующе)
|
||
let mut had_updates = false;
|
||
while let Ok(update) = update_rx.try_recv() {
|
||
app.td_client.handle_update(update);
|
||
had_updates = true;
|
||
}
|
||
|
||
// Помечаем UI как требующий перерисовки если были обновления
|
||
if had_updates {
|
||
app.needs_redraw = true;
|
||
}
|
||
|
||
// Обрабатываем результаты фоновой загрузки фото
|
||
#[cfg(feature = "images")]
|
||
{
|
||
use crate::tdlib::PhotoDownloadState;
|
||
|
||
let mut got_photos = false;
|
||
if let Some(ref mut rx) = app.photo_download_rx {
|
||
while let Ok((file_id, result)) = rx.try_recv() {
|
||
let new_state = match result {
|
||
Ok(path) => PhotoDownloadState::Downloaded(path),
|
||
Err(_) => PhotoDownloadState::Error("Ошибка загрузки".to_string()),
|
||
};
|
||
for msg in app.td_client.current_chat_messages_mut() {
|
||
if let Some(photo) = msg.photo_info_mut() {
|
||
if photo.file_id == file_id {
|
||
photo.download_state = new_state;
|
||
got_photos = true;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if got_photos {
|
||
app.needs_redraw = true;
|
||
}
|
||
}
|
||
|
||
// Очищаем устаревший typing status
|
||
if app.td_client.clear_stale_typing_status() {
|
||
app.needs_redraw = true;
|
||
}
|
||
|
||
// Обрабатываем очередь сообщений для отметки как прочитанных
|
||
if !app.td_client.pending_view_messages().is_empty() {
|
||
app.td_client.process_pending_view_messages().await;
|
||
}
|
||
|
||
// Обрабатываем очередь user_id для загрузки имён
|
||
if !app.td_client.pending_user_ids().is_empty() {
|
||
app.td_client.process_pending_user_ids().await;
|
||
}
|
||
|
||
// Обновляем состояние экрана на основе auth_state
|
||
let screen_changed = update_screen_state(app).await;
|
||
if screen_changed {
|
||
app.needs_redraw = true;
|
||
}
|
||
|
||
// Обновляем позицию воспроизведения голосового сообщения
|
||
{
|
||
let mut stop_playback = false;
|
||
if let Some(ref mut playback) = app.playback_state {
|
||
use crate::tdlib::PlaybackStatus;
|
||
match playback.status {
|
||
PlaybackStatus::Playing => {
|
||
let prev_second = playback.position as u32;
|
||
if let Some(last_tick) = app.last_playback_tick {
|
||
let delta = last_tick.elapsed().as_secs_f32();
|
||
playback.position += delta;
|
||
}
|
||
app.last_playback_tick = Some(std::time::Instant::now());
|
||
|
||
// Проверяем завершение воспроизведения
|
||
if playback.position >= playback.duration
|
||
|| app.audio_player.as_ref().map_or(false, |p| p.is_stopped())
|
||
{
|
||
stop_playback = true;
|
||
}
|
||
// Перерисовка только при смене секунды (не 60 FPS)
|
||
if playback.position as u32 != prev_second || stop_playback {
|
||
app.needs_redraw = true;
|
||
}
|
||
}
|
||
_ => {
|
||
app.last_playback_tick = None;
|
||
}
|
||
}
|
||
}
|
||
if stop_playback {
|
||
app.stop_playback();
|
||
app.last_playback_tick = None;
|
||
}
|
||
}
|
||
|
||
// Рендерим только если есть изменения
|
||
if app.needs_redraw {
|
||
terminal.draw(|f| ui::render(f, app))?;
|
||
app.needs_redraw = false;
|
||
}
|
||
|
||
// Используем poll с коротким таймаутом для быстрой реакции на ввод
|
||
// 16ms ≈ 60 FPS потенциально, но рендерим только при изменениях
|
||
if event::poll(Duration::from_millis(POLL_TIMEOUT_MS))? {
|
||
match event::read()? {
|
||
Event::Key(key) => {
|
||
// Global quit command
|
||
if key.code == KeyCode::Char('c')
|
||
&& key.modifiers.contains(KeyModifiers::CONTROL)
|
||
{
|
||
// Graceful shutdown
|
||
should_stop.store(true, Ordering::Relaxed);
|
||
|
||
// Останавливаем воспроизведение голосового (убиваем ffplay)
|
||
app.stop_playback();
|
||
|
||
// Закрываем TDLib клиент
|
||
let _ = tdlib_rs::functions::close(app.td_client.client_id()).await;
|
||
|
||
// Ждём завершения polling задачи (с таймаутом)
|
||
with_timeout_ignore(Duration::from_secs(SHUTDOWN_TIMEOUT_SECS), polling_handle).await;
|
||
|
||
return Ok(());
|
||
}
|
||
|
||
// Ctrl+A opens account switcher from any screen
|
||
if key.code == KeyCode::Char('a')
|
||
&& key.modifiers.contains(KeyModifiers::CONTROL)
|
||
&& app.account_switcher.is_none()
|
||
{
|
||
app.open_account_switcher();
|
||
} else if app.account_switcher.is_some() {
|
||
// Route to main input handler when account switcher is open
|
||
handle_main_input(app, key).await;
|
||
} else {
|
||
match app.screen {
|
||
AppScreen::Loading => {
|
||
// В состоянии загрузки игнорируем ввод
|
||
}
|
||
AppScreen::Auth => handle_auth_input(app, key.code).await,
|
||
AppScreen::Main => handle_main_input(app, key).await,
|
||
}
|
||
}
|
||
|
||
// Любой ввод требует перерисовки
|
||
app.needs_redraw = true;
|
||
}
|
||
Event::Resize(_, _) => {
|
||
// При изменении размера терминала нужна перерисовка
|
||
app.needs_redraw = true;
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
// 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;
|
||
|
||
// Авто-загрузка фото — неблокирующая фоновая задача (до 5 фото параллельно)
|
||
#[cfg(feature = "images")]
|
||
{
|
||
use crate::tdlib::PhotoDownloadState;
|
||
|
||
if app.config().images.auto_download_images && app.config().images.show_images {
|
||
let photo_file_ids: Vec<i32> = app
|
||
.td_client
|
||
.current_chat_messages()
|
||
.iter()
|
||
.rev()
|
||
.take(5)
|
||
.filter_map(|msg| {
|
||
msg.photo_info().and_then(|p| {
|
||
matches!(p.download_state, PhotoDownloadState::NotDownloaded)
|
||
.then_some(p.file_id)
|
||
})
|
||
})
|
||
.collect();
|
||
|
||
if !photo_file_ids.is_empty() {
|
||
let client_id = app.td_client.client_id();
|
||
let (tx, rx) =
|
||
tokio::sync::mpsc::unbounded_channel::<(i32, Result<String, String>)>();
|
||
app.photo_download_rx = Some(rx);
|
||
|
||
for file_id in photo_file_ids {
|
||
let tx = tx.clone();
|
||
tokio::spawn(async move {
|
||
let result = tokio::time::timeout(
|
||
Duration::from_secs(5),
|
||
async {
|
||
match tdlib_rs::functions::download_file(
|
||
file_id, 1, 0, 0, true, client_id,
|
||
)
|
||
.await
|
||
{
|
||
Ok(tdlib_rs::enums::File::File(file))
|
||
if file.local.is_downloading_completed
|
||
&& !file.local.path.is_empty() =>
|
||
{
|
||
Ok(file.local.path)
|
||
}
|
||
Ok(_) => Err("Файл не скачан".to_string()),
|
||
Err(e) => Err(format!("{:?}", e)),
|
||
}
|
||
},
|
||
)
|
||
.await;
|
||
|
||
let result = match result {
|
||
Ok(r) => r,
|
||
Err(_) => Err("Таймаут загрузки".to_string()),
|
||
};
|
||
let _ = tx.send((file_id, result));
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
app.needs_redraw = true;
|
||
}
|
||
|
||
// 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.clone();
|
||
app.screen = AppScreen::Loading;
|
||
|
||
// 4. Persist selected account as default for next launch
|
||
let mut accounts_config = accounts::load_or_create();
|
||
accounts_config.default_account = account_name;
|
||
if let Err(e) = accounts::save(&accounts_config) {
|
||
tracing::warn!("Could not save default account: {}", e);
|
||
}
|
||
app.chats.clear();
|
||
app.selected_chat_id = None;
|
||
app.chat_state = Default::default();
|
||
app.input_mode = Default::default();
|
||
app.status_message = Some("Переключение аккаунта...".to_string());
|
||
app.error_message = None;
|
||
app.is_searching = false;
|
||
app.search_query.clear();
|
||
app.message_input.clear();
|
||
app.cursor_position = 0;
|
||
app.message_scroll_offset = 0;
|
||
app.pending_chat_init = None;
|
||
app.account_switcher = None;
|
||
|
||
app.needs_redraw = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Возвращает true если состояние изменилось и требуется перерисовка
|
||
async fn update_screen_state<T: tdlib::TdClientTrait>(app: &mut App<T>) -> bool {
|
||
use utils::with_timeout_ignore;
|
||
|
||
let prev_screen = app.screen.clone();
|
||
let prev_status = app.status_message.clone();
|
||
let prev_error = app.error_message.clone();
|
||
let prev_chats_len = app.chats.len();
|
||
|
||
match &app.td_client.auth_state() {
|
||
AuthState::WaitTdlibParameters => {
|
||
app.screen = AppScreen::Loading;
|
||
app.status_message = Some("Инициализация TDLib...".to_string());
|
||
}
|
||
AuthState::WaitPhoneNumber | AuthState::WaitCode | AuthState::WaitPassword => {
|
||
app.screen = AppScreen::Auth;
|
||
app.is_loading = false;
|
||
}
|
||
AuthState::Ready => {
|
||
if prev_screen != AppScreen::Main {
|
||
app.screen = AppScreen::Main;
|
||
app.is_loading = true;
|
||
app.status_message = Some("Загрузка чатов...".to_string());
|
||
|
||
// Запрашиваем загрузку чатов с таймаутом (игнорируем ошибки)
|
||
with_timeout_ignore(Duration::from_secs(5), app.td_client.load_chats(50)).await;
|
||
}
|
||
|
||
// Синхронизируем чаты из td_client в app
|
||
if !app.td_client.chats().is_empty() {
|
||
app.chats = app.td_client.chats().to_vec();
|
||
if app.chat_list_state.selected().is_none() && !app.chats.is_empty() {
|
||
app.chat_list_state.select(Some(0));
|
||
}
|
||
// Синхронизируем muted чаты для notifications
|
||
app.td_client.sync_notification_muted_chats();
|
||
// Убираем статус загрузки когда чаты появились
|
||
if app.is_loading {
|
||
app.is_loading = false;
|
||
app.status_message = None;
|
||
}
|
||
}
|
||
}
|
||
AuthState::Closed => {
|
||
app.status_message = Some("Соединение закрыто".to_string());
|
||
}
|
||
AuthState::Error(e) => {
|
||
app.error_message = Some(e.clone());
|
||
}
|
||
}
|
||
|
||
// Проверяем, изменилось ли что-то
|
||
app.screen != prev_screen
|
||
|| app.status_message != prev_status
|
||
|| app.error_message != prev_error
|
||
|| app.chats.len() != prev_chats_len
|
||
}
|