refactor: extract state modules and services from monolithic files
Some checks failed
CI / Check (pull_request) Has been cancelled
CI / Format (pull_request) Has been cancelled
CI / Clippy (pull_request) Has been cancelled
CI / Build (macos-latest) (pull_request) Has been cancelled
CI / Build (ubuntu-latest) (pull_request) Has been cancelled
CI / Build (windows-latest) (pull_request) Has been cancelled

Извлечены state модули и сервисы из монолитных файлов для улучшения структуры:

State модули:
- auth_state.rs: состояние авторизации
- chat_list_state.rs: состояние списка чатов
- compose_state.rs: состояние ввода сообщений
- message_view_state.rs: состояние просмотра сообщений
- ui_state.rs: UI состояние

Сервисы и утилиты:
- chat_filter.rs: централизованная фильтрация чатов (470+ строк)
- message_service.rs: сервис работы с сообщениями (17KB)
- key_handler.rs: trait для обработки клавиш (380+ строк)

Config модуль:
- config.rs -> config/mod.rs: основной конфиг
- config/keybindings.rs: настраиваемые горячие клавиши (420+ строк)

Тесты: 626 passed 

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Mikhail Kilin
2026-02-04 19:29:25 +03:00
parent 72c4a886fa
commit bd5e5be618
13 changed files with 3173 additions and 369 deletions

512
src/app/message_service.rs Normal file
View File

@@ -0,0 +1,512 @@
/// Модуль для бизнес-логики работы с сообщениями
///
/// Чёткое разделение ответственности:
/// - `tdlib/messages.rs` - только получение и преобразование из TDLib
/// - `app/message_service.rs` (этот модуль) - бизнес-логика и операции
/// - `ui/messages.rs` - только рендеринг
///
/// Этот модуль отвечает за:
/// - Группировку сообщений по дате и отправителю
/// - Фильтрацию сообщений
/// - Поиск внутри сообщений
/// - Навигацию по сообщениям
/// - Операции над сообщениями (edit, delete, reply и т.д.)
use crate::tdlib::MessageInfo;
use crate::types::MessageId;
use chrono::{DateTime, Local};
use std::collections::HashMap;
/// Группа сообщений по дате
#[derive(Debug, Clone)]
pub struct MessageGroup {
/// Дата группы (отображаемая строка, например "Сегодня", "Вчера", "1 января")
pub date_label: String,
/// Сообщения в этой группе (отсортированы по времени)
pub messages: Vec<MessageId>,
}
/// Подгруппа сообщений от одного отправителя
#[derive(Debug, Clone)]
pub struct SenderGroup {
/// ID первого сообщения в группе
pub first_message_id: MessageId,
/// Имя отправителя
pub sender_name: String,
/// Список ID сообщений от этого отправителя подряд
pub message_ids: Vec<MessageId>,
}
/// Результат поиска сообщений
#[derive(Debug, Clone)]
pub struct MessageSearchResult {
/// ID сообщения
pub message_id: MessageId,
/// Позиция в списке сообщений
pub index: usize,
/// Фрагмент текста с совпадением
pub snippet: String,
/// Позиция совпадения в тексте
pub match_position: usize,
}
/// Сервис для работы с сообщениями
pub struct MessageService;
impl MessageService {
/// Группирует сообщения по дате
///
/// # Arguments
///
/// * `messages` - Список сообщений (должен быть отсортирован по времени)
/// * `timezone_offset` - Смещение часового пояса в секундах
///
/// # Returns
///
/// Список групп сообщений по датам
pub fn group_by_date(
messages: &[MessageInfo],
timezone_offset: i32,
) -> Vec<MessageGroup> {
let mut groups: Vec<MessageGroup> = Vec::new();
let mut current_date: Option<String> = None;
let mut current_messages: Vec<MessageId> = Vec::new();
for msg in messages {
let date_label = Self::get_date_label(msg.date(), timezone_offset);
if current_date.as_ref() != Some(&date_label) {
// Начинается новая дата - сохраняем предыдущую группу
if let Some(date) = current_date {
groups.push(MessageGroup {
date_label: date,
messages: current_messages.clone(),
});
current_messages.clear();
}
current_date = Some(date_label);
}
current_messages.push(msg.id());
}
// Добавляем последнюю группу
if let Some(date) = current_date {
groups.push(MessageGroup {
date_label: date,
messages: current_messages,
});
}
groups
}
/// Группирует сообщения по отправителю внутри одной даты
///
/// Последовательные сообщения от одного отправителя объединяются в группу.
pub fn group_by_sender(messages: &[MessageInfo]) -> Vec<SenderGroup> {
let mut groups: Vec<SenderGroup> = Vec::new();
let mut current_sender: Option<String> = None;
let mut current_ids: Vec<MessageId> = Vec::new();
let mut first_id: Option<MessageId> = None;
for msg in messages {
let sender = msg.sender_name().to_string();
if current_sender.as_ref() != Some(&sender) {
// Новый отправитель - сохраняем предыдущую группу
if let (Some(name), Some(first)) = (current_sender, first_id) {
groups.push(SenderGroup {
first_message_id: first,
sender_name: name,
message_ids: current_ids.clone(),
});
current_ids.clear();
}
current_sender = Some(sender);
first_id = Some(msg.id());
}
current_ids.push(msg.id());
}
// Добавляем последнюю группу
if let (Some(name), Some(first)) = (current_sender, first_id) {
groups.push(SenderGroup {
first_message_id: first,
sender_name: name,
message_ids: current_ids,
});
}
groups
}
/// Получает человекочитаемую метку даты
///
/// Возвращает "Сегодня", "Вчера" или дату в формате "1 января 2024"
fn get_date_label(timestamp: i32, _timezone_offset: i32) -> String {
let dt = DateTime::from_timestamp(timestamp as i64, 0)
.map(|dt| dt.with_timezone(&Local))
.unwrap_or_else(|| Local::now());
let msg_date = dt.date_naive();
let today = Local::now().date_naive();
let yesterday = today.pred_opt().unwrap_or(today);
if msg_date == today {
"Сегодня".to_string()
} else if msg_date == yesterday {
"Вчера".to_string()
} else {
msg_date.format("%d %B %Y").to_string()
}
}
/// Ищет сообщения по текстовому запросу
///
/// # Arguments
///
/// * `messages` - Список сообщений для поиска
/// * `query` - Поисковый запрос (case-insensitive)
/// * `max_results` - Максимальное количество результатов (0 = без ограничений)
///
/// # Returns
///
/// Список результатов поиска с контекстом
pub fn search(
messages: &[MessageInfo],
query: &str,
max_results: usize,
) -> Vec<MessageSearchResult> {
if query.is_empty() {
return Vec::new();
}
let query_lower = query.to_lowercase();
let mut results = Vec::new();
for (index, msg) in messages.iter().enumerate() {
let text = msg.text().to_lowercase();
if let Some(pos) = text.find(&query_lower) {
// Создаём snippet с контекстом
let start = pos.saturating_sub(20);
let end = (pos + query.len() + 20).min(text.len());
let snippet = msg.text()[start..end].to_string();
results.push(MessageSearchResult {
message_id: msg.id(),
index,
snippet,
match_position: pos,
});
if max_results > 0 && results.len() >= max_results {
break;
}
}
}
results
}
/// Находит следующее сообщение по запросу
///
/// # Arguments
///
/// * `messages` - Список сообщений
/// * `current_index` - Текущая позиция
/// * `query` - Поисковый запрос
///
/// # Returns
///
/// Индекс следующего найденного сообщения или None
pub fn find_next(
messages: &[MessageInfo],
current_index: usize,
query: &str,
) -> Option<usize> {
if query.is_empty() {
return None;
}
let query_lower = query.to_lowercase();
for (index, msg) in messages.iter().enumerate().skip(current_index + 1) {
if msg.text().to_lowercase().contains(&query_lower) {
return Some(index);
}
}
None
}
/// Находит предыдущее сообщение по запросу
pub fn find_previous(
messages: &[MessageInfo],
current_index: usize,
query: &str,
) -> Option<usize> {
if query.is_empty() || current_index == 0 {
return None;
}
let query_lower = query.to_lowercase();
for (index, msg) in messages.iter().enumerate().take(current_index).rev() {
if msg.text().to_lowercase().contains(&query_lower) {
return Some(index);
}
}
None
}
/// Фильтрует сообщения по отправителю
pub fn filter_by_sender<'a>(
messages: &'a [MessageInfo],
sender_name: &str,
) -> Vec<&'a MessageInfo> {
messages
.iter()
.filter(|msg| msg.sender_name() == sender_name)
.collect()
}
/// Фильтрует только непрочитанные сообщения
pub fn filter_unread<'a>(
messages: &'a [MessageInfo],
last_read_id: MessageId,
) -> Vec<&'a MessageInfo> {
messages
.iter()
.filter(|msg| msg.id().as_i64() > last_read_id.as_i64())
.collect()
}
/// Находит сообщение по ID
pub fn find_by_id<'a>(
messages: &'a [MessageInfo],
id: MessageId,
) -> Option<&'a MessageInfo> {
messages.iter().find(|msg| msg.id() == id)
}
/// Находит индекс сообщения по ID
pub fn find_index_by_id(
messages: &[MessageInfo],
id: MessageId,
) -> Option<usize> {
messages.iter().position(|msg| msg.id() == id)
}
/// Получает N последних сообщений
pub fn get_last_n<'a>(
messages: &'a [MessageInfo],
n: usize,
) -> &'a [MessageInfo] {
let start = messages.len().saturating_sub(n);
&messages[start..]
}
/// Получает сообщения в диапазоне дат
pub fn get_in_date_range<'a>(
messages: &'a [MessageInfo],
start_date: i32,
end_date: i32,
) -> Vec<&'a MessageInfo> {
messages
.iter()
.filter(|msg| {
let date = msg.date();
date >= start_date && date <= end_date
})
.collect()
}
/// Подсчитывает сообщения по типу отправителя
pub fn count_by_sender_type(messages: &[MessageInfo]) -> (usize, usize) {
let mut incoming = 0;
let mut outgoing = 0;
for msg in messages {
if msg.is_outgoing() {
outgoing += 1;
} else {
incoming += 1;
}
}
(incoming, outgoing)
}
/// Создаёт индекс сообщений по ID для быстрого доступа
pub fn create_index(messages: &[MessageInfo]) -> HashMap<MessageId, usize> {
messages
.iter()
.enumerate()
.map(|(index, msg)| (msg.id(), index))
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tdlib::MessageInfo;
use crate::types::MessageId;
fn create_test_message(
id: i64,
text: &str,
sender: &str,
date: i32,
is_outgoing: bool,
) -> MessageInfo {
MessageInfo::new(
MessageId::new(id),
sender.to_string(),
is_outgoing,
text.to_string(),
Vec::new(), // entities
date,
0, // edit_date
true, // is_read
is_outgoing, // can_be_edited only for outgoing
true, // can_be_deleted_only_for_self
is_outgoing, // can_be_deleted_for_all_users only for outgoing
None, // reply_to
None, // forward_from
Vec::new(), // reactions
)
}
#[test]
fn test_search() {
let messages = vec![
create_test_message(1, "Hello world", "Alice", 1000, false),
create_test_message(2, "How are you?", "Bob", 1010, false),
create_test_message(3, "Hello there", "Alice", 1020, false),
];
let results = MessageService::search(&messages, "hello", 0);
assert_eq!(results.len(), 2);
assert_eq!(results[0].message_id.as_i64(), 1);
assert_eq!(results[1].message_id.as_i64(), 3);
// Case-insensitive
let results = MessageService::search(&messages, "HELLO", 0);
assert_eq!(results.len(), 2);
// Max results
let results = MessageService::search(&messages, "hello", 1);
assert_eq!(results.len(), 1);
}
#[test]
fn test_find_next_previous() {
let messages = vec![
create_test_message(1, "test 1", "Alice", 1000, false),
create_test_message(2, "message", "Bob", 1010, false),
create_test_message(3, "test 2", "Alice", 1020, false),
create_test_message(4, "test 3", "Bob", 1030, false),
];
// Find next
let next = MessageService::find_next(&messages, 0, "test");
assert_eq!(next, Some(2));
let next = MessageService::find_next(&messages, 2, "test");
assert_eq!(next, Some(3));
// Find previous
let prev = MessageService::find_previous(&messages, 3, "test");
assert_eq!(prev, Some(2));
let prev = MessageService::find_previous(&messages, 2, "test");
assert_eq!(prev, Some(0));
}
#[test]
fn test_filter_by_sender() {
let messages = vec![
create_test_message(1, "msg1", "Alice", 1000, false),
create_test_message(2, "msg2", "Bob", 1010, false),
create_test_message(3, "msg3", "Alice", 1020, false),
];
let filtered = MessageService::filter_by_sender(&messages, "Alice");
assert_eq!(filtered.len(), 2);
assert_eq!(filtered[0].id().as_i64(), 1);
assert_eq!(filtered[1].id().as_i64(), 3);
}
#[test]
fn test_find_by_id() {
let messages = vec![
create_test_message(1, "msg1", "Alice", 1000, false),
create_test_message(2, "msg2", "Bob", 1010, false),
];
let found = MessageService::find_by_id(&messages, MessageId::new(2));
assert!(found.is_some());
assert_eq!(found.unwrap().text(), "msg2");
let not_found = MessageService::find_by_id(&messages, MessageId::new(999));
assert!(not_found.is_none());
}
#[test]
fn test_count_by_sender_type() {
let messages = vec![
create_test_message(1, "msg1", "Alice", 1000, false),
create_test_message(2, "msg2", "Me", 1010, true),
create_test_message(3, "msg3", "Bob", 1020, false),
create_test_message(4, "msg4", "Me", 1030, true),
];
let (incoming, outgoing) = MessageService::count_by_sender_type(&messages);
assert_eq!(incoming, 2);
assert_eq!(outgoing, 2);
}
#[test]
fn test_get_last_n() {
let messages = vec![
create_test_message(1, "msg1", "Alice", 1000, false),
create_test_message(2, "msg2", "Bob", 1010, false),
create_test_message(3, "msg3", "Alice", 1020, false),
];
let last_2 = MessageService::get_last_n(&messages, 2);
assert_eq!(last_2.len(), 2);
assert_eq!(last_2[0].id().as_i64(), 2);
assert_eq!(last_2[1].id().as_i64(), 3);
// Request more than available
let last_10 = MessageService::get_last_n(&messages, 10);
assert_eq!(last_10.len(), 3);
}
#[test]
fn test_create_index() {
let messages = vec![
create_test_message(1, "msg1", "Alice", 1000, false),
create_test_message(2, "msg2", "Bob", 1010, false),
create_test_message(3, "msg3", "Alice", 1020, false),
];
let index = MessageService::create_index(&messages);
assert_eq!(index.len(), 3);
assert_eq!(index.get(&MessageId::new(1)), Some(&0));
assert_eq!(index.get(&MessageId::new(2)), Some(&1));
assert_eq!(index.get(&MessageId::new(3)), Some(&2));
}
}