Refactor TDLib facade and local time handling

This commit is contained in:
Mikhail Kilin
2026-05-17 17:58:29 +03:00
parent e09b83be69
commit 2e510dc932
38 changed files with 1025 additions and 862 deletions

View File

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

View File

@@ -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")]

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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