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>
513 lines
17 KiB
Rust
513 lines
17 KiB
Rust
/// Модуль для бизнес-логики работы с сообщениями
|
||
///
|
||
/// Чёткое разделение ответственности:
|
||
/// - `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));
|
||
}
|
||
}
|