Refactor TDLib facade and local time handling
This commit is contained in:
@@ -226,6 +226,7 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::types::ChatId;
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn create_test_chat(
|
||||
id: i64,
|
||||
title: &str,
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
//! - Editing and sending messages
|
||||
//! - Loading older messages
|
||||
|
||||
mod media;
|
||||
|
||||
use super::chat_loader::{load_older_messages_if_needed, open_chat_and_load_data};
|
||||
use crate::app::methods::{
|
||||
compose::ComposeMethods, messages::MessageMethods, modal::ModalMethods,
|
||||
@@ -77,16 +79,16 @@ pub async fn handle_message_selection<T: TdClientTrait>(
|
||||
}
|
||||
}
|
||||
Some(crate::config::Command::ViewImage) => {
|
||||
handle_view_or_play_media(app).await;
|
||||
media::handle_view_or_play_media(app).await;
|
||||
}
|
||||
Some(crate::config::Command::TogglePlayback) => {
|
||||
handle_toggle_voice_playback(app).await;
|
||||
media::handle_toggle_voice_playback(app).await;
|
||||
}
|
||||
Some(crate::config::Command::SeekForward | crate::config::Command::MoveRight) => {
|
||||
handle_voice_seek(app, 5.0);
|
||||
media::handle_voice_seek(app, 5.0);
|
||||
}
|
||||
Some(crate::config::Command::SeekBackward | crate::config::Command::MoveLeft) => {
|
||||
handle_voice_seek(app, -5.0);
|
||||
media::handle_voice_seek(app, -5.0);
|
||||
}
|
||||
Some(crate::config::Command::ReactMessage) => {
|
||||
let Some(msg) = app.get_selected_message() else {
|
||||
@@ -163,23 +165,24 @@ pub async fn edit_message<T: TdClientTrait>(
|
||||
{
|
||||
Ok(mut edited_msg) => {
|
||||
// Сохраняем reply_to из старого сообщения (если есть)
|
||||
let messages = app.td_client.current_chat_messages_mut();
|
||||
if let Some(pos) = messages.iter().position(|m| m.id() == msg_id) {
|
||||
let old_reply_to = messages[pos].interactions.reply_to.clone();
|
||||
// Если в старом сообщении был reply и в новом он "Unknown" - сохраняем старый
|
||||
if let Some(old_reply) = old_reply_to {
|
||||
if edited_msg
|
||||
.interactions
|
||||
.reply_to
|
||||
.as_ref()
|
||||
.is_none_or(|r| r.sender_name == "Unknown")
|
||||
{
|
||||
edited_msg.interactions.reply_to = Some(old_reply);
|
||||
app.td_client.update_current_chat_messages(|messages| {
|
||||
if let Some(pos) = messages.iter().position(|m| m.id() == msg_id) {
|
||||
let old_reply_to = messages[pos].interactions.reply_to.clone();
|
||||
// Если в старом сообщении был reply и в новом он "Unknown" - сохраняем старый
|
||||
if let Some(old_reply) = old_reply_to {
|
||||
if edited_msg
|
||||
.interactions
|
||||
.reply_to
|
||||
.as_ref()
|
||||
.is_none_or(|r| r.sender_name == "Unknown")
|
||||
{
|
||||
edited_msg.interactions.reply_to = Some(old_reply);
|
||||
}
|
||||
}
|
||||
// Заменяем сообщение
|
||||
messages[pos] = edited_msg;
|
||||
}
|
||||
// Заменяем сообщение
|
||||
messages[pos] = edited_msg;
|
||||
}
|
||||
});
|
||||
// Очищаем инпут и сбрасываем состояние ПОСЛЕ успешного редактирования
|
||||
app.message_input.clear();
|
||||
app.cursor_position = 0;
|
||||
@@ -451,341 +454,6 @@ pub async fn handle_open_chat_keyboard_input<T: TdClientTrait>(app: &mut App<T>,
|
||||
}
|
||||
}
|
||||
|
||||
/// Обработка команды ViewImage — только фото
|
||||
async fn handle_view_or_play_media<T: TdClientTrait>(app: &mut App<T>) {
|
||||
let Some(msg) = app.get_selected_message() else {
|
||||
return;
|
||||
};
|
||||
|
||||
if msg.has_photo() {
|
||||
#[cfg(feature = "images")]
|
||||
handle_view_image(app).await;
|
||||
#[cfg(not(feature = "images"))]
|
||||
{
|
||||
app.status_message = Some("Просмотр изображений отключён".to_string());
|
||||
}
|
||||
} else {
|
||||
app.status_message = Some("Сообщение не содержит фото".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// Space: play/pause toggle для голосовых сообщений
|
||||
async fn handle_toggle_voice_playback<T: TdClientTrait>(app: &mut App<T>) {
|
||||
use crate::tdlib::PlaybackStatus;
|
||||
|
||||
// Если уже есть активное воспроизведение — toggle pause/resume
|
||||
if let Some(ref mut playback) = app.playback_state {
|
||||
if let Some(ref player) = app.audio_player {
|
||||
match playback.status {
|
||||
PlaybackStatus::Playing => {
|
||||
player.pause();
|
||||
playback.status = PlaybackStatus::Paused;
|
||||
app.last_playback_tick = None;
|
||||
app.status_message = Some("⏸ Пауза".to_string());
|
||||
}
|
||||
PlaybackStatus::Paused => {
|
||||
// Откатываем на 1 секунду для контекста
|
||||
let resume_pos = (playback.position - 1.0).max(0.0);
|
||||
// Перезапускаем ffplay с нужной позиции (-ss)
|
||||
if player.resume_from(resume_pos).is_ok() {
|
||||
playback.position = resume_pos;
|
||||
} else {
|
||||
// Fallback: простой SIGCONT без перемотки
|
||||
player.resume();
|
||||
}
|
||||
playback.status = PlaybackStatus::Playing;
|
||||
app.last_playback_tick = Some(Instant::now());
|
||||
app.status_message = Some("▶ Воспроизведение".to_string());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Нет активного воспроизведения — пробуем запустить текущее голосовое
|
||||
let Some(msg) = app.get_selected_message() else {
|
||||
return;
|
||||
};
|
||||
if msg.has_voice() {
|
||||
handle_play_voice(app).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Seek голосового сообщения на delta секунд
|
||||
fn handle_voice_seek<T: TdClientTrait>(app: &mut App<T>, delta: f32) {
|
||||
use crate::tdlib::PlaybackStatus;
|
||||
|
||||
let Some(ref mut playback) = app.playback_state else {
|
||||
return;
|
||||
};
|
||||
let Some(ref player) = app.audio_player else {
|
||||
return;
|
||||
};
|
||||
|
||||
let was_playing = matches!(playback.status, PlaybackStatus::Playing);
|
||||
let was_paused = matches!(playback.status, PlaybackStatus::Paused);
|
||||
|
||||
if was_playing || was_paused {
|
||||
let new_position = (playback.position + delta).clamp(0.0, playback.duration);
|
||||
|
||||
if was_playing {
|
||||
// Перезапускаем ffplay с новой позиции
|
||||
if player.resume_from(new_position).is_ok() {
|
||||
playback.position = new_position;
|
||||
app.last_playback_tick = Some(std::time::Instant::now());
|
||||
}
|
||||
} else {
|
||||
// На паузе — только двигаем позицию, воспроизведение начнётся при resume
|
||||
player.stop();
|
||||
playback.position = new_position;
|
||||
}
|
||||
|
||||
let arrow = if delta > 0.0 { "→" } else { "←" };
|
||||
app.status_message = Some(format!("{} {:.0}s", arrow, new_position));
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Обработка команды ViewImage — открыть модальное окно с фото
|
||||
#[cfg(feature = "images")]
|
||||
async fn handle_view_image<T: TdClientTrait>(app: &mut App<T>) {
|
||||
use crate::tdlib::{ImageModalState, PhotoDownloadState};
|
||||
|
||||
if !app.config().images.show_images {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(msg) = app.get_selected_message() else {
|
||||
return;
|
||||
};
|
||||
|
||||
if !msg.has_photo() {
|
||||
app.status_message = Some("Сообщение не содержит фото".to_string());
|
||||
return;
|
||||
}
|
||||
|
||||
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 download_state {
|
||||
PhotoDownloadState::Downloaded(path) => {
|
||||
// Открываем модальное окно
|
||||
app.image_modal = Some(ImageModalState {
|
||||
message_id: msg_id,
|
||||
photo_path: path,
|
||||
photo_width,
|
||||
photo_height,
|
||||
});
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
PhotoDownloadState::NotDownloaded | PhotoDownloadState::Downloading => {
|
||||
// Запоминаем намерение открыть модалку — откроется когда загрузится
|
||||
app.pending_image_open = Some(crate::app::PendingImageOpen {
|
||||
file_id,
|
||||
message_id: msg_id,
|
||||
photo_width,
|
||||
photo_height,
|
||||
});
|
||||
app.status_message = Some("Загрузка фото...".to_string());
|
||||
app.needs_redraw = true;
|
||||
|
||||
// Если нет активной фоновой загрузки — запускаем свою
|
||||
if app.photo_download_rx.is_none() {
|
||||
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
app.photo_download_rx = Some(rx);
|
||||
let client_id = app.td_client.client_id();
|
||||
tokio::spawn(async move {
|
||||
let result = tokio::time::timeout(Duration::from_secs(30), async {
|
||||
match tdlib_rs::functions::download_file(file_id, 1, 0, 0, true, client_id)
|
||||
.await
|
||||
{
|
||||
Ok(tdlib_rs::enums::File::File(f))
|
||||
if f.local.is_downloading_completed && !f.local.path.is_empty() =>
|
||||
{
|
||||
Ok(f.local.path)
|
||||
}
|
||||
Ok(_) => Err("Файл не скачан".to_string()),
|
||||
Err(e) => Err(format!("{:?}", e)),
|
||||
}
|
||||
})
|
||||
.await;
|
||||
let result = result.unwrap_or_else(|_| Err("Таймаут загрузки".to_string()));
|
||||
let _ = tx.send((file_id, result));
|
||||
});
|
||||
}
|
||||
}
|
||||
PhotoDownloadState::Error(_) => {
|
||||
// Повторная попытка загрузки
|
||||
app.status_message = Some("Повторная загрузка фото...".to_string());
|
||||
app.needs_redraw = true;
|
||||
match app.td_client.download_file(file_id).await {
|
||||
Ok(path) => {
|
||||
for msg in app.td_client.current_chat_messages_mut() {
|
||||
if let Some(photo) = msg.photo_info_mut() {
|
||||
if photo.file_id == file_id {
|
||||
photo.download_state = PhotoDownloadState::Downloaded(path.clone());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
app.image_modal = Some(ImageModalState {
|
||||
message_id: msg_id,
|
||||
photo_path: path,
|
||||
photo_width,
|
||||
photo_height,
|
||||
});
|
||||
app.status_message = None;
|
||||
}
|
||||
Err(e) => {
|
||||
app.error_message = Some(format!("Ошибка загрузки фото: {}", e));
|
||||
app.status_message = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Вспомогательная функция для воспроизведения из конкретного пути
|
||||
async fn handle_play_voice_from_path<T: TdClientTrait>(
|
||||
app: &mut App<T>,
|
||||
path: &str,
|
||||
voice: &crate::tdlib::VoiceInfo,
|
||||
msg: &crate::tdlib::MessageInfo,
|
||||
) {
|
||||
use crate::tdlib::{PlaybackState, PlaybackStatus};
|
||||
|
||||
if let Some(ref player) = app.audio_player {
|
||||
match player.play(path) {
|
||||
Ok(_) => {
|
||||
app.playback_state = Some(PlaybackState {
|
||||
message_id: msg.id(),
|
||||
status: PlaybackStatus::Playing,
|
||||
position: 0.0,
|
||||
duration: voice.duration as f32,
|
||||
volume: player.volume(),
|
||||
});
|
||||
app.last_playback_tick = Some(Instant::now());
|
||||
app.status_message = Some(format!("▶ Воспроизведение ({:.0}s)", voice.duration));
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
Err(e) => {
|
||||
app.error_message = Some(format!("Ошибка воспроизведения: {}", e));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
app.error_message = Some("Аудиоплеер не инициализирован".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// Воспроизведение голосового сообщения
|
||||
async fn handle_play_voice<T: TdClientTrait>(app: &mut App<T>) {
|
||||
use crate::tdlib::VoiceDownloadState;
|
||||
|
||||
let Some(msg) = app.get_selected_message() else {
|
||||
return;
|
||||
};
|
||||
|
||||
if !msg.has_voice() {
|
||||
return;
|
||||
}
|
||||
|
||||
let voice = msg.voice_info().unwrap();
|
||||
let file_id = voice.file_id;
|
||||
|
||||
match &voice.download_state {
|
||||
VoiceDownloadState::Downloaded(path) => {
|
||||
// TDLib может вернуть путь без расширения — ищем файл с .oga
|
||||
use std::path::Path;
|
||||
let audio_path = if Path::new(path).exists() {
|
||||
path.clone()
|
||||
} else {
|
||||
// Пробуем добавить .oga
|
||||
let with_oga = format!("{}.oga", path);
|
||||
if Path::new(&with_oga).exists() {
|
||||
with_oga
|
||||
} else {
|
||||
// Пробуем найти файл с похожим именем в той же папке
|
||||
if let Some(parent) = Path::new(path).parent() {
|
||||
if let Some(stem) = Path::new(path).file_name() {
|
||||
if let Ok(entries) = std::fs::read_dir(parent) {
|
||||
for entry in entries.flatten() {
|
||||
let entry_name = entry.file_name();
|
||||
if entry_name
|
||||
.to_string_lossy()
|
||||
.starts_with(&stem.to_string_lossy().to_string())
|
||||
{
|
||||
let found_path = entry.path().to_string_lossy().to_string();
|
||||
// Кэшируем найденный файл
|
||||
if let Some(ref mut cache) = app.voice_cache {
|
||||
let _ = cache.store(
|
||||
&file_id.to_string(),
|
||||
Path::new(&found_path),
|
||||
);
|
||||
}
|
||||
return handle_play_voice_from_path(
|
||||
app,
|
||||
&found_path,
|
||||
voice,
|
||||
&msg,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
app.error_message = Some(format!("Файл не найден: {}", path));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Кэшируем файл если ещё не в кэше
|
||||
if let Some(ref mut cache) = app.voice_cache {
|
||||
let _ = cache.store(&file_id.to_string(), Path::new(&audio_path));
|
||||
}
|
||||
|
||||
handle_play_voice_from_path(app, &audio_path, voice, &msg).await;
|
||||
}
|
||||
VoiceDownloadState::Downloading => {
|
||||
app.status_message = Some("Загрузка голосового...".to_string());
|
||||
}
|
||||
VoiceDownloadState::NotDownloaded => {
|
||||
// Проверяем кэш перед загрузкой
|
||||
let cache_key = file_id.to_string();
|
||||
if let Some(cached_path) = app.voice_cache.as_mut().and_then(|c| c.get(&cache_key)) {
|
||||
let path_str = cached_path.to_string_lossy().to_string();
|
||||
handle_play_voice_from_path(app, &path_str, voice, &msg).await;
|
||||
return;
|
||||
}
|
||||
|
||||
// Начинаем загрузку
|
||||
app.status_message = Some("Загрузка голосового...".to_string());
|
||||
match app.td_client.download_voice_note(file_id).await {
|
||||
Ok(path) => {
|
||||
// Кэшируем загруженный файл
|
||||
if let Some(ref mut cache) = app.voice_cache {
|
||||
let _ = cache.store(&cache_key, std::path::Path::new(&path));
|
||||
}
|
||||
|
||||
handle_play_voice_from_path(app, &path, voice, &msg).await;
|
||||
}
|
||||
Err(e) => {
|
||||
app.error_message = Some(format!("Ошибка загрузки: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
VoiceDownloadState::Error(e) => {
|
||||
app.error_message = Some(format!("Ошибка загрузки: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO (Этап 4): Эти функции будут переписаны для модального просмотрщика
|
||||
/*
|
||||
#[cfg(feature = "images")]
|
||||
|
||||
322
src/input/handlers/chat/media.rs
Normal file
322
src/input/handlers/chat/media.rs
Normal file
@@ -0,0 +1,322 @@
|
||||
//! Media actions for the open chat input handler.
|
||||
|
||||
use crate::app::methods::messages::MessageMethods;
|
||||
use crate::app::App;
|
||||
use crate::tdlib::TdClientTrait;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Обработка команды ViewImage — только фото.
|
||||
pub(super) async fn handle_view_or_play_media<T: TdClientTrait>(app: &mut App<T>) {
|
||||
let Some(msg) = app.get_selected_message() else {
|
||||
return;
|
||||
};
|
||||
|
||||
if msg.has_photo() {
|
||||
#[cfg(feature = "images")]
|
||||
handle_view_image(app).await;
|
||||
#[cfg(not(feature = "images"))]
|
||||
{
|
||||
app.status_message = Some("Просмотр изображений отключён".to_string());
|
||||
}
|
||||
} else {
|
||||
app.status_message = Some("Сообщение не содержит фото".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// Space: play/pause toggle для голосовых сообщений.
|
||||
pub(super) async fn handle_toggle_voice_playback<T: TdClientTrait>(app: &mut App<T>) {
|
||||
use crate::tdlib::PlaybackStatus;
|
||||
|
||||
if let Some(ref mut playback) = app.playback_state {
|
||||
if let Some(ref player) = app.audio_player {
|
||||
match playback.status {
|
||||
PlaybackStatus::Playing => {
|
||||
player.pause();
|
||||
playback.status = PlaybackStatus::Paused;
|
||||
app.last_playback_tick = None;
|
||||
app.status_message = Some("⏸ Пауза".to_string());
|
||||
}
|
||||
PlaybackStatus::Paused => {
|
||||
let resume_pos = (playback.position - 1.0).max(0.0);
|
||||
if player.resume_from(resume_pos).is_ok() {
|
||||
playback.position = resume_pos;
|
||||
} else {
|
||||
player.resume();
|
||||
}
|
||||
playback.status = PlaybackStatus::Playing;
|
||||
app.last_playback_tick = Some(Instant::now());
|
||||
app.status_message = Some("▶ Воспроизведение".to_string());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(msg) = app.get_selected_message() else {
|
||||
return;
|
||||
};
|
||||
if msg.has_voice() {
|
||||
handle_play_voice(app).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Seek голосового сообщения на delta секунд.
|
||||
pub(super) fn handle_voice_seek<T: TdClientTrait>(app: &mut App<T>, delta: f32) {
|
||||
use crate::tdlib::PlaybackStatus;
|
||||
|
||||
let Some(ref mut playback) = app.playback_state else {
|
||||
return;
|
||||
};
|
||||
let Some(ref player) = app.audio_player else {
|
||||
return;
|
||||
};
|
||||
|
||||
let was_playing = matches!(playback.status, PlaybackStatus::Playing);
|
||||
let was_paused = matches!(playback.status, PlaybackStatus::Paused);
|
||||
|
||||
if was_playing || was_paused {
|
||||
let new_position = (playback.position + delta).clamp(0.0, playback.duration);
|
||||
|
||||
if was_playing {
|
||||
if player.resume_from(new_position).is_ok() {
|
||||
playback.position = new_position;
|
||||
app.last_playback_tick = Some(Instant::now());
|
||||
}
|
||||
} else {
|
||||
player.stop();
|
||||
playback.position = new_position;
|
||||
}
|
||||
|
||||
let arrow = if delta > 0.0 { "→" } else { "←" };
|
||||
app.status_message = Some(format!("{} {:.0}s", arrow, new_position));
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "images")]
|
||||
async fn handle_view_image<T: TdClientTrait>(app: &mut App<T>) {
|
||||
use crate::tdlib::{ImageModalState, PhotoDownloadState};
|
||||
|
||||
if !app.config().images.show_images {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(msg) = app.get_selected_message() else {
|
||||
return;
|
||||
};
|
||||
|
||||
if !msg.has_photo() {
|
||||
app.status_message = Some("Сообщение не содержит фото".to_string());
|
||||
return;
|
||||
}
|
||||
|
||||
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 download_state {
|
||||
PhotoDownloadState::Downloaded(path) => {
|
||||
app.image_modal = Some(ImageModalState {
|
||||
message_id: msg_id,
|
||||
photo_path: path,
|
||||
photo_width,
|
||||
photo_height,
|
||||
});
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
PhotoDownloadState::NotDownloaded | PhotoDownloadState::Downloading => {
|
||||
app.pending_image_open = Some(crate::app::PendingImageOpen {
|
||||
file_id,
|
||||
message_id: msg_id,
|
||||
photo_width,
|
||||
photo_height,
|
||||
});
|
||||
app.status_message = Some("Загрузка фото...".to_string());
|
||||
app.needs_redraw = true;
|
||||
|
||||
if app.photo_download_rx.is_none() {
|
||||
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
app.photo_download_rx = Some(rx);
|
||||
let client_id = app.td_client.client_id();
|
||||
tokio::spawn(async move {
|
||||
let result = tokio::time::timeout(Duration::from_secs(30), async {
|
||||
match tdlib_rs::functions::download_file(file_id, 1, 0, 0, true, client_id)
|
||||
.await
|
||||
{
|
||||
Ok(tdlib_rs::enums::File::File(f))
|
||||
if f.local.is_downloading_completed && !f.local.path.is_empty() =>
|
||||
{
|
||||
Ok(f.local.path)
|
||||
}
|
||||
Ok(_) => Err("Файл не скачан".to_string()),
|
||||
Err(e) => Err(format!("{:?}", e)),
|
||||
}
|
||||
})
|
||||
.await;
|
||||
let result = result.unwrap_or_else(|_| Err("Таймаут загрузки".to_string()));
|
||||
let _ = tx.send((file_id, result));
|
||||
});
|
||||
}
|
||||
}
|
||||
PhotoDownloadState::Error(_) => {
|
||||
app.status_message = Some("Повторная загрузка фото...".to_string());
|
||||
app.needs_redraw = true;
|
||||
match app.td_client.download_file(file_id).await {
|
||||
Ok(path) => {
|
||||
app.td_client.update_current_chat_messages(|messages| {
|
||||
for msg in messages {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_play_voice_from_path<T: TdClientTrait>(
|
||||
app: &mut App<T>,
|
||||
path: &str,
|
||||
voice: &crate::tdlib::VoiceInfo,
|
||||
msg: &crate::tdlib::MessageInfo,
|
||||
) {
|
||||
use crate::tdlib::{PlaybackState, PlaybackStatus};
|
||||
|
||||
if let Some(ref player) = app.audio_player {
|
||||
match player.play(path) {
|
||||
Ok(_) => {
|
||||
app.playback_state = Some(PlaybackState {
|
||||
message_id: msg.id(),
|
||||
status: PlaybackStatus::Playing,
|
||||
position: 0.0,
|
||||
duration: voice.duration as f32,
|
||||
volume: player.volume(),
|
||||
});
|
||||
app.last_playback_tick = Some(Instant::now());
|
||||
app.status_message = Some(format!("▶ Воспроизведение ({:.0}s)", voice.duration));
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
Err(e) => {
|
||||
app.error_message = Some(format!("Ошибка воспроизведения: {}", e));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
app.error_message = Some("Аудиоплеер не инициализирован".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_play_voice<T: TdClientTrait>(app: &mut App<T>) {
|
||||
use crate::tdlib::VoiceDownloadState;
|
||||
|
||||
let Some(msg) = app.get_selected_message() else {
|
||||
return;
|
||||
};
|
||||
|
||||
if !msg.has_voice() {
|
||||
return;
|
||||
}
|
||||
|
||||
let voice = msg.voice_info().unwrap();
|
||||
let file_id = voice.file_id;
|
||||
|
||||
match &voice.download_state {
|
||||
VoiceDownloadState::Downloaded(path) => {
|
||||
use std::path::Path;
|
||||
let audio_path = if Path::new(path).exists() {
|
||||
path.clone()
|
||||
} else {
|
||||
let with_oga = format!("{}.oga", path);
|
||||
if Path::new(&with_oga).exists() {
|
||||
with_oga
|
||||
} else {
|
||||
if let Some(parent) = Path::new(path).parent() {
|
||||
if let Some(stem) = Path::new(path).file_name() {
|
||||
if let Ok(entries) = std::fs::read_dir(parent) {
|
||||
for entry in entries.flatten() {
|
||||
let entry_name = entry.file_name();
|
||||
if entry_name
|
||||
.to_string_lossy()
|
||||
.starts_with(&stem.to_string_lossy().to_string())
|
||||
{
|
||||
let found_path = entry.path().to_string_lossy().to_string();
|
||||
if let Some(ref mut cache) = app.voice_cache {
|
||||
let _ = cache.store(
|
||||
&file_id.to_string(),
|
||||
Path::new(&found_path),
|
||||
);
|
||||
}
|
||||
return handle_play_voice_from_path(
|
||||
app,
|
||||
&found_path,
|
||||
voice,
|
||||
&msg,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
app.error_message = Some(format!("Файл не найден: {}", path));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(ref mut cache) = app.voice_cache {
|
||||
let _ = cache.store(&file_id.to_string(), Path::new(&audio_path));
|
||||
}
|
||||
|
||||
handle_play_voice_from_path(app, &audio_path, voice, &msg).await;
|
||||
}
|
||||
VoiceDownloadState::Downloading => {
|
||||
app.status_message = Some("Загрузка голосового...".to_string());
|
||||
}
|
||||
VoiceDownloadState::NotDownloaded => {
|
||||
let cache_key = file_id.to_string();
|
||||
if let Some(cached_path) = app.voice_cache.as_mut().and_then(|c| c.get(&cache_key)) {
|
||||
let path_str = cached_path.to_string_lossy().to_string();
|
||||
handle_play_voice_from_path(app, &path_str, voice, &msg).await;
|
||||
return;
|
||||
}
|
||||
|
||||
app.status_message = Some("Загрузка голосового...".to_string());
|
||||
match app.td_client.download_voice_note(file_id).await {
|
||||
Ok(path) => {
|
||||
if let Some(ref mut cache) = app.voice_cache {
|
||||
let _ = cache.store(&cache_key, std::path::Path::new(&path));
|
||||
}
|
||||
|
||||
handle_play_voice_from_path(app, &path, voice, &msg).await;
|
||||
}
|
||||
Err(e) => {
|
||||
app.error_message = Some(format!("Ошибка загрузки: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
VoiceDownloadState::Error(e) => {
|
||||
app.error_message = Some(format!("Ошибка загрузки: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -228,16 +228,18 @@ pub fn process_chat_init_events<T: TdClientTrait>(app: &mut App<T>) {
|
||||
}
|
||||
|
||||
let mut changed = false;
|
||||
for msg in app.td_client.current_chat_messages_mut() {
|
||||
let Some(reply) = msg.interactions.reply_to.as_mut() else {
|
||||
continue;
|
||||
};
|
||||
if reply.message_id == message_id {
|
||||
reply.sender_name = sender_name.clone();
|
||||
reply.text = text.clone();
|
||||
changed = true;
|
||||
app.td_client.update_current_chat_messages(|messages| {
|
||||
for msg in messages {
|
||||
let Some(reply) = msg.interactions.reply_to.as_mut() else {
|
||||
continue;
|
||||
};
|
||||
if reply.message_id == message_id {
|
||||
reply.sender_name = sender_name.clone();
|
||||
reply.text = text.clone();
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if changed {
|
||||
app.needs_redraw = true;
|
||||
@@ -286,7 +288,8 @@ pub async fn load_older_messages_if_needed<T: TdClientTrait>(app: &mut App<T>) {
|
||||
|
||||
// Add older messages to the beginning if any were loaded
|
||||
if !older.is_empty() {
|
||||
let msgs = app.td_client.current_chat_messages_mut();
|
||||
msgs.splice(0..0, older);
|
||||
app.td_client.update_current_chat_messages(|messages| {
|
||||
messages.splice(0..0, older);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ pub mod profile;
|
||||
pub mod search;
|
||||
|
||||
pub use chat_loader::{
|
||||
load_older_messages_if_needed, open_chat_and_load_data, process_chat_init_events,
|
||||
process_chat_init_events,
|
||||
process_pending_chat_init,
|
||||
};
|
||||
pub use clipboard::*;
|
||||
|
||||
@@ -293,8 +293,9 @@ pub async fn handle_delete_confirmation<T: TdClientTrait>(app: &mut App<T>, key:
|
||||
Ok(_) => {
|
||||
// Удаляем из локального списка
|
||||
app.td_client
|
||||
.current_chat_messages_mut()
|
||||
.retain(|m| m.id() != msg_id);
|
||||
.update_current_chat_messages(|messages| {
|
||||
messages.retain(|m| m.id() != msg_id);
|
||||
});
|
||||
// Сбрасываем состояние
|
||||
app.chat_state = crate::app::ChatState::Normal;
|
||||
}
|
||||
|
||||
16
src/main.rs
16
src/main.rs
@@ -222,15 +222,17 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
|
||||
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;
|
||||
app.td_client.update_current_chat_messages(|messages| {
|
||||
for msg in messages {
|
||||
if let Some(photo) = msg.photo_info_mut() {
|
||||
if photo.file_id == file_id {
|
||||
photo.download_state = new_state;
|
||||
got_photos = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
// Если это фото ждёт открытия в модалке — открываем
|
||||
let pending_matches = app
|
||||
.pending_image_open
|
||||
|
||||
@@ -3,19 +3,22 @@
|
||||
//! This file contains the trait implementation that delegates to existing TdClient methods.
|
||||
|
||||
use super::client::TdClient;
|
||||
use super::r#trait::TdClientTrait;
|
||||
use super::r#trait::{
|
||||
AccountClient, AuthClient, ChatActionClient, ChatClient, ClientState, FileClient,
|
||||
MessageClient, NotificationClient, ReactionClient, UpdateClient, UserClient,
|
||||
};
|
||||
use super::{
|
||||
AuthState, ChatInfo, FolderInfo, MessageInfo, ProfileInfo, ReplyInfo, UserCache,
|
||||
UserOnlineStatus,
|
||||
};
|
||||
use crate::types::{ChatId, MessageId, UserId};
|
||||
use async_trait::async_trait;
|
||||
use std::borrow::Cow;
|
||||
use std::path::PathBuf;
|
||||
use tdlib_rs::enums::{ChatAction, Update};
|
||||
|
||||
#[async_trait]
|
||||
impl TdClientTrait for TdClient {
|
||||
// ============ Auth methods ============
|
||||
impl AuthClient for TdClient {
|
||||
async fn send_phone_number(&self, phone: String) -> Result<(), String> {
|
||||
self.send_phone_number(phone).await
|
||||
}
|
||||
@@ -27,8 +30,10 @@ impl TdClientTrait for TdClient {
|
||||
async fn send_password(&self, password: String) -> Result<(), String> {
|
||||
self.send_password(password).await
|
||||
}
|
||||
}
|
||||
|
||||
// ============ Chat methods ============
|
||||
#[async_trait]
|
||||
impl ChatClient for TdClient {
|
||||
async fn load_chats(&mut self, limit: i32) -> Result<(), String> {
|
||||
self.load_chats(limit).await
|
||||
}
|
||||
@@ -45,7 +50,39 @@ impl TdClientTrait for TdClient {
|
||||
self.get_profile_info(chat_id).await
|
||||
}
|
||||
|
||||
// ============ Chat actions ============
|
||||
fn chats(&self) -> &[ChatInfo] {
|
||||
self.chats()
|
||||
}
|
||||
|
||||
fn folders(&self) -> &[FolderInfo] {
|
||||
self.folders()
|
||||
}
|
||||
|
||||
fn main_chat_list_position(&self) -> i32 {
|
||||
self.main_chat_list_position()
|
||||
}
|
||||
|
||||
fn set_main_chat_list_position(&mut self, position: i32) {
|
||||
self.set_main_chat_list_position(position)
|
||||
}
|
||||
|
||||
fn update_chats<F>(&mut self, updater: F)
|
||||
where
|
||||
F: FnOnce(&mut Vec<ChatInfo>),
|
||||
{
|
||||
updater(self.chats_mut());
|
||||
}
|
||||
|
||||
fn update_folders<F>(&mut self, updater: F)
|
||||
where
|
||||
F: FnOnce(&mut Vec<FolderInfo>),
|
||||
{
|
||||
updater(self.folders_mut());
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ChatActionClient for TdClient {
|
||||
async fn send_chat_action(&self, chat_id: ChatId, action: ChatAction) {
|
||||
self.send_chat_action(chat_id, action).await
|
||||
}
|
||||
@@ -54,7 +91,17 @@ impl TdClientTrait for TdClient {
|
||||
self.clear_stale_typing_status()
|
||||
}
|
||||
|
||||
// ============ Message methods ============
|
||||
fn typing_status(&self) -> Option<&(UserId, String, std::time::Instant)> {
|
||||
self.typing_status()
|
||||
}
|
||||
|
||||
fn set_typing_status(&mut self, status: Option<(UserId, String, std::time::Instant)>) {
|
||||
self.set_typing_status(status)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl MessageClient for TdClient {
|
||||
async fn get_chat_history(
|
||||
&mut self,
|
||||
chat_id: ChatId,
|
||||
@@ -132,6 +179,18 @@ impl TdClientTrait for TdClient {
|
||||
self.set_draft_message(chat_id, text).await
|
||||
}
|
||||
|
||||
fn current_chat_messages(&self) -> Cow<'_, [MessageInfo]> {
|
||||
Cow::Borrowed(self.current_chat_messages())
|
||||
}
|
||||
|
||||
fn current_chat_id(&self) -> Option<ChatId> {
|
||||
self.current_chat_id()
|
||||
}
|
||||
|
||||
fn current_pinned_message(&self) -> Option<MessageInfo> {
|
||||
self.current_pinned_message().cloned()
|
||||
}
|
||||
|
||||
fn push_message(&mut self, msg: MessageInfo) {
|
||||
self.push_message(msg)
|
||||
}
|
||||
@@ -144,16 +203,66 @@ impl TdClientTrait for TdClient {
|
||||
self.process_pending_view_messages().await
|
||||
}
|
||||
|
||||
// ============ User methods ============
|
||||
fn clear_current_chat_messages(&mut self) {
|
||||
self.current_chat_messages_mut().clear()
|
||||
}
|
||||
|
||||
fn set_current_chat_messages(&mut self, messages: Vec<MessageInfo>) {
|
||||
*self.current_chat_messages_mut() = messages;
|
||||
}
|
||||
|
||||
fn update_current_chat_messages<F>(&mut self, updater: F)
|
||||
where
|
||||
F: FnOnce(&mut Vec<MessageInfo>),
|
||||
{
|
||||
updater(self.current_chat_messages_mut());
|
||||
}
|
||||
|
||||
fn set_current_chat_id(&mut self, chat_id: Option<ChatId>) {
|
||||
self.set_current_chat_id(chat_id)
|
||||
}
|
||||
|
||||
fn set_current_pinned_message(&mut self, msg: Option<MessageInfo>) {
|
||||
self.set_current_pinned_message(msg)
|
||||
}
|
||||
|
||||
fn pending_view_messages(&self) -> &[(ChatId, Vec<MessageId>)] {
|
||||
self.pending_view_messages()
|
||||
}
|
||||
|
||||
fn enqueue_pending_view_messages(&mut self, chat_id: ChatId, message_ids: Vec<MessageId>) {
|
||||
self.enqueue_pending_view_messages(chat_id, message_ids);
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl UserClient for TdClient {
|
||||
fn get_user_status_by_chat_id(&self, chat_id: ChatId) -> Option<&UserOnlineStatus> {
|
||||
self.get_user_status_by_chat_id(chat_id)
|
||||
}
|
||||
|
||||
fn pending_user_ids(&self) -> &[UserId] {
|
||||
self.pending_user_ids()
|
||||
}
|
||||
|
||||
fn user_cache(&self) -> &UserCache {
|
||||
self.user_cache()
|
||||
}
|
||||
|
||||
fn update_user_cache<F>(&mut self, updater: F)
|
||||
where
|
||||
F: FnOnce(&mut UserCache),
|
||||
{
|
||||
updater(self.user_cache_mut());
|
||||
}
|
||||
|
||||
async fn process_pending_user_ids(&mut self) {
|
||||
self.process_pending_user_ids().await
|
||||
}
|
||||
}
|
||||
|
||||
// ============ Reaction methods ============
|
||||
#[async_trait]
|
||||
impl ReactionClient for TdClient {
|
||||
async fn get_message_available_reactions(
|
||||
&self,
|
||||
chat_id: ChatId,
|
||||
@@ -171,8 +280,10 @@ impl TdClientTrait for TdClient {
|
||||
) -> Result<(), String> {
|
||||
self.toggle_reaction(chat_id, message_id, reaction).await
|
||||
}
|
||||
}
|
||||
|
||||
// ============ File methods ============
|
||||
#[async_trait]
|
||||
impl FileClient for TdClient {
|
||||
async fn download_file(&self, file_id: i32) -> Result<String, String> {
|
||||
self.download_file(file_id).await
|
||||
}
|
||||
@@ -181,7 +292,10 @@ impl TdClientTrait for TdClient {
|
||||
// Voice notes use the same download mechanism as photos
|
||||
self.download_file(file_id).await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ClientState for TdClient {
|
||||
fn client_id(&self) -> i32 {
|
||||
self.client_id()
|
||||
}
|
||||
@@ -194,99 +308,12 @@ impl TdClientTrait for TdClient {
|
||||
self.auth_state()
|
||||
}
|
||||
|
||||
fn chats(&self) -> &[ChatInfo] {
|
||||
self.chats()
|
||||
}
|
||||
|
||||
fn folders(&self) -> &[FolderInfo] {
|
||||
self.folders()
|
||||
}
|
||||
|
||||
fn current_chat_messages(&self) -> Vec<MessageInfo> {
|
||||
self.message_manager.current_chat_messages.to_vec()
|
||||
}
|
||||
|
||||
fn current_chat_id(&self) -> Option<ChatId> {
|
||||
self.current_chat_id()
|
||||
}
|
||||
|
||||
fn current_pinned_message(&self) -> Option<MessageInfo> {
|
||||
self.message_manager.current_pinned_message.clone()
|
||||
}
|
||||
|
||||
fn typing_status(&self) -> Option<&(UserId, String, std::time::Instant)> {
|
||||
self.typing_status()
|
||||
}
|
||||
|
||||
fn pending_view_messages(&self) -> &[(ChatId, Vec<MessageId>)] {
|
||||
self.pending_view_messages()
|
||||
}
|
||||
|
||||
fn pending_user_ids(&self) -> &[UserId] {
|
||||
self.pending_user_ids()
|
||||
}
|
||||
|
||||
fn main_chat_list_position(&self) -> i32 {
|
||||
self.main_chat_list_position()
|
||||
}
|
||||
|
||||
fn user_cache(&self) -> &UserCache {
|
||||
self.user_cache()
|
||||
}
|
||||
|
||||
fn network_state(&self) -> super::types::NetworkState {
|
||||
self.network_state.clone()
|
||||
}
|
||||
}
|
||||
|
||||
fn chats_mut(&mut self) -> &mut Vec<ChatInfo> {
|
||||
self.chats_mut()
|
||||
}
|
||||
|
||||
fn folders_mut(&mut self) -> &mut Vec<FolderInfo> {
|
||||
self.folders_mut()
|
||||
}
|
||||
|
||||
fn current_chat_messages_mut(&mut self) -> &mut Vec<MessageInfo> {
|
||||
self.current_chat_messages_mut()
|
||||
}
|
||||
|
||||
fn clear_current_chat_messages(&mut self) {
|
||||
self.current_chat_messages_mut().clear()
|
||||
}
|
||||
|
||||
fn set_current_chat_messages(&mut self, messages: Vec<MessageInfo>) {
|
||||
*self.current_chat_messages_mut() = messages;
|
||||
}
|
||||
|
||||
fn set_current_chat_id(&mut self, chat_id: Option<ChatId>) {
|
||||
self.set_current_chat_id(chat_id)
|
||||
}
|
||||
|
||||
fn set_current_pinned_message(&mut self, msg: Option<MessageInfo>) {
|
||||
self.set_current_pinned_message(msg)
|
||||
}
|
||||
|
||||
fn set_typing_status(&mut self, status: Option<(UserId, String, std::time::Instant)>) {
|
||||
self.set_typing_status(status)
|
||||
}
|
||||
|
||||
fn enqueue_pending_view_messages(&mut self, chat_id: ChatId, message_ids: Vec<MessageId>) {
|
||||
self.enqueue_pending_view_messages(chat_id, message_ids);
|
||||
}
|
||||
|
||||
fn pending_user_ids_mut(&mut self) -> &mut Vec<UserId> {
|
||||
self.pending_user_ids_mut()
|
||||
}
|
||||
|
||||
fn set_main_chat_list_position(&mut self, position: i32) {
|
||||
self.set_main_chat_list_position(position)
|
||||
}
|
||||
|
||||
fn user_cache_mut(&mut self) -> &mut UserCache {
|
||||
&mut self.user_cache
|
||||
}
|
||||
|
||||
// ============ Notification methods ============
|
||||
impl NotificationClient for TdClient {
|
||||
fn configure_notifications(&mut self, config: &crate::config::NotificationsConfig) {
|
||||
self.configure_notifications(config);
|
||||
}
|
||||
@@ -295,13 +322,16 @@ impl TdClientTrait for TdClient {
|
||||
self.notification_manager
|
||||
.sync_muted_chats(&self.chat_manager.chats);
|
||||
}
|
||||
}
|
||||
|
||||
// ============ Account switching ============
|
||||
#[async_trait]
|
||||
impl AccountClient for TdClient {
|
||||
async fn recreate_client(&mut self, db_path: PathBuf) -> Result<(), String> {
|
||||
TdClient::recreate_client(self, db_path).await
|
||||
}
|
||||
}
|
||||
|
||||
// ============ Update handling ============
|
||||
impl UpdateClient for TdClient {
|
||||
fn handle_update(&mut self, update: Update) {
|
||||
// Delegate to the real implementation
|
||||
TdClient::handle_update(self, update)
|
||||
|
||||
@@ -16,7 +16,11 @@ pub mod users;
|
||||
// Экспорт основных типов
|
||||
pub use auth::AuthState;
|
||||
pub use client::TdClient;
|
||||
pub use r#trait::TdClientTrait;
|
||||
#[allow(unused_imports)]
|
||||
pub use r#trait::{
|
||||
AccountClient, AuthClient, ChatActionClient, ChatClient, ClientState, FileClient,
|
||||
MessageClient, NotificationClient, ReactionClient, TdClientTrait, UpdateClient, UserClient,
|
||||
};
|
||||
#[allow(unused_imports)]
|
||||
pub use types::{
|
||||
ChatInfo, FolderInfo, MediaInfo, MessageBuilder, MessageInfo, NetworkState, PhotoDownloadState,
|
||||
|
||||
@@ -1,38 +1,57 @@
|
||||
//! Trait definition for TdClient to enable dependency injection
|
||||
//!
|
||||
//! This trait allows tests to use FakeTdClient instead of real TDLib client.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use crate::tdlib::{AuthState, FolderInfo, MessageInfo, ProfileInfo, UserCache, UserOnlineStatus};
|
||||
use crate::types::{ChatId, MessageId, UserId};
|
||||
use async_trait::async_trait;
|
||||
use std::borrow::Cow;
|
||||
use std::path::PathBuf;
|
||||
use tdlib_rs::enums::{ChatAction, Update};
|
||||
|
||||
use super::ChatInfo;
|
||||
|
||||
/// Trait for TDLib client operations
|
||||
///
|
||||
/// This trait defines the interface for both real and fake TDLib clients,
|
||||
/// enabling dependency injection and easier testing.
|
||||
#[allow(dead_code)]
|
||||
/// Auth operations.
|
||||
#[async_trait]
|
||||
pub trait TdClientTrait: Send {
|
||||
// ============ Auth methods ============
|
||||
pub trait AuthClient: Send {
|
||||
async fn send_phone_number(&self, phone: String) -> Result<(), String>;
|
||||
async fn send_code(&self, code: String) -> Result<(), String>;
|
||||
async fn send_password(&self, password: String) -> Result<(), String>;
|
||||
}
|
||||
|
||||
// ============ Chat methods ============
|
||||
/// Chat list and profile operations.
|
||||
#[async_trait]
|
||||
pub trait ChatClient: Send {
|
||||
async fn load_chats(&mut self, limit: i32) -> Result<(), String>;
|
||||
async fn load_folder_chats(&mut self, folder_id: i32, limit: i32) -> Result<(), String>;
|
||||
async fn leave_chat(&self, chat_id: ChatId) -> Result<(), String>;
|
||||
async fn get_profile_info(&self, chat_id: ChatId) -> Result<ProfileInfo, String>;
|
||||
|
||||
// ============ Chat actions ============
|
||||
fn chats(&self) -> &[ChatInfo];
|
||||
fn folders(&self) -> &[FolderInfo];
|
||||
fn main_chat_list_position(&self) -> i32;
|
||||
fn set_main_chat_list_position(&mut self, position: i32);
|
||||
fn update_chats<F>(&mut self, updater: F)
|
||||
where
|
||||
F: FnOnce(&mut Vec<ChatInfo>);
|
||||
fn update_folders<F>(&mut self, updater: F)
|
||||
where
|
||||
F: FnOnce(&mut Vec<FolderInfo>);
|
||||
}
|
||||
|
||||
/// Ephemeral chat actions such as typing status.
|
||||
#[async_trait]
|
||||
pub trait ChatActionClient: Send {
|
||||
async fn send_chat_action(&self, chat_id: ChatId, action: ChatAction);
|
||||
fn clear_stale_typing_status(&mut self) -> bool;
|
||||
fn typing_status(&self) -> Option<&(UserId, String, std::time::Instant)>;
|
||||
fn set_typing_status(&mut self, status: Option<(UserId, String, std::time::Instant)>);
|
||||
}
|
||||
|
||||
// ============ Message methods ============
|
||||
/// Message history, search, and mutation operations.
|
||||
#[async_trait]
|
||||
pub trait MessageClient: Send {
|
||||
async fn get_chat_history(
|
||||
&mut self,
|
||||
chat_id: ChatId,
|
||||
@@ -82,15 +101,38 @@ pub trait TdClientTrait: Send {
|
||||
|
||||
async fn set_draft_message(&self, chat_id: ChatId, text: String) -> Result<(), String>;
|
||||
|
||||
fn current_chat_messages(&self) -> Cow<'_, [MessageInfo]>;
|
||||
fn current_chat_id(&self) -> Option<ChatId>;
|
||||
fn current_pinned_message(&self) -> Option<MessageInfo>;
|
||||
fn push_message(&mut self, msg: MessageInfo);
|
||||
fn clear_current_chat_messages(&mut self);
|
||||
fn set_current_chat_messages(&mut self, messages: Vec<MessageInfo>);
|
||||
fn update_current_chat_messages<F>(&mut self, updater: F)
|
||||
where
|
||||
F: FnOnce(&mut Vec<MessageInfo>);
|
||||
fn set_current_chat_id(&mut self, chat_id: Option<ChatId>);
|
||||
fn set_current_pinned_message(&mut self, msg: Option<MessageInfo>);
|
||||
fn pending_view_messages(&self) -> &[(ChatId, Vec<MessageId>)];
|
||||
fn enqueue_pending_view_messages(&mut self, chat_id: ChatId, message_ids: Vec<MessageId>);
|
||||
async fn fetch_missing_reply_info(&mut self);
|
||||
async fn process_pending_view_messages(&mut self);
|
||||
}
|
||||
|
||||
// ============ User methods ============
|
||||
/// User cache and user-status operations.
|
||||
#[async_trait]
|
||||
pub trait UserClient: Send {
|
||||
fn get_user_status_by_chat_id(&self, chat_id: ChatId) -> Option<&UserOnlineStatus>;
|
||||
fn pending_user_ids(&self) -> &[UserId];
|
||||
fn user_cache(&self) -> &UserCache;
|
||||
fn update_user_cache<F>(&mut self, updater: F)
|
||||
where
|
||||
F: FnOnce(&mut UserCache);
|
||||
async fn process_pending_user_ids(&mut self);
|
||||
}
|
||||
|
||||
// ============ Reaction methods ============
|
||||
/// Message reaction operations.
|
||||
#[async_trait]
|
||||
pub trait ReactionClient: Send {
|
||||
async fn get_message_available_reactions(
|
||||
&self,
|
||||
chat_id: ChatId,
|
||||
@@ -103,52 +145,78 @@ pub trait TdClientTrait: Send {
|
||||
message_id: MessageId,
|
||||
reaction: String,
|
||||
) -> Result<(), String>;
|
||||
}
|
||||
|
||||
// ============ File methods ============
|
||||
/// File download operations.
|
||||
#[async_trait]
|
||||
pub trait FileClient: Send {
|
||||
async fn download_file(&self, file_id: i32) -> Result<String, String>;
|
||||
async fn download_voice_note(&self, file_id: i32) -> Result<String, String>;
|
||||
}
|
||||
|
||||
// ============ Getters (immutable) ============
|
||||
/// Shared client state that does not belong to one feature area.
|
||||
#[async_trait]
|
||||
pub trait ClientState: Send {
|
||||
fn client_id(&self) -> i32;
|
||||
async fn get_me(&self) -> Result<i64, String>;
|
||||
fn auth_state(&self) -> &AuthState;
|
||||
fn chats(&self) -> &[ChatInfo];
|
||||
fn folders(&self) -> &[FolderInfo];
|
||||
fn current_chat_messages(&self) -> Vec<MessageInfo>;
|
||||
fn current_chat_id(&self) -> Option<ChatId>;
|
||||
fn current_pinned_message(&self) -> Option<MessageInfo>;
|
||||
fn typing_status(&self) -> Option<&(UserId, String, std::time::Instant)>;
|
||||
fn pending_view_messages(&self) -> &[(ChatId, Vec<MessageId>)];
|
||||
fn pending_user_ids(&self) -> &[UserId];
|
||||
fn main_chat_list_position(&self) -> i32;
|
||||
fn user_cache(&self) -> &UserCache;
|
||||
fn network_state(&self) -> super::types::NetworkState;
|
||||
}
|
||||
|
||||
// ============ Setters (mutable) ============
|
||||
fn chats_mut(&mut self) -> &mut Vec<ChatInfo>;
|
||||
fn folders_mut(&mut self) -> &mut Vec<FolderInfo>;
|
||||
fn current_chat_messages_mut(&mut self) -> &mut Vec<MessageInfo>;
|
||||
fn clear_current_chat_messages(&mut self);
|
||||
fn set_current_chat_messages(&mut self, messages: Vec<MessageInfo>);
|
||||
fn set_current_chat_id(&mut self, chat_id: Option<ChatId>);
|
||||
fn set_current_pinned_message(&mut self, msg: Option<MessageInfo>);
|
||||
fn set_typing_status(&mut self, status: Option<(UserId, String, std::time::Instant)>);
|
||||
fn enqueue_pending_view_messages(&mut self, chat_id: ChatId, message_ids: Vec<MessageId>);
|
||||
fn pending_user_ids_mut(&mut self) -> &mut Vec<UserId>;
|
||||
fn set_main_chat_list_position(&mut self, position: i32);
|
||||
fn user_cache_mut(&mut self) -> &mut UserCache;
|
||||
|
||||
// ============ Notification methods ============
|
||||
/// Notification configuration operations.
|
||||
pub trait NotificationClient: Send {
|
||||
fn configure_notifications(&mut self, config: &crate::config::NotificationsConfig);
|
||||
fn sync_notification_muted_chats(&mut self);
|
||||
}
|
||||
|
||||
// ============ Account switching ============
|
||||
/// Account switching operations.
|
||||
#[async_trait]
|
||||
pub trait AccountClient: Send {
|
||||
/// 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 ============
|
||||
/// TDLib update routing.
|
||||
pub trait UpdateClient: Send {
|
||||
fn handle_update(&mut self, update: Update);
|
||||
}
|
||||
|
||||
/// Facade trait for TDLib client operations
|
||||
///
|
||||
/// This trait defines the interface for both real and fake TDLib clients,
|
||||
/// enabling dependency injection and easier testing.
|
||||
#[allow(dead_code)]
|
||||
pub trait TdClientTrait:
|
||||
AuthClient
|
||||
+ ChatClient
|
||||
+ ChatActionClient
|
||||
+ MessageClient
|
||||
+ UserClient
|
||||
+ ReactionClient
|
||||
+ FileClient
|
||||
+ ClientState
|
||||
+ NotificationClient
|
||||
+ AccountClient
|
||||
+ UpdateClient
|
||||
+ Send
|
||||
{
|
||||
}
|
||||
|
||||
impl<T> TdClientTrait for T where
|
||||
T: AuthClient
|
||||
+ ChatClient
|
||||
+ ChatActionClient
|
||||
+ MessageClient
|
||||
+ UserClient
|
||||
+ ReactionClient
|
||||
+ FileClient
|
||||
+ ClientState
|
||||
+ NotificationClient
|
||||
+ AccountClient
|
||||
+ UpdateClient
|
||||
+ Send
|
||||
{
|
||||
}
|
||||
|
||||
@@ -1,60 +1,145 @@
|
||||
use chrono::{DateTime, Local, NaiveDate, Utc};
|
||||
#[cfg(test)]
|
||||
use chrono::FixedOffset;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
fn as_local_datetime(timestamp: i32) -> Option<DateTime<Local>> {
|
||||
DateTime::<Utc>::from_timestamp(timestamp as i64, 0).map(|dt| dt.with_timezone(&Local))
|
||||
pub trait LocalTimeSource {
|
||||
fn now_date(&self) -> NaiveDate;
|
||||
fn now_timestamp(&self) -> i32;
|
||||
fn format_timestamp(&self, timestamp: i32, format: &str) -> Option<String>;
|
||||
fn date_for_timestamp(&self, timestamp: i32) -> Option<NaiveDate>;
|
||||
}
|
||||
|
||||
pub struct SystemLocalTime;
|
||||
|
||||
impl LocalTimeSource for SystemLocalTime {
|
||||
fn now_date(&self) -> NaiveDate {
|
||||
Local::now().date_naive()
|
||||
}
|
||||
|
||||
fn now_timestamp(&self) -> i32 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs() as i32
|
||||
}
|
||||
|
||||
fn format_timestamp(&self, timestamp: i32, format: &str) -> Option<String> {
|
||||
DateTime::<Utc>::from_timestamp(timestamp as i64, 0)
|
||||
.map(|dt| dt.with_timezone(&Local).format(format).to_string())
|
||||
}
|
||||
|
||||
fn date_for_timestamp(&self, timestamp: i32) -> Option<NaiveDate> {
|
||||
DateTime::<Utc>::from_timestamp(timestamp as i64, 0)
|
||||
.map(|dt| dt.with_timezone(&Local).date_naive())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[cfg(test)]
|
||||
pub struct FixedLocalTime {
|
||||
offset: FixedOffset,
|
||||
now: DateTime<FixedOffset>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl FixedLocalTime {
|
||||
fn new(offset: FixedOffset, now_timestamp: i32) -> Self {
|
||||
let now = DateTime::<Utc>::from_timestamp(now_timestamp as i64, 0)
|
||||
.expect("valid fixed timestamp")
|
||||
.with_timezone(&offset);
|
||||
Self { offset, now }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl LocalTimeSource for FixedLocalTime {
|
||||
fn now_date(&self) -> NaiveDate {
|
||||
self.now.date_naive()
|
||||
}
|
||||
|
||||
fn now_timestamp(&self) -> i32 {
|
||||
self.now.timestamp() as i32
|
||||
}
|
||||
|
||||
fn format_timestamp(&self, timestamp: i32, format: &str) -> Option<String> {
|
||||
DateTime::<Utc>::from_timestamp(timestamp as i64, 0)
|
||||
.map(|dt| dt.with_timezone(&self.offset).format(format).to_string())
|
||||
}
|
||||
|
||||
fn date_for_timestamp(&self, timestamp: i32) -> Option<NaiveDate> {
|
||||
DateTime::<Utc>::from_timestamp(timestamp as i64, 0)
|
||||
.map(|dt| dt.with_timezone(&self.offset).date_naive())
|
||||
}
|
||||
}
|
||||
|
||||
fn system_time() -> SystemLocalTime {
|
||||
SystemLocalTime
|
||||
}
|
||||
|
||||
/// Форматирование timestamp во время HH:MM в системной таймзоне.
|
||||
pub fn format_timestamp(timestamp: i32) -> String {
|
||||
as_local_datetime(timestamp)
|
||||
.map(|dt| dt.format("%H:%M").to_string())
|
||||
format_timestamp_with(timestamp, &system_time())
|
||||
}
|
||||
|
||||
pub fn format_timestamp_with(timestamp: i32, time: &impl LocalTimeSource) -> String {
|
||||
time.format_timestamp(timestamp, "%H:%M")
|
||||
.unwrap_or_else(|| "00:00".to_string())
|
||||
}
|
||||
|
||||
/// Форматирование timestamp в дату для разделителя.
|
||||
pub fn format_date(timestamp: i32) -> String {
|
||||
let Some(msg_dt) = as_local_datetime(timestamp) else {
|
||||
format_date_with(timestamp, &system_time())
|
||||
}
|
||||
|
||||
pub fn format_date_with(timestamp: i32, time: &impl LocalTimeSource) -> String {
|
||||
let Some(msg_day) = time.date_for_timestamp(timestamp) else {
|
||||
return "01.01.1970".to_string();
|
||||
};
|
||||
|
||||
let msg_day = msg_dt.date_naive();
|
||||
let today = Local::now().date_naive();
|
||||
let today = time.now_date();
|
||||
|
||||
if msg_day == today {
|
||||
"Сегодня".to_string()
|
||||
} else if Some(msg_day) == today.pred_opt() {
|
||||
"Вчера".to_string()
|
||||
} else {
|
||||
msg_dt.format("%d.%m.%Y").to_string()
|
||||
time.format_timestamp(timestamp, "%d.%m.%Y")
|
||||
.unwrap_or_else(|| "01.01.1970".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Получить день из timestamp для группировки.
|
||||
/// Возвращает число дней с 1970-01-01 в системной таймзоне.
|
||||
pub fn get_day(timestamp: i32) -> i64 {
|
||||
get_day_with(timestamp, &system_time())
|
||||
}
|
||||
|
||||
pub fn get_day_with(timestamp: i32, time: &impl LocalTimeSource) -> i64 {
|
||||
let epoch = NaiveDate::from_ymd_opt(1970, 1, 1).expect("valid epoch date");
|
||||
|
||||
as_local_datetime(timestamp)
|
||||
.map(|dt| dt.date_naive().signed_duration_since(epoch).num_days())
|
||||
time.date_for_timestamp(timestamp)
|
||||
.map(|date| date.signed_duration_since(epoch).num_days())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Форматирование timestamp в полную дату и время (DD.MM.YYYY HH:MM) в системной таймзоне.
|
||||
pub fn format_datetime(timestamp: i32) -> String {
|
||||
as_local_datetime(timestamp)
|
||||
.map(|dt| dt.format("%d.%m.%Y %H:%M").to_string())
|
||||
format_datetime_with(timestamp, &system_time())
|
||||
}
|
||||
|
||||
pub fn format_datetime_with(timestamp: i32, time: &impl LocalTimeSource) -> String {
|
||||
time.format_timestamp(timestamp, "%d.%m.%Y %H:%M")
|
||||
.unwrap_or_else(|| "01.01.1970 00:00".to_string())
|
||||
}
|
||||
|
||||
/// Форматирование "был(а) онлайн" из timestamp
|
||||
pub fn format_was_online(timestamp: i32) -> String {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs() as i32;
|
||||
format_was_online_with(timestamp, &system_time())
|
||||
}
|
||||
|
||||
pub fn format_was_online_with(timestamp: i32, time: &impl LocalTimeSource) -> String {
|
||||
let now = time.now_timestamp();
|
||||
let diff = now - timestamp;
|
||||
|
||||
if diff < 60 {
|
||||
@@ -67,8 +152,8 @@ pub fn format_was_online(timestamp: i32) -> String {
|
||||
format!("был(а) {} ч. назад", hours)
|
||||
} else {
|
||||
// Показываем локальную дату
|
||||
let datetime = as_local_datetime(timestamp)
|
||||
.map(|dt| dt.format("%d.%m %H:%M").to_string())
|
||||
let datetime = time
|
||||
.format_timestamp(timestamp, "%d.%m %H:%M")
|
||||
.unwrap_or_else(|| "давно".to_string());
|
||||
format!("был(а) {}", datetime)
|
||||
}
|
||||
@@ -78,83 +163,69 @@ pub fn format_was_online(timestamp: i32) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn fixed_time() -> FixedLocalTime {
|
||||
FixedLocalTime::new(
|
||||
FixedOffset::east_opt(3 * 3600).unwrap(),
|
||||
1_640_448_000, // 25.12.2021 03:00:00 +03:00
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_timestamp_matches_local_timezone() {
|
||||
fn test_format_timestamp_uses_supplied_timezone() {
|
||||
let timestamp = 1640000000;
|
||||
let expected = as_local_datetime(timestamp)
|
||||
.unwrap()
|
||||
.format("%H:%M")
|
||||
.to_string();
|
||||
assert_eq!(format_timestamp(timestamp), expected);
|
||||
assert_eq!(format_timestamp_with(timestamp, &fixed_time()), "14:33");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_day() {
|
||||
assert_eq!(get_day(0), 0);
|
||||
assert_eq!(get_day(86400), 1);
|
||||
let time = FixedLocalTime::new(FixedOffset::east_opt(0).unwrap(), 3 * 86_400);
|
||||
assert_eq!(get_day_with(0, &time), 0);
|
||||
assert_eq!(get_day_with(86400, &time), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_day_grouping() {
|
||||
let time = fixed_time();
|
||||
let msg1 = 1640000000;
|
||||
let msg2 = msg1 + 3600;
|
||||
assert_eq!(get_day(msg1), get_day(msg2));
|
||||
assert_eq!(get_day_with(msg1, &time), get_day_with(msg2, &time));
|
||||
|
||||
let msg3 = msg1 + 172800;
|
||||
assert_ne!(get_day(msg1), get_day(msg3));
|
||||
assert_ne!(get_day_with(msg1, &time), get_day_with(msg3, &time));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_datetime() {
|
||||
let timestamp = 1640000000;
|
||||
let result = format_datetime(timestamp);
|
||||
|
||||
assert_eq!(result.chars().filter(|&c| c == '.').count(), 2);
|
||||
assert!(result.contains(":"));
|
||||
assert_eq!(format_datetime_with(timestamp, &fixed_time()), "20.12.2021 14:33");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_date_today() {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs() as i32;
|
||||
|
||||
let result = format_date(now);
|
||||
let time = fixed_time();
|
||||
let result = format_date_with(time.now_timestamp(), &time);
|
||||
assert_eq!(result, "Сегодня");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_date_yesterday() {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs() as i32;
|
||||
|
||||
let yesterday = now - 86400;
|
||||
let result = format_date(yesterday);
|
||||
let time = fixed_time();
|
||||
let yesterday = time.now_timestamp() - 86400;
|
||||
let result = format_date_with(yesterday, &time);
|
||||
assert_eq!(result, "Вчера");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_date_old() {
|
||||
let old_timestamp = 1640000000;
|
||||
let result = format_date(old_timestamp);
|
||||
|
||||
assert!(result.contains('.'), "Expected date format with dots");
|
||||
assert_ne!(result, "Сегодня");
|
||||
assert_ne!(result, "Вчера");
|
||||
assert_eq!(result.split('.').count(), 3);
|
||||
assert_eq!(format_date_with(old_timestamp, &fixed_time()), "20.12.2021");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_date_epoch() {
|
||||
let epoch = 0;
|
||||
let result = format_date(epoch);
|
||||
let time = FixedLocalTime::new(FixedOffset::east_opt(0).unwrap(), 3 * 86_400);
|
||||
let result = format_date_with(epoch, &time);
|
||||
|
||||
assert!(result.contains('.'));
|
||||
assert!(result.contains("1970"));
|
||||
@@ -162,57 +233,37 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_format_was_online_just_now() {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs() as i32;
|
||||
|
||||
let time = fixed_time();
|
||||
let now = time.now_timestamp();
|
||||
let recent = now - 30;
|
||||
let result = format_was_online(recent);
|
||||
let result = format_was_online_with(recent, &time);
|
||||
assert_eq!(result, "был(а) только что");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_was_online_minutes_ago() {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs() as i32;
|
||||
|
||||
let time = fixed_time();
|
||||
let now = time.now_timestamp();
|
||||
let mins_ago = now - (15 * 60);
|
||||
let result = format_was_online(mins_ago);
|
||||
let result = format_was_online_with(mins_ago, &time);
|
||||
assert_eq!(result, "был(а) 15 мин. назад");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_was_online_hours_ago() {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs() as i32;
|
||||
|
||||
let time = fixed_time();
|
||||
let now = time.now_timestamp();
|
||||
let hours_ago = now - (5 * 3600);
|
||||
let result = format_was_online(hours_ago);
|
||||
let result = format_was_online_with(hours_ago, &time);
|
||||
assert_eq!(result, "был(а) 5 ч. назад");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_was_online_days_ago() {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs() as i32;
|
||||
|
||||
let time = fixed_time();
|
||||
let now = time.now_timestamp();
|
||||
let days_ago = now - (3 * 86400);
|
||||
let result = format_was_online(days_ago);
|
||||
let result = format_was_online_with(days_ago, &time);
|
||||
|
||||
assert!(result.starts_with("был(а)"));
|
||||
assert!(result.contains('.') || result.contains(':'));
|
||||
@@ -221,7 +272,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_format_was_online_very_old() {
|
||||
let old = 1577836800;
|
||||
let result = format_was_online(old);
|
||||
let result = format_was_online_with(old, &fixed_time());
|
||||
|
||||
assert!(result.starts_with("был(а)"));
|
||||
assert!(result.contains('.'));
|
||||
|
||||
Reference in New Issue
Block a user