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
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:
87
src/app/auth_state.rs
Normal file
87
src/app/auth_state.rs
Normal file
@@ -0,0 +1,87 @@
|
||||
/// Состояние аутентификации
|
||||
///
|
||||
/// Отвечает за данные авторизации:
|
||||
/// - Ввод номера телефона
|
||||
/// - Ввод кода подтверждения
|
||||
/// - Ввод пароля (2FA)
|
||||
|
||||
/// Состояние аутентификации
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct AuthState {
|
||||
/// Введённый номер телефона
|
||||
phone_input: String,
|
||||
|
||||
/// Введённый код подтверждения
|
||||
code_input: String,
|
||||
|
||||
/// Введённый пароль (для 2FA)
|
||||
password_input: String,
|
||||
}
|
||||
|
||||
impl AuthState {
|
||||
/// Создать новое состояние аутентификации
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
// === Phone input ===
|
||||
|
||||
pub fn phone_input(&self) -> &str {
|
||||
&self.phone_input
|
||||
}
|
||||
|
||||
pub fn phone_input_mut(&mut self) -> &mut String {
|
||||
&mut self.phone_input
|
||||
}
|
||||
|
||||
pub fn set_phone_input(&mut self, input: String) {
|
||||
self.phone_input = input;
|
||||
}
|
||||
|
||||
pub fn clear_phone_input(&mut self) {
|
||||
self.phone_input.clear();
|
||||
}
|
||||
|
||||
// === Code input ===
|
||||
|
||||
pub fn code_input(&self) -> &str {
|
||||
&self.code_input
|
||||
}
|
||||
|
||||
pub fn code_input_mut(&mut self) -> &mut String {
|
||||
&mut self.code_input
|
||||
}
|
||||
|
||||
pub fn set_code_input(&mut self, input: String) {
|
||||
self.code_input = input;
|
||||
}
|
||||
|
||||
pub fn clear_code_input(&mut self) {
|
||||
self.code_input.clear();
|
||||
}
|
||||
|
||||
// === Password input ===
|
||||
|
||||
pub fn password_input(&self) -> &str {
|
||||
&self.password_input
|
||||
}
|
||||
|
||||
pub fn password_input_mut(&mut self) -> &mut String {
|
||||
&mut self.password_input
|
||||
}
|
||||
|
||||
pub fn set_password_input(&mut self, input: String) {
|
||||
self.password_input = input;
|
||||
}
|
||||
|
||||
pub fn clear_password_input(&mut self) {
|
||||
self.password_input.clear();
|
||||
}
|
||||
|
||||
/// Очистить все поля ввода
|
||||
pub fn clear_all(&mut self) {
|
||||
self.phone_input.clear();
|
||||
self.code_input.clear();
|
||||
self.password_input.clear();
|
||||
}
|
||||
}
|
||||
410
src/app/chat_filter.rs
Normal file
410
src/app/chat_filter.rs
Normal file
@@ -0,0 +1,410 @@
|
||||
/// Модуль для централизованной фильтрации чатов
|
||||
///
|
||||
/// Предоставляет единый источник правды для всех видов фильтрации:
|
||||
/// - По папкам (folders)
|
||||
/// - По поисковому запросу
|
||||
/// - По статусу (archived, muted, и т.д.)
|
||||
///
|
||||
/// Используется как в App, так и в UI слое для консистентной фильтрации.
|
||||
|
||||
use crate::tdlib::ChatInfo;
|
||||
|
||||
/// Критерии фильтрации чатов
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ChatFilterCriteria {
|
||||
/// Фильтр по папке (folder_id)
|
||||
pub folder_id: Option<i32>,
|
||||
|
||||
/// Поисковый запрос (по названию или username)
|
||||
pub search_query: Option<String>,
|
||||
|
||||
/// Показывать только закреплённые
|
||||
pub pinned_only: bool,
|
||||
|
||||
/// Показывать только непрочитанные
|
||||
pub unread_only: bool,
|
||||
|
||||
/// Показывать только с упоминаниями
|
||||
pub mentions_only: bool,
|
||||
|
||||
/// Скрывать muted чаты
|
||||
pub hide_muted: bool,
|
||||
|
||||
/// Скрывать архивные чаты
|
||||
pub hide_archived: bool,
|
||||
}
|
||||
|
||||
impl ChatFilterCriteria {
|
||||
/// Создаёт критерии с дефолтными значениями
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Фильтр только по папке
|
||||
pub fn by_folder(folder_id: Option<i32>) -> Self {
|
||||
Self {
|
||||
folder_id,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Фильтр только по поисковому запросу
|
||||
pub fn by_search(query: String) -> Self {
|
||||
Self {
|
||||
search_query: Some(query),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Builder: установить папку
|
||||
pub fn with_folder(mut self, folder_id: Option<i32>) -> Self {
|
||||
self.folder_id = folder_id;
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder: установить поисковый запрос
|
||||
pub fn with_search(mut self, query: String) -> Self {
|
||||
self.search_query = Some(query);
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder: показывать только закреплённые
|
||||
pub fn pinned_only(mut self, enabled: bool) -> Self {
|
||||
self.pinned_only = enabled;
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder: показывать только непрочитанные
|
||||
pub fn unread_only(mut self, enabled: bool) -> Self {
|
||||
self.unread_only = enabled;
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder: показывать только с упоминаниями
|
||||
pub fn mentions_only(mut self, enabled: bool) -> Self {
|
||||
self.mentions_only = enabled;
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder: скрывать muted
|
||||
pub fn hide_muted(mut self, enabled: bool) -> Self {
|
||||
self.hide_muted = enabled;
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder: скрывать архивные
|
||||
pub fn hide_archived(mut self, enabled: bool) -> Self {
|
||||
self.hide_archived = enabled;
|
||||
self
|
||||
}
|
||||
|
||||
/// Проверяет подходит ли чат под все критерии
|
||||
pub fn matches(&self, chat: &ChatInfo) -> bool {
|
||||
// Фильтр по папке
|
||||
if let Some(folder_id) = self.folder_id {
|
||||
if !chat.folder_ids.contains(&folder_id) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Фильтр по поисковому запросу
|
||||
if let Some(ref query) = self.search_query {
|
||||
if !query.is_empty() {
|
||||
let query_lower = query.to_lowercase();
|
||||
let title_matches = chat.title.to_lowercase().contains(&query_lower);
|
||||
let username_matches = chat
|
||||
.username
|
||||
.as_ref()
|
||||
.map(|u| u.to_lowercase().contains(&query_lower))
|
||||
.unwrap_or(false);
|
||||
|
||||
if !title_matches && !username_matches {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Только закреплённые
|
||||
if self.pinned_only && !chat.is_pinned {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Только непрочитанные
|
||||
if self.unread_only && chat.unread_count == 0 {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Только с упоминаниями
|
||||
if self.mentions_only && chat.unread_mention_count == 0 {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Скрывать muted
|
||||
if self.hide_muted && chat.is_muted {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Скрывать архивные (folder_id == 1)
|
||||
if self.hide_archived && chat.folder_ids.contains(&1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Централизованный фильтр чатов
|
||||
pub struct ChatFilter;
|
||||
|
||||
impl ChatFilter {
|
||||
/// Фильтрует список чатов по критериям
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `chats` - Исходный список чатов
|
||||
/// * `criteria` - Критерии фильтрации
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Отфильтрованный список чатов (без клонирования, только references)
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// let criteria = ChatFilterCriteria::by_folder(Some(0))
|
||||
/// .with_search("John".to_string());
|
||||
///
|
||||
/// let filtered = ChatFilter::filter(&all_chats, &criteria);
|
||||
/// ```
|
||||
pub fn filter<'a>(
|
||||
chats: &'a [ChatInfo],
|
||||
criteria: &ChatFilterCriteria,
|
||||
) -> Vec<&'a ChatInfo> {
|
||||
chats.iter().filter(|chat| criteria.matches(chat)).collect()
|
||||
}
|
||||
|
||||
/// Фильтрует чаты по папке
|
||||
///
|
||||
/// Упрощённая версия для наиболее частого случая.
|
||||
pub fn by_folder(chats: &[ChatInfo], folder_id: Option<i32>) -> Vec<&ChatInfo> {
|
||||
let criteria = ChatFilterCriteria::by_folder(folder_id);
|
||||
Self::filter(chats, &criteria)
|
||||
}
|
||||
|
||||
/// Фильтрует чаты по поисковому запросу
|
||||
///
|
||||
/// Упрощённая версия для поиска.
|
||||
pub fn by_search<'a>(chats: &'a [ChatInfo], query: &str) -> Vec<&'a ChatInfo> {
|
||||
if query.is_empty() {
|
||||
return chats.iter().collect();
|
||||
}
|
||||
|
||||
let criteria = ChatFilterCriteria::by_search(query.to_string());
|
||||
Self::filter(chats, &criteria)
|
||||
}
|
||||
|
||||
/// Подсчитывает чаты подходящие под критерии
|
||||
pub fn count(chats: &[ChatInfo], criteria: &ChatFilterCriteria) -> usize {
|
||||
chats.iter().filter(|chat| criteria.matches(chat)).count()
|
||||
}
|
||||
|
||||
/// Подсчитывает непрочитанные сообщения в отфильтрованных чатах
|
||||
pub fn count_unread(chats: &[ChatInfo], criteria: &ChatFilterCriteria) -> i32 {
|
||||
chats
|
||||
.iter()
|
||||
.filter(|chat| criteria.matches(chat))
|
||||
.map(|chat| chat.unread_count)
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// Подсчитывает непрочитанные упоминания в отфильтрованных чатах
|
||||
pub fn count_unread_mentions(chats: &[ChatInfo], criteria: &ChatFilterCriteria) -> i32 {
|
||||
chats
|
||||
.iter()
|
||||
.filter(|chat| criteria.matches(chat))
|
||||
.map(|chat| chat.unread_mention_count)
|
||||
.sum()
|
||||
}
|
||||
}
|
||||
|
||||
/// Сортировка чатов
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ChatSortOrder {
|
||||
/// По времени последнего сообщения (новые сверху)
|
||||
ByLastMessage,
|
||||
|
||||
/// По названию (алфавит)
|
||||
ByTitle,
|
||||
|
||||
/// По количеству непрочитанных (больше сверху)
|
||||
ByUnreadCount,
|
||||
|
||||
/// Закреплённые сверху, остальные по последнему сообщению
|
||||
PinnedFirst,
|
||||
}
|
||||
|
||||
impl ChatSortOrder {
|
||||
/// Сортирует чаты согласно порядку
|
||||
///
|
||||
/// # Note
|
||||
///
|
||||
/// Модифицирует переданный slice in-place
|
||||
pub fn sort(&self, chats: &mut [&ChatInfo]) {
|
||||
match self {
|
||||
ChatSortOrder::ByLastMessage => {
|
||||
chats.sort_by(|a, b| b.last_message_date.cmp(&a.last_message_date));
|
||||
}
|
||||
ChatSortOrder::ByTitle => {
|
||||
chats.sort_by(|a, b| a.title.to_lowercase().cmp(&b.title.to_lowercase()));
|
||||
}
|
||||
ChatSortOrder::ByUnreadCount => {
|
||||
chats.sort_by(|a, b| b.unread_count.cmp(&a.unread_count));
|
||||
}
|
||||
ChatSortOrder::PinnedFirst => {
|
||||
chats.sort_by(|a, b| {
|
||||
// Сначала по pinned статусу
|
||||
match (a.is_pinned, b.is_pinned) {
|
||||
(true, false) => std::cmp::Ordering::Less,
|
||||
(false, true) => std::cmp::Ordering::Greater,
|
||||
// Если оба pinned или оба не pinned - по времени
|
||||
_ => b.last_message_date.cmp(&a.last_message_date),
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::types::ChatId;
|
||||
|
||||
fn create_test_chat(
|
||||
id: i64,
|
||||
title: &str,
|
||||
username: Option<&str>,
|
||||
folder_ids: Vec<i32>,
|
||||
unread: i32,
|
||||
mentions: i32,
|
||||
is_pinned: bool,
|
||||
is_muted: bool,
|
||||
) -> ChatInfo {
|
||||
use crate::types::MessageId;
|
||||
|
||||
ChatInfo {
|
||||
id: ChatId::new(id),
|
||||
title: title.to_string(),
|
||||
username: username.map(String::from),
|
||||
folder_ids,
|
||||
unread_count: unread,
|
||||
unread_mention_count: mentions,
|
||||
is_pinned,
|
||||
is_muted,
|
||||
last_message_date: 0,
|
||||
last_message: String::new(),
|
||||
order: 0,
|
||||
last_read_outbox_message_id: MessageId::new(0),
|
||||
draft_text: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filter_by_folder() {
|
||||
let chats = vec![
|
||||
create_test_chat(1, "Chat 1", None, vec![0], 0, 0, false, false),
|
||||
create_test_chat(2, "Chat 2", None, vec![1], 0, 0, false, false),
|
||||
create_test_chat(3, "Chat 3", None, vec![0, 1], 0, 0, false, false),
|
||||
];
|
||||
|
||||
let filtered = ChatFilter::by_folder(&chats, Some(0));
|
||||
assert_eq!(filtered.len(), 2); // Chat 1 and Chat 3
|
||||
assert_eq!(filtered[0].id.as_i64(), 1);
|
||||
assert_eq!(filtered[1].id.as_i64(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filter_by_search() {
|
||||
let chats = vec![
|
||||
create_test_chat(1, "John Doe", Some("johndoe"), vec![0], 0, 0, false, false),
|
||||
create_test_chat(2, "Jane Smith", Some("janesmith"), vec![0], 0, 0, false, false),
|
||||
create_test_chat(3, "Bob Johnson", None, vec![0], 0, 0, false, false),
|
||||
];
|
||||
|
||||
// Поиск по имени
|
||||
let filtered = ChatFilter::by_search(&chats, "john");
|
||||
assert_eq!(filtered.len(), 2); // John Doe and Bob Johnson
|
||||
|
||||
// Поиск по username
|
||||
let filtered = ChatFilter::by_search(&chats, "smith");
|
||||
assert_eq!(filtered.len(), 1);
|
||||
assert_eq!(filtered[0].title, "Jane Smith");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filter_criteria_builder() {
|
||||
let chats = vec![
|
||||
create_test_chat(1, "Chat 1", None, vec![0], 5, 0, true, false),
|
||||
create_test_chat(2, "Chat 2", None, vec![0], 0, 0, false, false),
|
||||
create_test_chat(3, "Chat 3", None, vec![0], 10, 2, false, false),
|
||||
];
|
||||
|
||||
let criteria = ChatFilterCriteria::new()
|
||||
.with_folder(Some(0))
|
||||
.unread_only(true)
|
||||
.pinned_only(false);
|
||||
|
||||
let filtered = ChatFilter::filter(&chats, &criteria);
|
||||
assert_eq!(filtered.len(), 2); // Chat 1 and Chat 3 have unread
|
||||
|
||||
let criteria = ChatFilterCriteria::new()
|
||||
.pinned_only(true);
|
||||
|
||||
let filtered = ChatFilter::filter(&chats, &criteria);
|
||||
assert_eq!(filtered.len(), 1); // Only Chat 1 is pinned
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_count_methods() {
|
||||
let chats = vec![
|
||||
create_test_chat(1, "Chat 1", None, vec![0], 5, 1, false, false),
|
||||
create_test_chat(2, "Chat 2", None, vec![0], 10, 2, false, false),
|
||||
create_test_chat(3, "Chat 3", None, vec![1], 3, 0, false, false),
|
||||
];
|
||||
|
||||
let criteria = ChatFilterCriteria::by_folder(Some(0));
|
||||
|
||||
assert_eq!(ChatFilter::count(&chats, &criteria), 2);
|
||||
assert_eq!(ChatFilter::count_unread(&chats, &criteria), 15); // 5 + 10
|
||||
assert_eq!(ChatFilter::count_unread_mentions(&chats, &criteria), 3); // 1 + 2
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sort_by_title() {
|
||||
let chat1 = create_test_chat(1, "Charlie", None, vec![0], 0, 0, false, false);
|
||||
let chat2 = create_test_chat(2, "Alice", None, vec![0], 0, 0, false, false);
|
||||
let chat3 = create_test_chat(3, "Bob", None, vec![0], 0, 0, false, false);
|
||||
|
||||
let mut chats = vec![&chat1, &chat2, &chat3];
|
||||
ChatSortOrder::ByTitle.sort(&mut chats);
|
||||
|
||||
assert_eq!(chats[0].title, "Alice");
|
||||
assert_eq!(chats[1].title, "Bob");
|
||||
assert_eq!(chats[2].title, "Charlie");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sort_pinned_first() {
|
||||
let chat1 = create_test_chat(1, "Chat 1", None, vec![0], 0, 0, false, false);
|
||||
let chat2 = create_test_chat(2, "Chat 2", None, vec![0], 0, 0, true, false);
|
||||
let chat3 = create_test_chat(3, "Chat 3", None, vec![0], 0, 0, true, false);
|
||||
|
||||
let mut chats = vec![&chat1, &chat2, &chat3];
|
||||
ChatSortOrder::PinnedFirst.sort(&mut chats);
|
||||
|
||||
// Pinned chats first
|
||||
assert!(chats[0].is_pinned);
|
||||
assert!(chats[1].is_pinned);
|
||||
assert!(!chats[2].is_pinned);
|
||||
}
|
||||
}
|
||||
195
src/app/chat_list_state.rs
Normal file
195
src/app/chat_list_state.rs
Normal file
@@ -0,0 +1,195 @@
|
||||
/// Состояние списка чатов
|
||||
///
|
||||
/// Отвечает за:
|
||||
/// - Список чатов
|
||||
/// - Выбранный чат в списке
|
||||
/// - Фильтрацию по папкам
|
||||
/// - Поиск чатов
|
||||
|
||||
use crate::app::chat_filter::{ChatFilter, ChatFilterCriteria};
|
||||
use crate::tdlib::ChatInfo;
|
||||
use ratatui::widgets::ListState;
|
||||
|
||||
/// Состояние списка чатов
|
||||
#[derive(Debug)]
|
||||
pub struct ChatListState {
|
||||
/// Список всех чатов
|
||||
pub chats: Vec<ChatInfo>,
|
||||
|
||||
/// Состояние виджета списка (выбранный индекс)
|
||||
pub list_state: ListState,
|
||||
|
||||
/// Выбранная папка (None = All, Some(id) = конкретная папка)
|
||||
pub selected_folder_id: Option<i32>,
|
||||
|
||||
/// Флаг режима поиска чатов
|
||||
pub is_searching: bool,
|
||||
|
||||
/// Поисковый запрос для фильтрации чатов
|
||||
pub search_query: String,
|
||||
}
|
||||
|
||||
impl Default for ChatListState {
|
||||
fn default() -> Self {
|
||||
let mut state = ListState::default();
|
||||
state.select(Some(0));
|
||||
|
||||
Self {
|
||||
chats: Vec::new(),
|
||||
list_state: state,
|
||||
selected_folder_id: None,
|
||||
is_searching: false,
|
||||
search_query: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ChatListState {
|
||||
/// Создать новое состояние списка чатов
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
// === Chats ===
|
||||
|
||||
pub fn chats(&self) -> &[ChatInfo] {
|
||||
&self.chats
|
||||
}
|
||||
|
||||
pub fn chats_mut(&mut self) -> &mut Vec<ChatInfo> {
|
||||
&mut self.chats
|
||||
}
|
||||
|
||||
pub fn set_chats(&mut self, chats: Vec<ChatInfo>) {
|
||||
self.chats = chats;
|
||||
}
|
||||
|
||||
pub fn add_chat(&mut self, chat: ChatInfo) {
|
||||
self.chats.push(chat);
|
||||
}
|
||||
|
||||
pub fn clear_chats(&mut self) {
|
||||
self.chats.clear();
|
||||
}
|
||||
|
||||
// === List state (selection) ===
|
||||
|
||||
pub fn list_state(&self) -> &ListState {
|
||||
&self.list_state
|
||||
}
|
||||
|
||||
pub fn list_state_mut(&mut self) -> &mut ListState {
|
||||
&mut self.list_state
|
||||
}
|
||||
|
||||
pub fn selected_index(&self) -> Option<usize> {
|
||||
self.list_state.selected()
|
||||
}
|
||||
|
||||
pub fn select(&mut self, index: Option<usize>) {
|
||||
self.list_state.select(index);
|
||||
}
|
||||
|
||||
// === Folder ===
|
||||
|
||||
pub fn selected_folder_id(&self) -> Option<i32> {
|
||||
self.selected_folder_id
|
||||
}
|
||||
|
||||
pub fn set_selected_folder_id(&mut self, id: Option<i32>) {
|
||||
self.selected_folder_id = id;
|
||||
}
|
||||
|
||||
// === Search ===
|
||||
|
||||
pub fn is_searching(&self) -> bool {
|
||||
self.is_searching
|
||||
}
|
||||
|
||||
pub fn set_searching(&mut self, searching: bool) {
|
||||
self.is_searching = searching;
|
||||
}
|
||||
|
||||
pub fn search_query(&self) -> &str {
|
||||
&self.search_query
|
||||
}
|
||||
|
||||
pub fn search_query_mut(&mut self) -> &mut String {
|
||||
&mut self.search_query
|
||||
}
|
||||
|
||||
pub fn set_search_query(&mut self, query: String) {
|
||||
self.search_query = query;
|
||||
}
|
||||
|
||||
pub fn start_search(&mut self) {
|
||||
self.is_searching = true;
|
||||
self.search_query.clear();
|
||||
}
|
||||
|
||||
pub fn cancel_search(&mut self) {
|
||||
self.is_searching = false;
|
||||
self.search_query.clear();
|
||||
self.list_state.select(Some(0));
|
||||
}
|
||||
|
||||
// === Navigation ===
|
||||
|
||||
/// Получить отфильтрованный список чатов
|
||||
pub fn get_filtered_chats(&self) -> Vec<&ChatInfo> {
|
||||
// Используем ChatFilter для централизованной фильтрации
|
||||
let mut criteria = ChatFilterCriteria::new().with_folder(self.selected_folder_id);
|
||||
|
||||
if !self.search_query.is_empty() {
|
||||
criteria = criteria.with_search(self.search_query.clone());
|
||||
}
|
||||
|
||||
ChatFilter::filter(&self.chats, &criteria)
|
||||
}
|
||||
|
||||
/// Выбрать следующий чат
|
||||
pub fn next_chat(&mut self) {
|
||||
let filtered = self.get_filtered_chats();
|
||||
if filtered.is_empty() {
|
||||
return;
|
||||
}
|
||||
let i = match self.list_state.selected() {
|
||||
Some(i) => {
|
||||
if i >= filtered.len() - 1 {
|
||||
0
|
||||
} else {
|
||||
i + 1
|
||||
}
|
||||
}
|
||||
None => 0,
|
||||
};
|
||||
self.list_state.select(Some(i));
|
||||
}
|
||||
|
||||
/// Выбрать предыдущий чат
|
||||
pub fn previous_chat(&mut self) {
|
||||
let filtered = self.get_filtered_chats();
|
||||
if filtered.is_empty() {
|
||||
return;
|
||||
}
|
||||
let i = match self.list_state.selected() {
|
||||
Some(i) => {
|
||||
if i == 0 {
|
||||
filtered.len() - 1
|
||||
} else {
|
||||
i - 1
|
||||
}
|
||||
}
|
||||
None => 0,
|
||||
};
|
||||
self.list_state.select(Some(i));
|
||||
}
|
||||
|
||||
/// Получить выбранный в данный момент чат
|
||||
pub fn get_selected_chat(&self) -> Option<&ChatInfo> {
|
||||
let filtered = self.get_filtered_chats();
|
||||
self.list_state
|
||||
.selected()
|
||||
.and_then(|i| filtered.get(i).copied())
|
||||
}
|
||||
}
|
||||
247
src/app/compose_state.rs
Normal file
247
src/app/compose_state.rs
Normal file
@@ -0,0 +1,247 @@
|
||||
/// Состояние написания сообщения
|
||||
///
|
||||
/// Отвечает за:
|
||||
/// - Текст сообщения
|
||||
/// - Позицию курсора
|
||||
/// - Typing indicator
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
/// Состояние написания сообщения
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ComposeState {
|
||||
/// Текст вводимого сообщения
|
||||
pub message_input: String,
|
||||
|
||||
/// Позиция курсора в message_input (в символах, не байтах)
|
||||
pub cursor_position: usize,
|
||||
|
||||
/// Время последней отправки typing status (для throttling)
|
||||
pub last_typing_sent: Option<Instant>,
|
||||
}
|
||||
|
||||
impl Default for ComposeState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
message_input: String::new(),
|
||||
cursor_position: 0,
|
||||
last_typing_sent: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ComposeState {
|
||||
/// Создать новое состояние написания сообщения
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
// === Message input ===
|
||||
|
||||
pub fn message_input(&self) -> &str {
|
||||
&self.message_input
|
||||
}
|
||||
|
||||
pub fn message_input_mut(&mut self) -> &mut String {
|
||||
&mut self.message_input
|
||||
}
|
||||
|
||||
pub fn set_message_input(&mut self, input: String) {
|
||||
self.message_input = input;
|
||||
self.cursor_position = self.message_input.chars().count();
|
||||
}
|
||||
|
||||
pub fn clear_message_input(&mut self) {
|
||||
self.message_input.clear();
|
||||
self.cursor_position = 0;
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.message_input.is_empty()
|
||||
}
|
||||
|
||||
// === Cursor position ===
|
||||
|
||||
pub fn cursor_position(&self) -> usize {
|
||||
self.cursor_position
|
||||
}
|
||||
|
||||
pub fn set_cursor_position(&mut self, pos: usize) {
|
||||
let max_pos = self.message_input.chars().count();
|
||||
self.cursor_position = pos.min(max_pos);
|
||||
}
|
||||
|
||||
pub fn move_cursor_left(&mut self) {
|
||||
if self.cursor_position > 0 {
|
||||
self.cursor_position -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn move_cursor_right(&mut self) {
|
||||
let max_pos = self.message_input.chars().count();
|
||||
if self.cursor_position < max_pos {
|
||||
self.cursor_position += 1;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn move_cursor_to_start(&mut self) {
|
||||
self.cursor_position = 0;
|
||||
}
|
||||
|
||||
pub fn move_cursor_to_end(&mut self) {
|
||||
self.cursor_position = self.message_input.chars().count();
|
||||
}
|
||||
|
||||
// === Typing indicator ===
|
||||
|
||||
pub fn last_typing_sent(&self) -> Option<Instant> {
|
||||
self.last_typing_sent
|
||||
}
|
||||
|
||||
pub fn set_last_typing_sent(&mut self, time: Option<Instant>) {
|
||||
self.last_typing_sent = time;
|
||||
}
|
||||
|
||||
pub fn update_last_typing_sent(&mut self) {
|
||||
self.last_typing_sent = Some(Instant::now());
|
||||
}
|
||||
|
||||
pub fn clear_typing_indicator(&mut self) {
|
||||
self.last_typing_sent = None;
|
||||
}
|
||||
|
||||
/// Проверить, нужно ли отправить typing indicator
|
||||
/// (если прошло больше 5 секунд с последней отправки)
|
||||
pub fn should_send_typing(&self) -> bool {
|
||||
match self.last_typing_sent {
|
||||
None => true,
|
||||
Some(last) => last.elapsed().as_secs() >= 5,
|
||||
}
|
||||
}
|
||||
|
||||
// === Text editing ===
|
||||
|
||||
/// Вставить символ в текущую позицию курсора
|
||||
pub fn insert_char(&mut self, c: char) {
|
||||
let char_indices: Vec<usize> = self.message_input.char_indices().map(|(i, _)| i).collect();
|
||||
|
||||
let byte_pos = if self.cursor_position >= char_indices.len() {
|
||||
self.message_input.len()
|
||||
} else {
|
||||
char_indices[self.cursor_position]
|
||||
};
|
||||
|
||||
self.message_input.insert(byte_pos, c);
|
||||
self.cursor_position += 1;
|
||||
}
|
||||
|
||||
/// Удалить символ перед курсором (Backspace)
|
||||
pub fn delete_char_before_cursor(&mut self) {
|
||||
if self.cursor_position > 0 {
|
||||
let char_indices: Vec<usize> = self.message_input.char_indices().map(|(i, _)| i).collect();
|
||||
let byte_pos = char_indices[self.cursor_position - 1];
|
||||
self.message_input.remove(byte_pos);
|
||||
self.cursor_position -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Удалить символ после курсора (Delete)
|
||||
pub fn delete_char_after_cursor(&mut self) {
|
||||
let char_indices: Vec<usize> = self.message_input.char_indices().map(|(i, _)| i).collect();
|
||||
|
||||
if self.cursor_position < char_indices.len() {
|
||||
let byte_pos = char_indices[self.cursor_position];
|
||||
self.message_input.remove(byte_pos);
|
||||
}
|
||||
}
|
||||
|
||||
/// Удалить слово перед курсором (Ctrl+Backspace)
|
||||
pub fn delete_word_before_cursor(&mut self) {
|
||||
if self.cursor_position == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let chars: Vec<char> = self.message_input.chars().collect();
|
||||
let mut pos = self.cursor_position;
|
||||
|
||||
// Пропустить пробелы
|
||||
while pos > 0 && chars[pos - 1].is_whitespace() {
|
||||
pos -= 1;
|
||||
}
|
||||
|
||||
// Удалить символы слова
|
||||
while pos > 0 && !chars[pos - 1].is_whitespace() {
|
||||
pos -= 1;
|
||||
}
|
||||
|
||||
let removed_count = self.cursor_position - pos;
|
||||
if removed_count > 0 {
|
||||
let char_indices: Vec<usize> = self.message_input.char_indices().map(|(i, _)| i).collect();
|
||||
let start_byte = char_indices[pos];
|
||||
let end_byte = if self.cursor_position >= char_indices.len() {
|
||||
self.message_input.len()
|
||||
} else {
|
||||
char_indices[self.cursor_position]
|
||||
};
|
||||
|
||||
self.message_input.drain(start_byte..end_byte);
|
||||
self.cursor_position = pos;
|
||||
}
|
||||
}
|
||||
|
||||
/// Очистить всё и сбросить состояние
|
||||
pub fn reset(&mut self) {
|
||||
self.message_input.clear();
|
||||
self.cursor_position = 0;
|
||||
self.last_typing_sent = None;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_insert_char() {
|
||||
let mut state = ComposeState::new();
|
||||
state.insert_char('H');
|
||||
state.insert_char('i');
|
||||
assert_eq!(state.message_input(), "Hi");
|
||||
assert_eq!(state.cursor_position(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_char_before_cursor() {
|
||||
let mut state = ComposeState::new();
|
||||
state.set_message_input("Hello".to_string());
|
||||
state.delete_char_before_cursor();
|
||||
assert_eq!(state.message_input(), "Hell");
|
||||
assert_eq!(state.cursor_position(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cursor_movement() {
|
||||
let mut state = ComposeState::new();
|
||||
state.set_message_input("Hello".to_string());
|
||||
|
||||
state.move_cursor_to_start();
|
||||
assert_eq!(state.cursor_position(), 0);
|
||||
|
||||
state.move_cursor_right();
|
||||
assert_eq!(state.cursor_position(), 1);
|
||||
|
||||
state.move_cursor_to_end();
|
||||
assert_eq!(state.cursor_position(), 5);
|
||||
|
||||
state.move_cursor_left();
|
||||
assert_eq!(state.cursor_position(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_word() {
|
||||
let mut state = ComposeState::new();
|
||||
state.set_message_input("Hello World".to_string());
|
||||
state.delete_word_before_cursor();
|
||||
assert_eq!(state.message_input(), "Hello ");
|
||||
}
|
||||
}
|
||||
512
src/app/message_service.rs
Normal file
512
src/app/message_service.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
278
src/app/message_view_state.rs
Normal file
278
src/app/message_view_state.rs
Normal file
@@ -0,0 +1,278 @@
|
||||
/// Состояние просмотра сообщений
|
||||
///
|
||||
/// Отвечает за:
|
||||
/// - Текущий открытый чат
|
||||
/// - Скроллинг сообщений
|
||||
/// - Состояние чата (редактирование, ответ, и т.д.)
|
||||
|
||||
use crate::app::ChatState;
|
||||
use crate::types::{ChatId, MessageId};
|
||||
|
||||
/// Состояние просмотра сообщений
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MessageViewState {
|
||||
/// ID текущего открытого чата
|
||||
pub selected_chat_id: Option<ChatId>,
|
||||
|
||||
/// Оффсет скроллинга для сообщений
|
||||
pub message_scroll_offset: usize,
|
||||
|
||||
/// Состояние чата (Normal, Editing, Reply, и т.д.)
|
||||
pub chat_state: ChatState,
|
||||
}
|
||||
|
||||
impl Default for MessageViewState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
selected_chat_id: None,
|
||||
message_scroll_offset: 0,
|
||||
chat_state: ChatState::Normal,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MessageViewState {
|
||||
/// Создать новое состояние просмотра сообщений
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
// === Selected chat ===
|
||||
|
||||
pub fn selected_chat_id(&self) -> Option<ChatId> {
|
||||
self.selected_chat_id
|
||||
}
|
||||
|
||||
pub fn set_selected_chat_id(&mut self, id: Option<ChatId>) {
|
||||
self.selected_chat_id = id;
|
||||
}
|
||||
|
||||
pub fn has_open_chat(&self) -> bool {
|
||||
self.selected_chat_id.is_some()
|
||||
}
|
||||
|
||||
pub fn close_chat(&mut self) {
|
||||
self.selected_chat_id = None;
|
||||
self.message_scroll_offset = 0;
|
||||
self.chat_state = ChatState::Normal;
|
||||
}
|
||||
|
||||
// === Scroll offset ===
|
||||
|
||||
pub fn message_scroll_offset(&self) -> usize {
|
||||
self.message_scroll_offset
|
||||
}
|
||||
|
||||
pub fn set_message_scroll_offset(&mut self, offset: usize) {
|
||||
self.message_scroll_offset = offset;
|
||||
}
|
||||
|
||||
pub fn reset_scroll(&mut self) {
|
||||
self.message_scroll_offset = 0;
|
||||
}
|
||||
|
||||
// === Chat state ===
|
||||
|
||||
pub fn chat_state(&self) -> &ChatState {
|
||||
&self.chat_state
|
||||
}
|
||||
|
||||
pub fn chat_state_mut(&mut self) -> &mut ChatState {
|
||||
&mut self.chat_state
|
||||
}
|
||||
|
||||
pub fn set_chat_state(&mut self, state: ChatState) {
|
||||
self.chat_state = state;
|
||||
}
|
||||
|
||||
pub fn reset_chat_state(&mut self) {
|
||||
self.chat_state = ChatState::Normal;
|
||||
}
|
||||
|
||||
// === Message selection ===
|
||||
|
||||
pub fn is_selecting_message(&self) -> bool {
|
||||
self.chat_state.is_message_selection()
|
||||
}
|
||||
|
||||
pub fn start_message_selection(&mut self, total_messages: usize) {
|
||||
if total_messages == 0 {
|
||||
return;
|
||||
}
|
||||
self.chat_state = ChatState::MessageSelection {
|
||||
selected_index: total_messages - 1,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn select_previous_message(&mut self) {
|
||||
if let ChatState::MessageSelection { selected_index } = &mut self.chat_state {
|
||||
if *selected_index > 0 {
|
||||
*selected_index -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn select_next_message(&mut self, total_messages: usize) {
|
||||
if total_messages == 0 {
|
||||
return;
|
||||
}
|
||||
if let ChatState::MessageSelection { selected_index } = &mut self.chat_state {
|
||||
if *selected_index < total_messages - 1 {
|
||||
*selected_index += 1;
|
||||
} else {
|
||||
self.chat_state = ChatState::Normal;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_selected_message_index(&self) -> Option<usize> {
|
||||
self.chat_state.selected_message_index()
|
||||
}
|
||||
|
||||
// === Editing ===
|
||||
|
||||
pub fn is_editing(&self) -> bool {
|
||||
self.chat_state.is_editing()
|
||||
}
|
||||
|
||||
pub fn start_editing(&mut self, message_id: MessageId, selected_index: usize) {
|
||||
self.chat_state = ChatState::Editing {
|
||||
message_id,
|
||||
selected_index,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn cancel_editing(&mut self) {
|
||||
self.chat_state = ChatState::Normal;
|
||||
}
|
||||
|
||||
pub fn get_editing_message_id(&self) -> Option<MessageId> {
|
||||
if let ChatState::Editing { message_id, .. } = &self.chat_state {
|
||||
Some(*message_id)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
// === Reply ===
|
||||
|
||||
pub fn is_replying(&self) -> bool {
|
||||
self.chat_state.is_reply()
|
||||
}
|
||||
|
||||
pub fn start_reply(&mut self, message_id: MessageId) {
|
||||
self.chat_state = ChatState::Reply { message_id };
|
||||
}
|
||||
|
||||
pub fn cancel_reply(&mut self) {
|
||||
self.chat_state = ChatState::Normal;
|
||||
}
|
||||
|
||||
pub fn get_replying_to_message_id(&self) -> Option<MessageId> {
|
||||
if let ChatState::Reply { message_id } = &self.chat_state {
|
||||
Some(*message_id)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
// === Forward ===
|
||||
|
||||
pub fn is_forwarding(&self) -> bool {
|
||||
self.chat_state.is_forward()
|
||||
}
|
||||
|
||||
pub fn start_forward(&mut self, message_id: MessageId) {
|
||||
self.chat_state = ChatState::Forward {
|
||||
message_id,
|
||||
selecting_chat: true,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn cancel_forward(&mut self) {
|
||||
self.chat_state = ChatState::Normal;
|
||||
}
|
||||
|
||||
// === Delete confirmation ===
|
||||
|
||||
pub fn is_confirm_delete_shown(&self) -> bool {
|
||||
self.chat_state.is_delete_confirmation()
|
||||
}
|
||||
|
||||
// === Pinned messages ===
|
||||
|
||||
pub fn is_pinned_mode(&self) -> bool {
|
||||
self.chat_state.is_pinned_mode()
|
||||
}
|
||||
|
||||
pub fn enter_pinned_mode(&mut self, messages: Vec<crate::tdlib::MessageInfo>) {
|
||||
if !messages.is_empty() {
|
||||
self.chat_state = ChatState::PinnedMessages {
|
||||
messages,
|
||||
selected_index: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
pub fn exit_pinned_mode(&mut self) {
|
||||
self.chat_state = ChatState::Normal;
|
||||
}
|
||||
|
||||
// === Search in chat ===
|
||||
|
||||
pub fn is_message_search_mode(&self) -> bool {
|
||||
self.chat_state.is_search_in_chat()
|
||||
}
|
||||
|
||||
pub fn enter_message_search_mode(&mut self) {
|
||||
self.chat_state = ChatState::SearchInChat {
|
||||
query: String::new(),
|
||||
results: Vec::new(),
|
||||
selected_index: 0,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn exit_message_search_mode(&mut self) {
|
||||
self.chat_state = ChatState::Normal;
|
||||
}
|
||||
|
||||
// === Profile ===
|
||||
|
||||
pub fn is_profile_mode(&self) -> bool {
|
||||
self.chat_state.is_profile()
|
||||
}
|
||||
|
||||
pub fn enter_profile_mode(&mut self, info: crate::tdlib::ProfileInfo) {
|
||||
self.chat_state = ChatState::Profile {
|
||||
info,
|
||||
selected_action: 0,
|
||||
leave_group_confirmation_step: 0,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn exit_profile_mode(&mut self) {
|
||||
self.chat_state = ChatState::Normal;
|
||||
}
|
||||
|
||||
// === Reaction picker ===
|
||||
|
||||
pub fn is_reaction_picker_mode(&self) -> bool {
|
||||
self.chat_state.is_reaction_picker()
|
||||
}
|
||||
|
||||
pub fn enter_reaction_picker_mode(
|
||||
&mut self,
|
||||
message_id: MessageId,
|
||||
available_reactions: Vec<String>,
|
||||
) {
|
||||
self.chat_state = ChatState::ReactionPicker {
|
||||
message_id,
|
||||
available_reactions,
|
||||
selected_index: 0,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn exit_reaction_picker_mode(&mut self) {
|
||||
self.chat_state = ChatState::Normal;
|
||||
}
|
||||
}
|
||||
128
src/app/ui_state.rs
Normal file
128
src/app/ui_state.rs
Normal file
@@ -0,0 +1,128 @@
|
||||
/// UI состояние приложения
|
||||
///
|
||||
/// Отвечает за общее состояние интерфейса:
|
||||
/// - Текущий экран (screen)
|
||||
/// - Сообщения об ошибках и статусе
|
||||
/// - Флаги загрузки и перерисовки
|
||||
|
||||
use crate::app::AppScreen;
|
||||
|
||||
/// Состояние UI приложения
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UIState {
|
||||
/// Текущий экран приложения
|
||||
pub screen: AppScreen,
|
||||
|
||||
/// Сообщение об ошибке (если есть)
|
||||
pub error_message: Option<String>,
|
||||
|
||||
/// Статусное сообщение (загрузка, прогресс, и т.д.)
|
||||
pub status_message: Option<String>,
|
||||
|
||||
/// Флаг необходимости перерисовки
|
||||
pub needs_redraw: bool,
|
||||
|
||||
/// Флаг загрузки (общий)
|
||||
pub is_loading: bool,
|
||||
}
|
||||
|
||||
impl Default for UIState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
screen: AppScreen::Loading,
|
||||
error_message: None,
|
||||
status_message: Some("Инициализация TDLib...".to_string()),
|
||||
needs_redraw: true,
|
||||
is_loading: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl UIState {
|
||||
/// Создать новое UI состояние
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
// === Screen ===
|
||||
|
||||
pub fn screen(&self) -> &AppScreen {
|
||||
&self.screen
|
||||
}
|
||||
|
||||
pub fn set_screen(&mut self, screen: AppScreen) {
|
||||
self.screen = screen;
|
||||
self.mark_for_redraw();
|
||||
}
|
||||
|
||||
// === Error message ===
|
||||
|
||||
pub fn error_message(&self) -> Option<&str> {
|
||||
self.error_message.as_deref()
|
||||
}
|
||||
|
||||
pub fn set_error_message(&mut self, message: Option<String>) {
|
||||
self.error_message = message;
|
||||
self.mark_for_redraw();
|
||||
}
|
||||
|
||||
pub fn clear_error(&mut self) {
|
||||
self.error_message = None;
|
||||
self.mark_for_redraw();
|
||||
}
|
||||
|
||||
// === Status message ===
|
||||
|
||||
pub fn status_message(&self) -> Option<&str> {
|
||||
self.status_message.as_deref()
|
||||
}
|
||||
|
||||
pub fn set_status_message(&mut self, message: Option<String>) {
|
||||
self.status_message = message;
|
||||
self.mark_for_redraw();
|
||||
}
|
||||
|
||||
pub fn clear_status(&mut self) {
|
||||
self.status_message = None;
|
||||
self.mark_for_redraw();
|
||||
}
|
||||
|
||||
// === Redraw flag ===
|
||||
|
||||
pub fn needs_redraw(&self) -> bool {
|
||||
self.needs_redraw
|
||||
}
|
||||
|
||||
pub fn set_needs_redraw(&mut self, redraw: bool) {
|
||||
self.needs_redraw = redraw;
|
||||
}
|
||||
|
||||
pub fn mark_for_redraw(&mut self) {
|
||||
self.needs_redraw = true;
|
||||
}
|
||||
|
||||
pub fn clear_redraw_flag(&mut self) {
|
||||
self.needs_redraw = false;
|
||||
}
|
||||
|
||||
// === Loading flag ===
|
||||
|
||||
pub fn is_loading(&self) -> bool {
|
||||
self.is_loading
|
||||
}
|
||||
|
||||
pub fn set_loading(&mut self, loading: bool) {
|
||||
self.is_loading = loading;
|
||||
if loading {
|
||||
self.mark_for_redraw();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start_loading(&mut self) {
|
||||
self.set_loading(true);
|
||||
}
|
||||
|
||||
pub fn stop_loading(&mut self) {
|
||||
self.set_loading(false);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user