This commit is contained in:
Mikhail Kilin
2026-01-31 03:48:50 +03:00
parent 1bf9b3d703
commit 644e36597d
37 changed files with 1070 additions and 600 deletions

View File

@@ -0,0 +1,25 @@
# Code Style and Conventions
## Rust Style
- Следовать стандартному Rust стилю (rustfmt)
- Snake_case для переменных и функций
- PascalCase для типов и enum вариантов
- SCREAMING_SNAKE_CASE для констант
## Project Conventions
- Использовать `Result<T, String>` для ошибок (планируется заменить на `Result<T>` с кастомным enum)
- Async/await для TDLib операций
- Группировать imports: std → external crates → local modules
- Константы вынесены в `src/constants.rs`
## Architecture Patterns
- Модульная структура: app, ui, input, tdlib, utils
- TdClient разделён на подмодули: auth, chats, messages, users, reactions
- ChatState enum для состояний чата (type-safe)
- Snapshot тесты для UI компонентов
- Integration тесты для business logic
## Documentation
- Комментарии на русском в коде (для логики)
- Doc-комментарии на английском (для публичного API)
- CLAUDE.md, CONTEXT.md, ROADMAP.md для документации проекта

View File

@@ -0,0 +1,28 @@
# Telegram TUI - Project Overview
## Purpose
TUI (Text User Interface) клиент для Telegram с vim-style навигацией.
## Tech Stack
- **Language**: Rust
- **TUI Framework**: ratatui 0.29 + crossterm 0.28
- **Telegram**: tdlib-rs 1.1 (с автоматической загрузкой TDLib)
- **Async Runtime**: tokio (full features)
- **Config**: toml 0.8, dirs 5.0
- **Other**: chrono 0.4, clipboard 0.5, serde/serde_json 1.0
## Current Status
- Фаза 9 завершена (100%)
- Все основные фичи реализованы
- 148/151 тестов (98% покрытие)
- Рефакторинг: Priority 1 завершён, Priority 2 на 40%
## Key Features
- TDLib интеграция с авторизацией
- Список чатов с папками, фильтрацией
- Отправка/редактирование/удаление сообщений
- Reply, Forward, Реакции
- Markdown форматирование
- Поиск по чатам и сообщениям
- Typing indicator, online статусы
- Конфигурационный файл с цветами и timezone

View File

@@ -0,0 +1,37 @@
# Suggested Commands
## Building and Running
**ВАЖНО**: НИКОГДА не запускать самостоятельно! Всегда просить пользователя!
```bash
# Пользователь должен запустить:
cargo run
cargo build
cargo build --release
```
## Testing
```bash
cargo test # Запустить все тесты
cargo test --lib # Только библиотечные тесты
cargo test <test_name> # Конкретный тест
```
## Code Quality
```bash
cargo clippy # Линтер
cargo fmt # Форматирование
cargo check # Быстрая проверка компиляции
```
## Development Workflow
1. Сделать изменения
2. `cargo check` - быстрая проверка
3. `cargo test` - запустить тесты
4. `cargo clippy` - проверить предупреждения
5. `cargo fmt` - отформатировать код
6. Попросить пользователя запустить `cargo run` для ручной проверки
## macOS Specific
- Система: Darwin
- Стандартные Unix команды работают (ls, grep, find, cd, etc.)

View File

@@ -0,0 +1,39 @@
# Task Completion Checklist
## После завершения задачи:
### 1. Проверка кода
- [ ] `cargo check` - компиляция без ошибок
- [ ] `cargo clippy` - нет новых предупреждений
- [ ] `cargo fmt` - код отформатирован
- [ ] `cargo test` - все тесты проходят
### 2. Ручное тестирование
- [ ] Описать сценарии для проверки
- [ ] Попросить пользователя запустить `cargo run`
- [ ] Дождаться фидбека от пользователя
### 3. Документация
- [ ] Обновить CONTEXT.md (секция "Последние обновления")
- [ ] Добавить в CONTEXT.md что сделано
- [ ] Если нужно - обновить ROADMAP.md
### 4. Git (только по запросу пользователя)
- [ ] НИКОГДА не добавлять себя в Co-Authored-By
- [ ] Создавать коммит только если пользователь попросил
## Формат сообщения пользователю
```
Готово! Проверь, пожалуйста:
1. [Конкретный сценарий проверки]
2. [Что должно произойти]
3. [На что обратить внимание]
Напиши, если что-то не работает.
```
## Важно
- Работать поэтапно (один этап = одна логическая единица)
- После каждого этапа давать сценарий проверки
- Не делать сразу много изменений

View File

@@ -130,9 +130,13 @@ src/
├── lib.rs # Библиотечный интерфейс (для тестов) ├── lib.rs # Библиотечный интерфейс (для тестов)
├── types.rs # Типобезопасные обёртки (ChatId, MessageId, UserId) ├── types.rs # Типобезопасные обёртки (ChatId, MessageId, UserId)
├── config.rs # Конфигурация (TOML), загрузка credentials ├── config.rs # Конфигурация (TOML), загрузка credentials
├── error.rs # TeletuiError enum, Result<T> type alias
├── constants.rs # Константы проекта (MAX_MESSAGES_IN_CHAT, POLL_TIMEOUT_MS, etc.)
├── formatting.rs # Markdown форматирование (CharStyle, format_text_with_entities)
├── app/ ├── app/
│ ├── mod.rs # App структура и состояние (needs_redraw флаг) │ ├── mod.rs # App структура и состояние (needs_redraw флаг)
── state.rs # AppScreen enum ── state.rs # AppScreen enum
│ └── chat_state.rs # ChatState enum (Normal, MessageSelection, Editing, etc.)
├── ui/ ├── ui/
│ ├── mod.rs # Роутинг UI по экранам, проверка минимального размера │ ├── mod.rs # Роутинг UI по экранам, проверка минимального размера
│ ├── loading.rs # Экран загрузки │ ├── loading.rs # Экран загрузки
@@ -140,7 +144,15 @@ src/
│ ├── main_screen.rs # Главный экран с папками │ ├── main_screen.rs # Главный экран с папками
│ ├── chat_list.rs # Список чатов (pin, mute, online, mentions) │ ├── chat_list.rs # Список чатов (pin, mute, online, mentions)
│ ├── messages.rs # Область сообщений (wrap, группировка, динамический инпут) │ ├── messages.rs # Область сообщений (wrap, группировка, динамический инпут)
── footer.rs # Подвал с командами и статусом сети ── footer.rs # Подвал с командами и статусом сети
│ ├── profile.rs # Экран профиля пользователя/чата
│ └── components/ # Переиспользуемые UI компоненты
│ ├── mod.rs
│ ├── modal.rs
│ ├── input_field.rs
│ ├── message_bubble.rs
│ ├── chat_list_item.rs
│ └── emoji_picker.rs
├── input/ ├── input/
│ ├── mod.rs # Роутинг ввода │ ├── mod.rs # Роутинг ввода
│ ├── auth.rs # Обработка ввода на экране авторизации │ ├── auth.rs # Обработка ввода на экране авторизации
@@ -154,7 +166,7 @@ src/
├── messages.rs # MessageManager для сообщений ├── messages.rs # MessageManager для сообщений
├── users.rs # UserCache с LRU кэшем ├── users.rs # UserCache с LRU кэшем
├── reactions.rs # ReactionManager ├── reactions.rs # ReactionManager
└── types.rs # Общие типы данных (ChatInfo, MessageInfo, etc.) └── types.rs # Общие типы данных (ChatInfo, MessageInfo, MessageBuilder, etc.)
tests/ tests/
├── helpers/ ├── helpers/
@@ -299,6 +311,35 @@ reaction_other = "gray"
## Последние обновления (2026-01-31) ## Последние обновления (2026-01-31)
### P3.8 — Извлечение форматирования ✅ ЗАВЕРШЕНО!
**Что сделано**:
- ✅ Создан `src/formatting.rs` с логикой markdown форматирования (262 строки)
- ✅ Перенесены функции из `messages.rs`:
- `CharStyle` — структура для стилей символов (bold, italic, code, spoiler, url, mention)
- `format_text_with_entities()` — преобразование текста с entities в стилизованные Span
- `styles_equal()` — сравнение стилей
- `adjust_entities_for_substring()` — корректировка entities при переносе текста
- ✅ Добавлено 5 unit тестов для форматирования
- ✅ Обновлены `src/lib.rs` и `src/main.rs` для экспорта модуля
-`src/ui/messages.rs` сокращён на ~143 строки
-Все lib тесты проходят (17 passed)
- ✅ Бинарник компилируется успешно
**Преимущества**:
- 📦 Логика форматирования изолирована в отдельном модуле
- ✅ Можно тестировать независимо
- 🔄 Легко переиспользовать в других компонентах UI
- 📖 Улучшена читаемость кода
**Статус Priority 3**: 25% (2/8 задач) ✅
- ✅ P3.7 — UI компоненты
- ✅ P3.8 — Форматирование ← ТОЛЬКО ЧТО!
- ⏳ P3.9 — Группировка сообщений
- ⏳ P3.10 — Hotkey mapping
---
### 🎉🎊 PRIORITY 2 ЗАВЕРШЁН НА 100%! 🎊🎉 ### 🎉🎊 PRIORITY 2 ЗАВЕРШЁН НА 100%! 🎊🎉
**P2.7 — MessageBuilder pattern** ✅ ФИНАЛЬНАЯ ЗАДАЧА ЗАВЕРШЕНА! **P2.7 — MessageBuilder pattern** ✅ ФИНАЛЬНАЯ ЗАДАЧА ЗАВЕРШЕНА!
@@ -548,12 +589,14 @@ let message = MessageBuilder::new(MessageId::new(123))
4. ~~**MessageInfo реструктуризация**~~ ✅ — группировка полей в логические структуры (2026-01-31) 4. ~~**MessageInfo реструктуризация**~~ ✅ — группировка полей в логические структуры (2026-01-31)
5. ~~**MessageBuilder pattern**~~ ✅ — fluent API для создания сообщений (2026-01-31) 5. ~~**MessageBuilder pattern**~~ ✅ — fluent API для создания сообщений (2026-01-31)
**Завершено** (Priority 3): ✅ 1/4 (25%)
1. ~~**P3.7 — UI компоненты**~~ ✅ — выделение переиспользуемых компонентов (2026-01-31)
2. ~~**P3.8 — Форматирование**~~ ✅ — вынесено markdown форматирование в src/formatting.rs (2026-01-31)
**В работе** (Priority 3-5): **В работе** (Priority 3-5):
1. **UI компоненты** — выделение переиспользуемых компонентов 1. **P3.9 — Группировка сообщений** — вынести логику группировки в отдельный модуль
2. **MessageBuilder** — упрощение создания сообщений 2. **P3.10 — Hotkey mapping** — добавить настройку хоткеев в конфиг
3. **UI компоненты** — выделить переиспользуемые компоненты 3. **Юнит-тесты** — добавить для utils и других модулей
4. **Форматирование** — вынести markdown форматирование в отдельный модуль
5. **Юнит-тесты** — добавить для utils и других модулей
## Известные проблемы ## Известные проблемы

View File

@@ -5,7 +5,7 @@ pub use chat_state::ChatState;
pub use state::AppScreen; pub use state::AppScreen;
use crate::tdlib::{ChatInfo, TdClient}; use crate::tdlib::{ChatInfo, TdClient};
use crate::types::ChatId; use crate::types::{ChatId, MessageId};
use ratatui::widgets::ListState; use ratatui::widgets::ListState;
pub struct App { pub struct App {
@@ -189,7 +189,7 @@ impl App {
// Сначала извлекаем данные из сообщения // Сначала извлекаем данные из сообщения
let msg_data = self.get_selected_message().and_then(|msg| { let msg_data = self.get_selected_message().and_then(|msg| {
if msg.can_be_edited() && msg.is_outgoing() { if msg.can_be_edited() && msg.is_outgoing() {
Some((msg.id()(), msg.text().to_string(), selected_idx.unwrap())) Some((msg.id(), msg.text().to_string(), selected_idx.unwrap()))
} else { } else {
None None
} }
@@ -226,7 +226,7 @@ impl App {
} }
pub fn get_selected_chat_id(&self) -> Option<i64> { pub fn get_selected_chat_id(&self) -> Option<i64> {
self.selected_chat_id self.selected_chat_id.map(|id| id.as_i64())
} }
pub fn get_selected_chat(&self) -> Option<&ChatInfo> { pub fn get_selected_chat(&self) -> Option<&ChatInfo> {
@@ -451,7 +451,7 @@ impl App {
/// Получить ID текущего pinned для перехода в историю /// Получить ID текущего pinned для перехода в историю
pub fn get_selected_pinned_id(&self) -> Option<i64> { pub fn get_selected_pinned_id(&self) -> Option<i64> {
self.get_selected_pinned().map(|m| m.id()) self.get_selected_pinned().map(|m| m.id().as_i64())
} }
// === Message Search Mode === // === Message Search Mode ===
@@ -522,7 +522,7 @@ impl App {
/// Получить ID выбранного результата для перехода /// Получить ID выбранного результата для перехода
pub fn get_selected_search_result_id(&self) -> Option<i64> { pub fn get_selected_search_result_id(&self) -> Option<i64> {
self.get_selected_search_result().map(|m| m.id()) self.get_selected_search_result().map(|m| m.id().as_i64())
} }
/// Получить поисковый запрос из режима поиска /// Получить поисковый запрос из режима поиска
@@ -703,7 +703,7 @@ impl App {
available_reactions: Vec<String>, available_reactions: Vec<String>,
) { ) {
self.chat_state = ChatState::ReactionPicker { self.chat_state = ChatState::ReactionPicker {
message_id, message_id: MessageId::new(message_id),
available_reactions, available_reactions,
selected_index: 0, selected_index: 0,
}; };
@@ -748,6 +748,6 @@ impl App {
} }
pub fn get_selected_message_for_reaction(&self) -> Option<i64> { pub fn get_selected_message_for_reaction(&self) -> Option<i64> {
self.chat_state.selected_message_id() self.chat_state.selected_message_id().map(|id| id.as_i64())
} }
} }

285
src/formatting.rs Normal file
View File

@@ -0,0 +1,285 @@
//! Модуль для форматирования текста с markdown entities
//!
//! Предоставляет функции для преобразования текста с TDLib TextEntity
//! в стилизованные Span для отображения в TUI.
use ratatui::{
style::{Color, Modifier, Style},
text::Span,
};
use tdlib_rs::enums::TextEntityType;
use tdlib_rs::types::TextEntity;
/// Структура для хранения стиля символа
#[derive(Clone, Default)]
struct CharStyle {
bold: bool,
italic: bool,
underline: bool,
strikethrough: bool,
code: bool,
spoiler: bool,
url: bool,
mention: bool,
}
impl CharStyle {
/// Преобразует CharStyle в ratatui Style
fn to_style(&self, base_color: Color) -> Style {
let mut style = Style::default();
if self.code {
// Код отображается cyan на тёмном фоне
style = style.fg(Color::Cyan).bg(Color::DarkGray);
} else if self.spoiler {
// Спойлер — серый текст (скрытый)
style = style.fg(Color::DarkGray).bg(Color::DarkGray);
} else if self.url || self.mention {
// Ссылки и упоминания — синий с подчёркиванием
style = style.fg(Color::Blue).add_modifier(Modifier::UNDERLINED);
} else {
style = style.fg(base_color);
}
if self.bold {
style = style.add_modifier(Modifier::BOLD);
}
if self.italic {
style = style.add_modifier(Modifier::ITALIC);
}
if self.underline {
style = style.add_modifier(Modifier::UNDERLINED);
}
if self.strikethrough {
style = style.add_modifier(Modifier::CROSSED_OUT);
}
style
}
}
/// Проверяет равенство двух стилей
fn styles_equal(a: &CharStyle, b: &CharStyle) -> bool {
a.bold == b.bold
&& a.italic == b.italic
&& a.underline == b.underline
&& a.strikethrough == b.strikethrough
&& a.code == b.code
&& a.spoiler == b.spoiler
&& a.url == b.url
&& a.mention == b.mention
}
/// Преобразует текст с entities в вектор стилизованных Span
///
/// # Аргументы
///
/// * `text` - Текст для форматирования
/// * `entities` - Массив TextEntity с информацией о форматировании
/// * `base_color` - Базовый цвет текста
///
/// # Возвращает
///
/// Вектор Span<'static> со стилизованными фрагментами текста
pub fn format_text_with_entities(
text: &str,
entities: &[TextEntity],
base_color: Color,
) -> Vec<Span<'static>> {
if entities.is_empty() {
return vec![Span::styled(
text.to_string(),
Style::default().fg(base_color),
)];
}
// Создаём массив стилей для каждого символа
let chars: Vec<char> = text.chars().collect();
let mut char_styles: Vec<CharStyle> = vec![CharStyle::default(); chars.len()];
// Применяем entities к символам
for entity in entities {
let start = entity.offset as usize;
let end = (entity.offset + entity.length) as usize;
for i in start..end.min(chars.len()) {
match &entity.r#type {
TextEntityType::Bold => char_styles[i].bold = true,
TextEntityType::Italic => char_styles[i].italic = true,
TextEntityType::Underline => char_styles[i].underline = true,
TextEntityType::Strikethrough => char_styles[i].strikethrough = true,
TextEntityType::Code | TextEntityType::Pre | TextEntityType::PreCode(_) => {
char_styles[i].code = true
}
TextEntityType::Spoiler => char_styles[i].spoiler = true,
TextEntityType::Url
| TextEntityType::TextUrl(_)
| TextEntityType::EmailAddress
| TextEntityType::PhoneNumber => char_styles[i].url = true,
TextEntityType::Mention | TextEntityType::MentionName(_) => {
char_styles[i].mention = true
}
_ => {}
}
}
}
// Группируем последовательные символы с одинаковым стилем
let mut spans: Vec<Span<'static>> = Vec::new();
let mut current_text = String::new();
let mut current_style: Option<CharStyle> = None;
for (i, ch) in chars.iter().enumerate() {
let style = &char_styles[i];
match &current_style {
Some(prev_style) if styles_equal(prev_style, style) => {
current_text.push(*ch);
}
_ => {
if !current_text.is_empty() {
if let Some(prev_style) = &current_style {
spans.push(Span::styled(
current_text.clone(),
prev_style.to_style(base_color),
));
}
}
current_text = ch.to_string();
current_style = Some(style.clone());
}
}
}
// Добавляем последний span
if !current_text.is_empty() {
if let Some(style) = current_style {
spans.push(Span::styled(current_text, style.to_style(base_color)));
}
}
if spans.is_empty() {
spans.push(Span::styled(text.to_string(), Style::default().fg(base_color)));
}
spans
}
/// Фильтрует и корректирует entities для подстроки
///
/// Используется для правильного отображения форматирования при переносе текста.
///
/// # Аргументы
///
/// * `entities` - Исходный массив entities
/// * `start` - Начальная позиция подстроки (в символах)
/// * `length` - Длина подстроки (в символах)
///
/// # Возвращает
///
/// Новый массив entities с откорректированными offset и length
pub fn adjust_entities_for_substring(
entities: &[TextEntity],
start: usize,
length: usize,
) -> Vec<TextEntity> {
let start = start as i32;
let end = start + length as i32;
entities
.iter()
.filter_map(|e| {
let e_start = e.offset;
let e_end = e.offset + e.length;
// Проверяем пересечение с нашей подстрокой
if e_end <= start || e_start >= end {
return None;
}
// Вычисляем пересечение
let new_start = (e_start - start).max(0);
let new_end = (e_end - start).min(length as i32);
if new_end > new_start {
Some(TextEntity {
offset: new_start,
length: new_end - new_start,
r#type: e.r#type.clone(),
})
} else {
None
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_format_text_no_entities() {
let text = "Hello, world!";
let entities = vec![];
let spans = format_text_with_entities(text, &entities, Color::White);
assert_eq!(spans.len(), 1);
assert_eq!(spans[0].content, "Hello, world!");
}
#[test]
fn test_format_text_with_bold() {
let text = "Hello";
let entities = vec![TextEntity {
offset: 0,
length: 5,
r#type: TextEntityType::Bold,
}];
let spans = format_text_with_entities(text, &entities, Color::White);
assert_eq!(spans.len(), 1);
assert_eq!(spans[0].content, "Hello");
assert!(spans[0].style.add_modifier.contains(Modifier::BOLD));
}
#[test]
fn test_adjust_entities_full_overlap() {
let entities = vec![TextEntity {
offset: 0,
length: 10,
r#type: TextEntityType::Bold,
}];
let adjusted = adjust_entities_for_substring(&entities, 0, 10);
assert_eq!(adjusted.len(), 1);
assert_eq!(adjusted[0].offset, 0);
assert_eq!(adjusted[0].length, 10);
}
#[test]
fn test_adjust_entities_partial_overlap() {
let entities = vec![TextEntity {
offset: 5,
length: 10,
r#type: TextEntityType::Bold,
}];
let adjusted = adjust_entities_for_substring(&entities, 0, 10);
assert_eq!(adjusted.len(), 1);
assert_eq!(adjusted[0].offset, 5);
assert_eq!(adjusted[0].length, 5); // Обрезано до конца подстроки
}
#[test]
fn test_adjust_entities_no_overlap() {
let entities = vec![TextEntity {
offset: 20,
length: 10,
r#type: TextEntityType::Bold,
}];
let adjusted = adjust_entities_for_substring(&entities, 0, 10);
assert_eq!(adjusted.len(), 0); // Нет пересечений
}
}

View File

@@ -30,7 +30,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
app.status_message = Some("Загрузка закреплённых...".to_string()); app.status_message = Some("Загрузка закреплённых...".to_string());
match timeout( match timeout(
Duration::from_secs(5), Duration::from_secs(5),
app.td_client.get_pinned_messages(chat_id), app.td_client.get_pinned_messages(ChatId::new(chat_id)),
) )
.await .await
{ {
@@ -212,7 +212,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
if !query.is_empty() { if !query.is_empty() {
if let Ok(Ok(results)) = timeout( if let Ok(Ok(results)) = timeout(
Duration::from_secs(3), Duration::from_secs(3),
app.td_client.search_messages(chat_id, &query), app.td_client.search_messages(ChatId::new(chat_id), &query),
) )
.await .await
{ {
@@ -233,7 +233,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
if let Some(chat_id) = app.get_selected_chat_id() { if let Some(chat_id) = app.get_selected_chat_id() {
if let Ok(Ok(results)) = timeout( if let Ok(Ok(results)) = timeout(
Duration::from_secs(3), Duration::from_secs(3),
app.td_client.search_messages(chat_id, &query), app.td_client.search_messages(ChatId::new(chat_id), &query),
) )
.await .await
{ {
@@ -388,7 +388,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
match timeout( match timeout(
Duration::from_secs(5), Duration::from_secs(5),
app.td_client.delete_messages( app.td_client.delete_messages(
chat_id, ChatId::new(chat_id),
vec![msg_id], vec![msg_id],
can_delete_for_all, can_delete_for_all,
), ),
@@ -442,7 +442,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
Duration::from_secs(5), Duration::from_secs(5),
app.td_client.forward_messages( app.td_client.forward_messages(
to_chat_id, to_chat_id,
from_chat_id, ChatId::new(from_chat_id),
vec![msg_id], vec![msg_id],
), ),
) )
@@ -490,7 +490,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
app.message_scroll_offset = 0; app.message_scroll_offset = 0;
match timeout( match timeout(
Duration::from_secs(10), Duration::from_secs(10),
app.td_client.get_chat_history(chat_id, 100), app.td_client.get_chat_history(ChatId::new(chat_id), 100),
) )
.await .await
{ {
@@ -504,7 +504,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
// Загружаем последнее закреплённое сообщение // Загружаем последнее закреплённое сообщение
let _ = timeout( let _ = timeout(
Duration::from_secs(2), Duration::from_secs(2),
app.td_client.load_current_pinned_message(chat_id), app.td_client.load_current_pinned_message(ChatId::new(chat_id)),
) )
.await; .await;
// Загружаем черновик // Загружаем черновик
@@ -572,7 +572,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
match timeout( match timeout(
Duration::from_secs(5), Duration::from_secs(5),
app.td_client.edit_message(chat_id, msg_id, text), app.td_client.edit_message(ChatId::new(chat_id), msg_id, text),
) )
.await .await
{ {
@@ -622,13 +622,13 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
// Отменяем typing status // Отменяем typing status
app.td_client app.td_client
.send_chat_action(chat_id, ChatAction::Cancel) .send_chat_action(ChatId::new(chat_id), ChatAction::Cancel)
.await; .await;
match timeout( match timeout(
Duration::from_secs(5), Duration::from_secs(5),
app.td_client app.td_client
.send_message(chat_id, text, reply_to_id, reply_info), .send_message(ChatId::new(chat_id), text, reply_to_id, reply_info),
) )
.await .await
{ {
@@ -659,7 +659,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
app.message_scroll_offset = 0; app.message_scroll_offset = 0;
match timeout( match timeout(
Duration::from_secs(10), Duration::from_secs(10),
app.td_client.get_chat_history(chat_id, 100), app.td_client.get_chat_history(ChatId::new(chat_id), 100),
) )
.await .await
{ {
@@ -673,7 +673,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
// Загружаем последнее закреплённое сообщение // Загружаем последнее закреплённое сообщение
let _ = timeout( let _ = timeout(
Duration::from_secs(2), Duration::from_secs(2),
app.td_client.load_current_pinned_message(chat_id), app.td_client.load_current_pinned_message(ChatId::new(chat_id)),
) )
.await; .await;
// Загружаем черновик // Загружаем черновик
@@ -795,7 +795,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
app.status_message = None; app.status_message = None;
app.needs_redraw = true; app.needs_redraw = true;
} else { } else {
app.enter_reaction_picker_mode(message_id, reactions); app.enter_reaction_picker_mode(message_id.as_i64(), reactions);
app.status_message = None; app.status_message = None;
app.needs_redraw = true; app.needs_redraw = true;
} }
@@ -894,7 +894,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
if should_send_typing { if should_send_typing {
if let Some(chat_id) = app.get_selected_chat_id() { if let Some(chat_id) = app.get_selected_chat_id() {
app.td_client app.td_client
.send_chat_action(chat_id, ChatAction::Typing) .send_chat_action(ChatId::new(chat_id), ChatAction::Typing)
.await; .await;
app.last_typing_sent = Some(Instant::now()); app.last_typing_sent = Some(Instant::now());
} }
@@ -943,7 +943,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
.current_chat_messages() .current_chat_messages()
.first() .first()
.map(|m| m.id()) .map(|m| m.id())
.unwrap_or(0); .unwrap_or(MessageId::new(0));
if let Some(chat_id) = app.get_selected_chat_id() { if let Some(chat_id) = app.get_selected_chat_id() {
// Подгружаем больше сообщений если скролл близко к верху // Подгружаем больше сообщений если скролл близко к верху
if app.message_scroll_offset if app.message_scroll_offset
@@ -952,7 +952,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
if let Ok(Ok(older)) = timeout( if let Ok(Ok(older)) = timeout(
Duration::from_secs(3), Duration::from_secs(3),
app.td_client app.td_client
.load_older_messages(chat_id, oldest_msg_id), .load_older_messages(ChatId::new(chat_id), oldest_msg_id),
) )
.await .await
{ {
@@ -1041,12 +1041,12 @@ fn format_message_for_clipboard(msg: &crate::tdlib::MessageInfo) -> String {
let mut result = String::new(); let mut result = String::new();
// Добавляем forward контекст если есть // Добавляем forward контекст если есть
if let Some(forward) = &msg.forward_from { if let Some(forward) = msg.forward_from() {
result.push_str(&format!("↪ Переслано от {}\n", forward.sender_name)); result.push_str(&format!("↪ Переслано от {}\n", forward.sender_name));
} }
// Добавляем reply контекст если есть // Добавляем reply контекст если есть
if let Some(reply) = &msg.reply_to { if let Some(reply) = msg.reply_to() {
result.push_str(&format!("{}: {}\n", reply.sender_name, reply.text)); result.push_str(&format!("{}: {}\n", reply.sender_name, reply.text));
} }

View File

@@ -5,6 +5,7 @@ pub mod app;
pub mod config; pub mod config;
pub mod constants; pub mod constants;
pub mod error; pub mod error;
pub mod formatting;
pub mod input; pub mod input;
pub mod tdlib; pub mod tdlib;
pub mod types; pub mod types;

View File

@@ -1,8 +1,11 @@
mod app; mod app;
mod config; mod config;
mod constants; mod constants;
mod error;
mod formatting;
mod input; mod input;
mod tdlib; mod tdlib;
mod types;
mod ui; mod ui;
mod utils; mod utils;

View File

@@ -292,27 +292,27 @@ impl TdClient {
self.message_manager.current_pinned_message = msg; self.message_manager.current_pinned_message = msg;
} }
pub fn typing_status(&self) -> Option<&(i64, String, std::time::Instant)> { pub fn typing_status(&self) -> Option<&(crate::types::UserId, String, std::time::Instant)> {
self.chat_manager.typing_status.as_ref() self.chat_manager.typing_status.as_ref()
} }
pub fn set_typing_status(&mut self, status: Option<(i64, String, std::time::Instant)>) { pub fn set_typing_status(&mut self, status: Option<(crate::types::UserId, String, std::time::Instant)>) {
self.chat_manager.typing_status = status; self.chat_manager.typing_status = status;
} }
pub fn pending_view_messages(&self) -> &[(i64, Vec<i64>)] { pub fn pending_view_messages(&self) -> &[(crate::types::ChatId, Vec<crate::types::MessageId>)] {
&self.message_manager.pending_view_messages &self.message_manager.pending_view_messages
} }
pub fn pending_view_messages_mut(&mut self) -> &mut Vec<(i64, Vec<i64>)> { pub fn pending_view_messages_mut(&mut self) -> &mut Vec<(crate::types::ChatId, Vec<crate::types::MessageId>)> {
&mut self.message_manager.pending_view_messages &mut self.message_manager.pending_view_messages
} }
pub fn pending_user_ids(&self) -> &[i64] { pub fn pending_user_ids(&self) -> &[crate::types::UserId] {
&self.user_cache.pending_user_ids &self.user_cache.pending_user_ids
} }
pub fn pending_user_ids_mut(&mut self) -> &mut Vec<i64> { pub fn pending_user_ids_mut(&mut self) -> &mut Vec<crate::types::UserId> {
&mut self.user_cache.pending_user_ids &mut self.user_cache.pending_user_ids
} }
@@ -470,8 +470,8 @@ impl TdClient {
let chat_id = ChatId::new(new_msg.message.chat_id); let chat_id = ChatId::new(new_msg.message.chat_id);
if Some(chat_id) == self.current_chat_id() { if Some(chat_id) == self.current_chat_id() {
let msg_info = self.convert_message(&new_msg.message, chat_id); let msg_info = self.convert_message(&new_msg.message, chat_id);
let msg_id = msg_info.id; let msg_id = msg_info.id();
let is_incoming = !msg_info.is_outgoing; let is_incoming = !msg_info.is_outgoing();
// Проверяем, есть ли уже сообщение с таким id // Проверяем, есть ли уже сообщение с таким id
let existing_idx = self let existing_idx = self
@@ -488,12 +488,12 @@ impl TdClient {
// Для исходящих: обновляем can_be_edited и другие поля, // Для исходящих: обновляем can_be_edited и другие поля,
// но сохраняем reply_to (добавленный при отправке) // но сохраняем reply_to (добавленный при отправке)
let existing = &mut self.current_chat_messages_mut()[idx]; let existing = &mut self.current_chat_messages_mut()[idx];
existing.can_be_edited = msg_info.can_be_edited; existing.state.can_be_edited = msg_info.state.can_be_edited;
existing.can_be_deleted_only_for_self = existing.state.can_be_deleted_only_for_self =
msg_info.can_be_deleted_only_for_self; msg_info.state.can_be_deleted_only_for_self;
existing.can_be_deleted_for_all_users = existing.state.can_be_deleted_for_all_users =
msg_info.can_be_deleted_for_all_users; msg_info.state.can_be_deleted_for_all_users;
existing.is_read = msg_info.is_read; existing.state.is_read = msg_info.state.is_read;
} }
} }
None => { None => {
@@ -518,7 +518,7 @@ impl TdClient {
// Clone chat_user_ids to avoid borrow conflict // Clone chat_user_ids to avoid borrow conflict
let chat_user_ids = self.user_cache.chat_user_ids.clone(); let chat_user_ids = self.user_cache.chat_user_ids.clone();
self.chats_mut() self.chats_mut()
.retain(|c| chat_user_ids.get(&c.id) != Some(&user_id)); .retain(|c| chat_user_ids.get(&c.id) != Some(&UserId::new(user_id)));
return; return;
} }
@@ -528,15 +528,15 @@ impl TdClient {
} else { } else {
format!("{} {}", user.first_name, user.last_name) format!("{} {}", user.first_name, user.last_name)
}; };
self.user_cache.user_names.insert(user.id, display_name); self.user_cache.user_names.insert(UserId::new(user.id), display_name);
// Сохраняем username если есть // Сохраняем username если есть
if let Some(usernames) = user.usernames { if let Some(usernames) = user.usernames {
if let Some(username) = usernames.active_usernames.first() { if let Some(username) = usernames.active_usernames.first() {
self.user_cache.user_usernames.insert(user.id, username.clone()); self.user_cache.user_usernames.insert(UserId::new(user.id), username.clone());
// Обновляем username в чатах, связанных с этим пользователем // Обновляем username в чатах, связанных с этим пользователем
for (&chat_id, &user_id) in &self.user_cache.chat_user_ids.clone() { for (&chat_id, &user_id) in &self.user_cache.chat_user_ids.clone() {
if user_id == user.id { if user_id == UserId::new(user.id) {
if let Some(chat) = self.chats_mut().iter_mut().find(|c| c.id == chat_id) if let Some(chat) = self.chats_mut().iter_mut().find(|c| c.id == chat_id)
{ {
chat.username = Some(format!("@{}", username)); chat.username = Some(format!("@{}", username));
@@ -991,20 +991,20 @@ impl TdClient {
match origin { match origin {
MessageOrigin::User(u) => self MessageOrigin::User(u) => self
.user_cache.user_names .user_cache.user_names
.peek(&u.sender_user_id) .peek(&UserId::new(u.sender_user_id))
.cloned() .cloned()
.unwrap_or_else(|| format!("User_{}", u.sender_user_id)), .unwrap_or_else(|| format!("User_{}", u.sender_user_id)),
MessageOrigin::Chat(c) => self MessageOrigin::Chat(c) => self
.chats() .chats()
.iter() .iter()
.find(|chat| chat.id == c.sender_chat_id) .find(|chat| chat.id == ChatId::new(c.sender_chat_id))
.map(|chat| chat.title.clone()) .map(|chat| chat.title.clone())
.unwrap_or_else(|| "Чат".to_string()), .unwrap_or_else(|| "Чат".to_string()),
MessageOrigin::HiddenUser(h) => h.sender_name.clone(), MessageOrigin::HiddenUser(h) => h.sender_name.clone(),
MessageOrigin::Channel(c) => self MessageOrigin::Channel(c) => self
.chats() .chats()
.iter() .iter()
.find(|chat| chat.id == c.chat_id) .find(|chat| chat.id == ChatId::new(c.chat_id))
.map(|chat| chat.title.clone()) .map(|chat| chat.title.clone())
.unwrap_or_else(|| "Канал".to_string()), .unwrap_or_else(|| "Канал".to_string()),
} }
@@ -1017,15 +1017,15 @@ impl TdClient {
let msg_data: std::collections::HashMap<i64, (String, String)> = self let msg_data: std::collections::HashMap<i64, (String, String)> = self
.current_chat_messages() .current_chat_messages()
.iter() .iter()
.map(|m| (m.id(), (m.sender_name().to_string(), m.text().to_string()))) .map(|m| (m.id().as_i64(), (m.sender_name().to_string(), m.text().to_string())))
.collect(); .collect();
// Обновляем reply_to для сообщений с неполными данными // Обновляем reply_to для сообщений с неполными данными
for msg in self.current_chat_messages_mut().iter_mut() { for msg in self.current_chat_messages_mut().iter_mut() {
if let Some(ref mut reply) = msg.reply_to { if let Some(ref mut reply) = msg.interactions.reply_to {
// Если sender_name = "..." или text пустой — пробуем заполнить // Если sender_name = "..." или text пустой — пробуем заполнить
if reply.sender_name == "..." || reply.text.is_empty() { if reply.sender_name == "..." || reply.text.is_empty() {
if let Some((sender, content)) = msg_data.get(&reply.message_id) { if let Some((sender, content)) = msg_data.get(&reply.message_id.as_i64()) {
if reply.sender_name == "..." { if reply.sender_name == "..." {
reply.sender_name = sender.clone(); reply.sender_name = sender.clone();
} }

View File

@@ -4,7 +4,7 @@ use tdlib_rs::enums::{ChatAction, InputMessageContent, InputMessageReplyTo, Mess
use tdlib_rs::functions; use tdlib_rs::functions;
use tdlib_rs::types::{Chat as TdChat, FormattedText, InputMessageReplyToMessage, InputMessageText, Message as TdMessage, TextEntity, TextParseModeMarkdown}; use tdlib_rs::types::{Chat as TdChat, FormattedText, InputMessageReplyToMessage, InputMessageText, Message as TdMessage, TextEntity, TextParseModeMarkdown};
use super::types::{ForwardInfo, MessageInfo, ReactionInfo, ReplyInfo}; use super::types::{ForwardInfo, MessageBuilder, MessageInfo, ReactionInfo, ReplyInfo};
/// Менеджер сообщений /// Менеджер сообщений
pub struct MessageManager { pub struct MessageManager {
@@ -375,7 +375,8 @@ impl MessageManager {
let batch = std::mem::take(&mut self.pending_view_messages); let batch = std::mem::take(&mut self.pending_view_messages);
for (chat_id, message_ids) in batch { for (chat_id, message_ids) in batch {
let _ = functions::view_messages(chat_id, message_ids, None, true, self.client_id).await; let ids: Vec<i64> = message_ids.iter().map(|id| id.as_i64()).collect();
let _ = functions::view_messages(chat_id.as_i64(), ids, None, true, self.client_id).await;
} }
} }
@@ -454,7 +455,7 @@ impl MessageManager {
if let tdlib_rs::enums::MessageReplyTo::Message(reply_msg) = reply_to { if let tdlib_rs::enums::MessageReplyTo::Message(reply_msg) = reply_to {
// Здесь можно загрузить информацию об оригинальном сообщении // Здесь можно загрузить информацию об оригинальном сообщении
Some(ReplyInfo { Some(ReplyInfo {
message_id: reply_msg.message_id, message_id: MessageId::new(reply_msg.message_id),
sender_name: "Unknown".to_string(), sender_name: "Unknown".to_string(),
text: "...".to_string(), text: "...".to_string(),
}) })
@@ -488,22 +489,48 @@ impl MessageManager {
}) })
.unwrap_or_default(); .unwrap_or_default();
Some(MessageInfo { let mut builder = MessageBuilder::new(MessageId::new(msg.id))
id: msg.id, .sender_name(sender_name)
sender_name, .text(content_text)
is_outgoing: msg.is_outgoing, .entities(entities)
content: content_text, .date(msg.date)
entities, .edit_date(msg.edit_date);
date: msg.date,
edit_date: msg.edit_date, if msg.is_outgoing {
is_read: !msg.contains_unread_mention, builder = builder.outgoing();
can_be_edited: msg.can_be_edited, } else {
can_be_deleted_only_for_self: msg.can_be_deleted_only_for_self, builder = builder.incoming();
can_be_deleted_for_all_users: msg.can_be_deleted_for_all_users, }
reply_to,
forward_from, if !msg.contains_unread_mention {
reactions, builder = builder.read();
}) } else {
builder = builder.unread();
}
if msg.can_be_edited {
builder = builder.editable();
}
if msg.can_be_deleted_only_for_self {
builder = builder.deletable_for_self();
}
if msg.can_be_deleted_for_all_users {
builder = builder.deletable_for_all();
}
if let Some(reply) = reply_to {
builder = builder.reply_to(reply);
}
if let Some(forward) = forward_from {
builder = builder.forward_from(forward);
}
builder = builder.reactions(reactions);
Some(builder.build())
} }
/// Получить недостающую reply информацию для сообщений /// Получить недостающую reply информацию для сообщений
@@ -511,7 +538,7 @@ impl MessageManager {
// Collect message IDs that need to be fetched // Collect message IDs that need to be fetched
let mut to_fetch = Vec::new(); let mut to_fetch = Vec::new();
for msg in &self.current_chat_messages { for msg in &self.current_chat_messages {
if let Some(ref reply) = msg.reply_to { if let Some(ref reply) = msg.interactions.reply_to {
if reply.sender_name == "Unknown" { if reply.sender_name == "Unknown" {
to_fetch.push(reply.message_id); to_fetch.push(reply.message_id);
} }
@@ -522,17 +549,18 @@ impl MessageManager {
if let Some(chat_id) = self.current_chat_id { if let Some(chat_id) = self.current_chat_id {
for message_id in to_fetch { for message_id in to_fetch {
if let Ok(original_msg_enum) = if let Ok(original_msg_enum) =
functions::get_message(chat_id, message_id, self.client_id).await functions::get_message(chat_id.as_i64(), message_id.as_i64(), self.client_id).await
{ {
if let tdlib_rs::enums::Message::Message(original_msg) = original_msg_enum { if let tdlib_rs::enums::Message::Message(original_msg) = original_msg_enum {
if let Some(orig_info) = self.convert_message(&original_msg).await { if let Some(orig_info) = self.convert_message(&original_msg).await {
// Update the reply info // Update the reply info
for msg in &mut self.current_chat_messages { for msg in &mut self.current_chat_messages {
if let Some(ref mut reply) = msg.reply_to { if let Some(ref mut reply) = msg.interactions.reply_to {
if reply.message_id == message_id { if reply.message_id == message_id {
reply.sender_name = orig_info.sender_name.clone(); reply.sender_name = orig_info.metadata.sender_name.clone();
reply.text = orig_info reply.text = orig_info
.content .content
.text
.chars() .chars()
.take(50) .take(50)
.collect::<String>(); .collect::<String>();

View File

@@ -68,7 +68,7 @@ pub struct MessageMetadata {
} }
/// Контент сообщения (текст и форматирование) /// Контент сообщения (текст и форматирование)
#[derive(Debug, Clone)] #[derive(Debug, Clone, PartialEq)]
pub struct MessageContent { pub struct MessageContent {
pub text: String, pub text: String,
/// Сущности форматирования (bold, italic, code и т.д.) /// Сущности форматирования (bold, italic, code и т.д.)

View File

@@ -61,7 +61,7 @@ impl<V: Clone> LruCache<V> {
} }
/// Проверить наличие ключа /// Проверить наличие ключа
pub fn contains_key(&self, key: &i64) -> bool { pub fn contains_key(&self, key: &UserId) -> bool {
self.map.contains_key(key) self.map.contains_key(key)
} }
@@ -181,7 +181,7 @@ impl UserCache {
} }
// Берём первые N user_ids для загрузки // Берём первые N user_ids для загрузки
let batch: Vec<i64> = self let batch: Vec<UserId> = self
.pending_user_ids .pending_user_ids
.drain(..self.pending_user_ids.len().min(LAZY_LOAD_USERS_PER_TICK)) .drain(..self.pending_user_ids.len().min(LAZY_LOAD_USERS_PER_TICK))
.collect(); .collect();
@@ -191,7 +191,7 @@ impl UserCache {
continue; // Уже в кэше continue; // Уже в кэше
} }
match functions::get_user(user_id, self.client_id).await { match functions::get_user(user_id.as_i64(), self.client_id).await {
Ok(user_enum) => { Ok(user_enum) => {
self.handle_user_update(&user_enum); self.handle_user_update(&user_enum);
} }

View File

@@ -36,7 +36,7 @@ impl fmt::Display for ChatId {
} }
/// Message identifier /// Message identifier
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
pub struct MessageId(pub i64); pub struct MessageId(pub i64);
impl MessageId { impl MessageId {

View File

@@ -1,5 +1,6 @@
use crate::app::App; use crate::app::App;
use crate::tdlib::UserOnlineStatus; use crate::tdlib::UserOnlineStatus;
use crate::ui::components;
use ratatui::{ use ratatui::{
layout::{Constraint, Direction, Layout, Rect}, layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style}, style::{Color, Modifier, Style},
@@ -43,63 +44,8 @@ pub fn render(f: &mut Frame, area: Rect, app: &mut App) {
.iter() .iter()
.map(|chat| { .map(|chat| {
let is_selected = app.selected_chat_id == Some(chat.id); let is_selected = app.selected_chat_id == Some(chat.id);
let pin_icon = if chat.is_pinned { "📌 " } else { "" }; let user_status = app.td_client.get_user_status_by_chat_id(chat.id);
let mute_icon = if chat.is_muted { "🔇 " } else { "" }; components::render_chat_list_item(chat, is_selected, user_status)
// Онлайн-статус (зелёная точка для онлайн)
let status_icon = match app.td_client.get_user_status_by_chat_id(chat.id) {
Some(UserOnlineStatus::Online) => "",
_ => " ",
};
let prefix = if is_selected { "" } else { " " };
let username_text = chat
.username
.as_ref()
.map(|u| format!(" {}", u))
.unwrap_or_default();
// Индикатор упоминаний @
let mention_badge = if chat.unread_mention_count > 0 {
" @".to_string()
} else {
String::new()
};
// Индикатор черновика ✎
let draft_badge = if chat.draft_text.is_some() {
"".to_string()
} else {
String::new()
};
let unread_badge = if chat.unread_count > 0 {
format!(" ({})", chat.unread_count)
} else {
String::new()
};
let content = format!(
"{}{}{}{}{}{}{}{}{}",
prefix,
status_icon,
pin_icon,
mute_icon,
chat.title,
username_text,
mention_badge,
draft_badge,
unread_badge
);
// Цвет: онлайн — зелёные, остальные — белые
let style = match app.td_client.get_user_status_by_chat_id(chat.id) {
Some(UserOnlineStatus::Online) => Style::default().fg(Color::Green),
_ => Style::default().fg(Color::White),
};
ListItem::new(content).style(style)
}) })
.collect(); .collect();

View File

@@ -0,0 +1,78 @@
use crate::tdlib::{ChatInfo, UserOnlineStatus};
use ratatui::{
style::{Color, Style},
widgets::ListItem,
};
/// Рендерит элемент списка чатов
///
/// # Параметры
/// - `chat`: Информация о чате
/// - `is_selected`: Выбран ли этот чат
/// - `user_status`: Онлайн-статус пользователя (если доступен)
///
/// # Возвращает
/// ListItem с форматированным отображением чата
pub fn render_chat_list_item(
chat: &ChatInfo,
is_selected: bool,
user_status: Option<&UserOnlineStatus>,
) -> ListItem<'static> {
let pin_icon = if chat.is_pinned { "📌 " } else { "" };
let mute_icon = if chat.is_muted { "🔇 " } else { "" };
// Онлайн-статус (зелёная точка для онлайн)
let status_icon = match user_status {
Some(UserOnlineStatus::Online) => "",
_ => " ",
};
let prefix = if is_selected { "" } else { " " };
let username_text = chat
.username
.as_ref()
.map(|u| format!(" {}", u))
.unwrap_or_default();
// Индикатор упоминаний @
let mention_badge = if chat.unread_mention_count > 0 {
" @".to_string()
} else {
String::new()
};
// Индикатор черновика ✎
let draft_badge = if chat.draft_text.is_some() {
"".to_string()
} else {
String::new()
};
let unread_badge = if chat.unread_count > 0 {
format!(" ({})", chat.unread_count)
} else {
String::new()
};
let content = format!(
"{}{}{}{}{}{}{}{}{}",
prefix,
status_icon,
pin_icon,
mute_icon,
chat.title,
username_text,
mention_badge,
draft_badge,
unread_badge
);
// Цвет: онлайн — зелёные, остальные — белые
let style = match user_status {
Some(UserOnlineStatus::Online) => Style::default().fg(Color::Green),
_ => Style::default().fg(Color::White),
};
ListItem::new(content).style(style)
}

View File

@@ -0,0 +1,112 @@
use ratatui::{
layout::{Alignment, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, Paragraph},
Frame,
};
/// Рендерит модалку выбора реакций (emoji picker)
///
/// # Параметры
/// - `f`: Frame для рендеринга
/// - `area`: Область экрана
/// - `available_reactions`: Список доступных эмодзи
/// - `selected_index`: Индекс выбранного эмодзи
pub fn render_emoji_picker(
f: &mut Frame,
area: Rect,
available_reactions: &[String],
selected_index: usize,
) {
// Размеры модалки (зависят от количества реакций)
let emojis_per_row = 8;
let rows = (available_reactions.len() + emojis_per_row - 1) / emojis_per_row;
let modal_width = 50u16;
let modal_height = (rows + 4) as u16; // +4 для заголовка, отступов и подсказки
// Центрируем модалку
let x = area.x + (area.width.saturating_sub(modal_width)) / 2;
let y = area.y + (area.height.saturating_sub(modal_height)) / 2;
let modal_area = Rect::new(
x,
y,
modal_width.min(area.width),
modal_height.min(area.height),
);
// Очищаем область под модалкой
f.render_widget(Clear, modal_area);
// Формируем содержимое - сетка эмодзи
let mut text_lines = vec![Line::from("")]; // Пустая строка сверху
for row in 0..rows {
let mut row_spans = vec![Span::raw(" ")]; // Отступ слева
for col in 0..emojis_per_row {
let idx = row * emojis_per_row + col;
if idx >= available_reactions.len() {
break;
}
let emoji = &available_reactions[idx];
let is_selected = idx == selected_index;
let style = if is_selected {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
.add_modifier(Modifier::REVERSED)
} else {
Style::default().fg(Color::White)
};
row_spans.push(Span::styled(format!(" {} ", emoji), style));
row_spans.push(Span::raw(" ")); // Пробел между эмодзи
}
text_lines.push(Line::from(row_spans));
}
// Добавляем пустую строку и подсказку
text_lines.push(Line::from(""));
text_lines.push(Line::from(vec![
Span::styled(
" [←/→/↑/↓] ",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::raw("Выбор "),
Span::styled(
" [Enter] ",
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
Span::raw("Добавить "),
Span::styled(
" [Esc] ",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
),
Span::raw("Отмена"),
]));
let modal = Paragraph::new(text_lines)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow))
.title(" Выбери реакцию ")
.title_style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
)
.alignment(Alignment::Center);
f.render_widget(modal, modal_area);
}

View File

@@ -0,0 +1,53 @@
use ratatui::{
style::{Color, Style},
text::{Line, Span},
};
/// Рендерит текст с курсором в виде Line
///
/// # Параметры
/// - `prefix`: Префикс перед текстом (например, "Сообщение: ")
/// - `text`: Текст в поле ввода
/// - `cursor_pos`: Позиция курсора (индекс символа)
/// - `color`: Цвет текста и курсора
///
/// # Возвращает
/// Line с текстом и блочным курсором на указанной позиции
pub fn render_input_field(
prefix: &str,
text: &str,
cursor_pos: usize,
color: Color,
) -> Line<'static> {
let chars: Vec<char> = text.chars().collect();
let mut spans: Vec<Span> = vec![Span::raw(prefix.to_string())];
// Ограничиваем cursor_pos границами текста
let safe_cursor_pos = cursor_pos.min(chars.len());
// Текст до курсора
if safe_cursor_pos > 0 {
let before: String = chars[..safe_cursor_pos].iter().collect();
spans.push(Span::styled(before, Style::default().fg(color)));
}
// Символ под курсором (или █ если курсор в конце)
if safe_cursor_pos < chars.len() {
let cursor_char = chars[safe_cursor_pos].to_string();
spans.push(Span::styled(
cursor_char,
Style::default().fg(Color::Black).bg(color),
));
} else {
// Курсор в конце - показываем блок
spans.push(Span::styled("", Style::default().fg(color)));
}
// Текст после курсора
if safe_cursor_pos + 1 < chars.len() {
let after: String = chars[safe_cursor_pos + 1..].iter().collect();
spans.push(Span::styled(after, Style::default().fg(color)));
}
Line::from(spans)
}

View File

@@ -0,0 +1,26 @@
// Message bubble component
//
// TODO: Этот компонент требует дальнейшего рефакторинга.
// Логика рендеринга сообщений в messages.rs очень сложная и интегрированная,
// включая:
// - Группировку сообщений по дате и отправителю
// - Форматирование markdown (entities)
// - Перенос длинных текстов
// - Отображение reply, forward, reactions
// - Выравнивание (входящие/исходящие)
//
// Для полного выделения компонента нужно сначала:
// 1. Вынести форматирование в src/formatting.rs (P3.8)
// 2. Вынести группировку в src/message_grouping.rs (P3.9)
//
// Пока этот файл служит placeholder'ом для будущего рефакторинга.
use crate::tdlib::MessageInfo;
/// Placeholder для функции рендеринга пузыря сообщения
///
/// TODO: Реализовать после выполнения P3.8 и P3.9
pub fn render_message_bubble(_message: &MessageInfo) {
// Будет реализовано позже
unimplemented!("Message bubble rendering requires P3.8 and P3.9 first")
}

14
src/ui/components/mod.rs Normal file
View File

@@ -0,0 +1,14 @@
// UI компоненты для переиспользования
pub mod modal;
pub mod input_field;
pub mod message_bubble;
pub mod chat_list_item;
pub mod emoji_picker;
// Экспорт основных функций
pub use modal::render_modal;
pub use input_field::render_input_field;
pub use message_bubble::render_message_bubble;
pub use chat_list_item::render_chat_list_item;
pub use emoji_picker::render_emoji_picker;

View File

@@ -0,0 +1,86 @@
use ratatui::{
layout::{Alignment, Rect},
style::{Color, Modifier, Style},
text::Line,
widgets::{Block, Borders, Clear, Paragraph},
Frame,
};
/// Рендерит центрированную модалку с заданным содержимым
///
/// # Параметры
/// - `f`: Frame для рендеринга
/// - `area`: Область экрана
/// - `title`: Заголовок модалки
/// - `content`: Содержимое модалки (строки текста)
/// - `width`: Ширина модалки
/// - `height`: Высота модалки
/// - `border_color`: Цвет рамки
pub fn render_modal(
f: &mut Frame,
area: Rect,
title: &str,
content: Vec<Line>,
width: u16,
height: u16,
border_color: Color,
) {
// Центрируем модалку
let x = area.x + (area.width.saturating_sub(width)) / 2;
let y = area.y + (area.height.saturating_sub(height)) / 2;
let modal_area = Rect::new(x, y, width.min(area.width), height.min(area.height));
// Очищаем область под модалкой
f.render_widget(Clear, modal_area);
// Рендерим модалку
let modal = Paragraph::new(content)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color))
.title(format!(" {} ", title))
.title_style(
Style::default()
.fg(border_color)
.add_modifier(Modifier::BOLD),
),
)
.alignment(Alignment::Center);
f.render_widget(modal, modal_area);
}
/// Рендерит модалку подтверждения удаления
pub fn render_delete_confirm_modal(f: &mut Frame, area: Rect) {
use ratatui::text::Span;
let content = vec![
Line::from(""),
Line::from(Span::styled(
"Удалить сообщение?",
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(vec![
Span::styled(
" [y/Enter] ",
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
Span::raw("Да"),
Span::raw(" "),
Span::styled(
" [n/Esc] ",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
),
Span::raw("Нет"),
]),
];
render_modal(f, area, "Подтверждение", content, 40, 7, Color::Red);
}

View File

@@ -1,4 +1,6 @@
use crate::app::App; use crate::app::App;
use crate::formatting;
use crate::ui::components;
use crate::utils::{format_date, format_timestamp_with_tz, get_day}; use crate::utils::{format_date, format_timestamp_with_tz, get_day};
use ratatui::{ use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect}, layout::{Alignment, Constraint, Direction, Layout, Rect},
@@ -7,188 +9,15 @@ use ratatui::{
widgets::{Block, Borders, Paragraph}, widgets::{Block, Borders, Paragraph},
Frame, Frame,
}; };
use tdlib_rs::enums::TextEntityType;
use tdlib_rs::types::TextEntity;
/// Структура для хранения стиля символа
#[derive(Clone, Default)]
struct CharStyle {
bold: bool,
italic: bool,
underline: bool,
strikethrough: bool,
code: bool,
spoiler: bool,
url: bool,
mention: bool,
}
impl CharStyle {
fn to_style(&self, base_color: Color) -> Style {
let mut style = Style::default();
if self.code {
// Код отображается cyan на тёмном фоне
style = style.fg(Color::Cyan).bg(Color::DarkGray);
} else if self.spoiler {
// Спойлер — серый текст (скрытый)
style = style.fg(Color::DarkGray).bg(Color::DarkGray);
} else if self.url || self.mention {
// Ссылки и упоминания — синий с подчёркиванием
style = style.fg(Color::Blue).add_modifier(Modifier::UNDERLINED);
} else {
style = style.fg(base_color);
}
if self.bold {
style = style.add_modifier(Modifier::BOLD);
}
if self.italic {
style = style.add_modifier(Modifier::ITALIC);
}
if self.underline {
style = style.add_modifier(Modifier::UNDERLINED);
}
if self.strikethrough {
style = style.add_modifier(Modifier::CROSSED_OUT);
}
style
}
}
/// Преобразует текст с entities в вектор стилизованных Span (owned)
fn format_text_with_entities(
text: &str,
entities: &[TextEntity],
base_color: Color,
) -> Vec<Span<'static>> {
if entities.is_empty() {
return vec![Span::styled(
text.to_string(),
Style::default().fg(base_color),
)];
}
// Создаём массив стилей для каждого символа
let chars: Vec<char> = text.chars().collect();
let mut char_styles: Vec<CharStyle> = vec![CharStyle::default(); chars.len()];
// Применяем entities к символам
for entity in entities {
let start = entity.offset as usize;
let end = (entity.offset + entity.length) as usize;
for i in start..end.min(chars.len()) {
match &entity.r#type {
TextEntityType::Bold => char_styles[i].bold = true,
TextEntityType::Italic => char_styles[i].italic = true,
TextEntityType::Underline => char_styles[i].underline = true,
TextEntityType::Strikethrough => char_styles[i].strikethrough = true,
TextEntityType::Code | TextEntityType::Pre | TextEntityType::PreCode(_) => {
char_styles[i].code = true
}
TextEntityType::Spoiler => char_styles[i].spoiler = true,
TextEntityType::Url
| TextEntityType::TextUrl(_)
| TextEntityType::EmailAddress
| TextEntityType::PhoneNumber => char_styles[i].url = true,
TextEntityType::Mention | TextEntityType::MentionName(_) => {
char_styles[i].mention = true
}
_ => {}
}
}
}
// Группируем последовательные символы с одинаковым стилем
let mut spans: Vec<Span<'static>> = Vec::new();
let mut current_text = String::new();
let mut current_style: Option<CharStyle> = None;
for (i, ch) in chars.iter().enumerate() {
let style = &char_styles[i];
match &current_style {
Some(prev_style) if styles_equal(prev_style, style) => {
current_text.push(*ch);
}
_ => {
if !current_text.is_empty() {
if let Some(prev_style) = &current_style {
spans.push(Span::styled(
current_text.clone(),
prev_style.to_style(base_color),
));
}
}
current_text = ch.to_string();
current_style = Some(style.clone());
}
}
}
// Добавляем последний span
if !current_text.is_empty() {
if let Some(style) = current_style {
spans.push(Span::styled(current_text, style.to_style(base_color)));
}
}
if spans.is_empty() {
spans.push(Span::styled(text.to_string(), Style::default().fg(base_color)));
}
spans
}
/// Проверяет равенство двух стилей
fn styles_equal(a: &CharStyle, b: &CharStyle) -> bool {
a.bold == b.bold
&& a.italic == b.italic
&& a.underline == b.underline
&& a.strikethrough == b.strikethrough
&& a.code == b.code
&& a.spoiler == b.spoiler
&& a.url == b.url
&& a.mention == b.mention
}
/// Рендерит текст инпута с блочным курсором
fn render_input_with_cursor( fn render_input_with_cursor(
prefix: &str, prefix: &str,
text: &str, text: &str,
cursor_pos: usize, cursor_pos: usize,
color: Color, color: Color,
) -> Line<'static> { ) -> Line<'static> {
let chars: Vec<char> = text.chars().collect(); // Используем компонент input_field
let mut spans: Vec<Span> = vec![Span::raw(prefix.to_string())]; components::render_input_field(prefix, text, cursor_pos, color)
// Ограничиваем cursor_pos границами текста
let safe_cursor_pos = cursor_pos.min(chars.len());
// Текст до курсора
if safe_cursor_pos > 0 {
let before: String = chars[..safe_cursor_pos].iter().collect();
spans.push(Span::styled(before, Style::default().fg(color)));
}
// Символ под курсором (или █ если курсор в конце)
if safe_cursor_pos < chars.len() {
let cursor_char = chars[safe_cursor_pos].to_string();
spans.push(Span::styled(cursor_char, Style::default().fg(Color::Black).bg(color)));
} else {
// Курсор в конце - показываем блок
spans.push(Span::styled("", Style::default().fg(color)));
}
// Текст после курсора
if safe_cursor_pos + 1 < chars.len() {
let after: String = chars[safe_cursor_pos + 1..].iter().collect();
spans.push(Span::styled(after, Style::default().fg(color)));
}
Line::from(spans)
} }
/// Информация о строке после переноса: текст и позиция в оригинале /// Информация о строке после переноса: текст и позиция в оригинале
@@ -282,43 +111,6 @@ fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
result result
} }
/// Фильтрует и корректирует entities для подстроки
fn adjust_entities_for_substring(
entities: &[TextEntity],
start: usize,
length: usize,
) -> Vec<TextEntity> {
let start = start as i32;
let end = start + length as i32;
entities
.iter()
.filter_map(|e| {
let e_start = e.offset;
let e_end = e.offset + e.length;
// Проверяем пересечение с нашей подстрокой
if e_end <= start || e_start >= end {
return None;
}
// Вычисляем пересечение
let new_start = (e_start - start).max(0);
let new_end = (e_end - start).min(length as i32);
if new_end > new_start {
Some(TextEntity {
offset: new_start,
length: new_end - new_start,
r#type: e.r#type.clone(),
})
} else {
None
}
})
.collect()
}
pub fn render(f: &mut Frame, area: Rect, app: &App) { pub fn render(f: &mut Frame, area: Rect, app: &App) {
// Режим профиля // Режим профиля
if app.is_profile_mode() { if app.is_profile_mode() {
@@ -615,7 +407,7 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
let line_len = wrapped.text.chars().count(); let line_len = wrapped.text.chars().count();
// Получаем entities для этой строки // Получаем entities для этой строки
let line_entities = adjust_entities_for_substring( let line_entities = formatting::adjust_entities_for_substring(
msg.entities(), msg.entities(),
wrapped.start_offset, wrapped.start_offset,
line_len, line_len,
@@ -623,7 +415,7 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
// Форматируем текст с entities // Форматируем текст с entities
let formatted_spans = let formatted_spans =
format_text_with_entities(&wrapped.text, &line_entities, msg_color); formatting::format_text_with_entities(&wrapped.text, &line_entities, msg_color);
if is_last_line { if is_last_line {
// Последняя строка — добавляем time_mark // Последняя строка — добавляем time_mark
@@ -675,7 +467,7 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
let line_len = wrapped.text.chars().count(); let line_len = wrapped.text.chars().count();
// Получаем entities для этой строки // Получаем entities для этой строки
let line_entities = adjust_entities_for_substring( let line_entities = formatting::adjust_entities_for_substring(
msg.entities(), msg.entities(),
wrapped.start_offset, wrapped.start_offset,
line_len, line_len,
@@ -683,7 +475,7 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
// Форматируем текст с entities // Форматируем текст с entities
let formatted_spans = let formatted_spans =
format_text_with_entities(&wrapped.text, &line_entities, msg_color); formatting::format_text_with_entities(&wrapped.text, &line_entities, msg_color);
if i == 0 { if i == 0 {
// Первая строка — с временем и маркером выбора // Первая строка — с временем и маркером выбора
@@ -717,7 +509,7 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
if !msg.reactions().is_empty() { if !msg.reactions().is_empty() {
let mut reaction_spans = vec![]; let mut reaction_spans = vec![];
for reaction in &msg.reactions() { for reaction in msg.reactions() {
if !reaction_spans.is_empty() { if !reaction_spans.is_empty() {
reaction_spans.push(Span::raw(" ")); reaction_spans.push(Span::raw(" "));
} }
@@ -831,10 +623,10 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
// Режим выбора сообщения - подсказка зависит от возможностей // Режим выбора сообщения - подсказка зависит от возможностей
let selected_msg = app.get_selected_message(); let selected_msg = app.get_selected_message();
let can_edit = selected_msg let can_edit = selected_msg
.map(|m| m.can_be_edited && m.is_outgoing) .map(|m| m.can_be_edited() && m.is_outgoing())
.unwrap_or(false); .unwrap_or(false);
let can_delete = selected_msg let can_delete = selected_msg
.map(|m| m.can_be_deleted_only_for_self || m.can_be_deleted_for_all_users) .map(|m| m.can_be_deleted_only_for_self() || m.can_be_deleted_for_all_users())
.unwrap_or(false); .unwrap_or(false);
let hint = match (can_edit, can_delete) { let hint = match (can_edit, can_delete) {
@@ -872,7 +664,7 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
let reply_preview = app let reply_preview = app
.get_replying_to_message() .get_replying_to_message()
.map(|m| { .map(|m| {
let sender = if m.is_outgoing { let sender = if m.is_outgoing() {
"Вы" "Вы"
} else { } else {
m.sender_name() m.sender_name()
@@ -1336,56 +1128,7 @@ fn render_pinned_mode(f: &mut Frame, area: Rect, app: &App) {
/// Рендерит модалку подтверждения удаления /// Рендерит модалку подтверждения удаления
fn render_delete_confirm_modal(f: &mut Frame, area: Rect) { fn render_delete_confirm_modal(f: &mut Frame, area: Rect) {
use ratatui::widgets::Clear; components::modal::render_delete_confirm_modal(f, area);
// Размеры модалки
let modal_width = 40u16;
let modal_height = 7u16;
// Центрируем модалку
let x = area.x + (area.width.saturating_sub(modal_width)) / 2;
let y = area.y + (area.height.saturating_sub(modal_height)) / 2;
let modal_area = Rect::new(x, y, modal_width.min(area.width), modal_height.min(area.height));
// Очищаем область под модалкой
f.render_widget(Clear, modal_area);
// Содержимое модалки
let text = vec![
Line::from(""),
Line::from(Span::styled(
"Удалить сообщение?",
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(vec![
Span::styled(
" [y/Enter] ",
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
Span::raw("Да"),
Span::raw(" "),
Span::styled(" [n/Esc] ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
Span::raw("Нет"),
]),
];
let modal = Paragraph::new(text)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Red))
.title(" Подтверждение ")
.title_style(Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
)
.alignment(Alignment::Center);
f.render_widget(modal, modal_area);
} }
/// Рендерит модалку выбора реакции /// Рендерит модалку выбора реакции
@@ -1395,88 +1138,5 @@ fn render_reaction_picker_modal(
available_reactions: &[String], available_reactions: &[String],
selected_index: usize, selected_index: usize,
) { ) {
use ratatui::widgets::Clear; components::render_emoji_picker(f, area, available_reactions, selected_index);
// Размеры модалки (зависят от количества реакций)
let emojis_per_row = 8;
let rows = (available_reactions.len() + emojis_per_row - 1) / emojis_per_row;
let modal_width = 50u16;
let modal_height = (rows + 4) as u16; // +4 для заголовка, отступов и подсказки
// Центрируем модалку
let x = area.x + (area.width.saturating_sub(modal_width)) / 2;
let y = area.y + (area.height.saturating_sub(modal_height)) / 2;
let modal_area = Rect::new(x, y, modal_width.min(area.width), modal_height.min(area.height));
// Очищаем область под модалкой
f.render_widget(Clear, modal_area);
// Формируем содержимое - сетка эмодзи
let mut text_lines = vec![Line::from("")]; // Пустая строка сверху
for row in 0..rows {
let mut row_spans = vec![Span::raw(" ")]; // Отступ слева
for col in 0..emojis_per_row {
let idx = row * emojis_per_row + col;
if idx >= available_reactions.len() {
break;
}
let emoji = &available_reactions[idx];
let is_selected = idx == selected_index;
let style = if is_selected {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
.add_modifier(Modifier::REVERSED)
} else {
Style::default().fg(Color::White)
};
row_spans.push(Span::styled(format!(" {} ", emoji), style));
row_spans.push(Span::raw(" ")); // Пробел между эмодзи
}
text_lines.push(Line::from(row_spans));
}
// Добавляем пустую строку и подсказку
text_lines.push(Line::from(""));
text_lines.push(Line::from(vec![
Span::styled(
" [←/→/↑/↓] ",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::raw("Выбор "),
Span::styled(
" [Enter] ",
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
Span::raw("Добавить "),
Span::styled(" [Esc] ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
Span::raw("Отмена"),
]));
let modal = Paragraph::new(text_lines)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow))
.title(" Выбери реакцию ")
.title_style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
)
.alignment(Alignment::Left);
f.render_widget(modal, modal_area);
} }

View File

@@ -1,5 +1,6 @@
mod auth; mod auth;
pub mod chat_list; pub mod chat_list;
pub mod components;
pub mod footer; pub mod footer;
mod loading; mod loading;
mod main_screen; mod main_screen;

View File

@@ -96,12 +96,12 @@ fn format_message_for_test(msg: &tele_tui::tdlib::MessageInfo) -> String {
let mut result = String::new(); let mut result = String::new();
// Добавляем forward контекст если есть // Добавляем forward контекст если есть
if let Some(forward) = &msg.forward_from { if let Some(forward) = &msg.forward_from() {
result.push_str(&format!("↪ Переслано от {}\n", forward.sender_name)); result.push_str(&format!("↪ Переслано от {}\n", forward.sender_name));
} }
// Добавляем reply контекст если есть // Добавляем reply контекст если есть
if let Some(reply) = &msg.reply_to { if let Some(reply) = &msg.reply_to() {
result.push_str(&format!("{}: {}\n", reply.sender_name, reply.text)); result.push_str(&format!("{}: {}\n", reply.sender_name, reply.text));
} }

View File

@@ -4,6 +4,7 @@ mod helpers;
use helpers::fake_tdclient::FakeTdClient; use helpers::fake_tdclient::FakeTdClient;
use helpers::test_data::TestMessageBuilder; use helpers::test_data::TestMessageBuilder;
use tele_tui::types::MessageId;
/// Test: Удаление сообщения убирает его из списка /// Test: Удаление сообщения убирает его из списка
#[test] #[test]
@@ -51,8 +52,8 @@ fn test_delete_multiple_messages() {
// Проверяем что осталось только второе сообщение // Проверяем что осталось только второе сообщение
let messages = client.get_messages(123); let messages = client.get_messages(123);
assert_eq!(messages.len(), 1); assert_eq!(messages.len(), 1);
assert_eq!(messages[0].id, msg2_id); assert_eq!(messages[0].id(), MessageId::new(msg2_id));
assert_eq!(messages[0].content.text(), "Message 2"); assert_eq!(messages[0].content.text, "Message 2");
} }
/// Test: Удаление только своих сообщений (проверка через can_be_deleted_for_all_users) /// Test: Удаление только своих сообщений (проверка через can_be_deleted_for_all_users)
@@ -74,12 +75,12 @@ fn test_can_only_delete_own_messages_for_all() {
// Проверяем флаги удаления // Проверяем флаги удаления
let messages = client.get_messages(123); let messages = client.get_messages(123);
assert_eq!(messages[0].can_be_deleted_for_all_users, true); // Наше assert_eq!(messages[0].can_be_deleted_for_all_users(), true); // Наше
assert_eq!(messages[1].can_be_deleted_for_all_users, false); // Чужое assert_eq!(messages[1].can_be_deleted_for_all_users(), false); // Чужое
// Оба можно удалить для себя // Оба можно удалить для себя
assert_eq!(messages[0].can_be_deleted_only_for_self, true); assert_eq!(messages[0].can_be_deleted_only_for_self(), true);
assert_eq!(messages[1].can_be_deleted_only_for_self, true); assert_eq!(messages[1].can_be_deleted_only_for_self(), true);
} }
/// Test: Удаление несуществующего сообщения (ничего не происходит) /// Test: Удаление несуществующего сообщения (ничего не происходит)
@@ -102,7 +103,7 @@ fn test_delete_nonexistent_message() {
// Но существующее сообщение осталось // Но существующее сообщение осталось
let messages = client.get_messages(123); let messages = client.get_messages(123);
assert_eq!(messages.len(), 1); assert_eq!(messages.len(), 1);
assert_eq!(messages[0].id, msg_id); assert_eq!(messages[0].id(), MessageId::new(msg_id));
} }
/// Test: Подтверждение удаления (симуляция модалки) /// Test: Подтверждение удаления (симуляция модалки)
@@ -144,6 +145,6 @@ fn test_cancel_delete_keeps_message() {
// Сообщение на месте // Сообщение на месте
let messages = client.get_messages(123); let messages = client.get_messages(123);
assert_eq!(messages[0].id, msg_id); assert_eq!(messages[0].id(), MessageId::new(msg_id));
assert_eq!(messages[0].content.text(), "Keep me"); assert_eq!(messages[0].content.text, "Keep me");
} }

View File

@@ -24,7 +24,7 @@ fn test_edit_message_changes_text() {
// Проверяем что текст сообщения изменился // Проверяем что текст сообщения изменился
let messages = client.get_messages(123); let messages = client.get_messages(123);
assert_eq!(messages.len(), 1); assert_eq!(messages.len(), 1);
assert_eq!(messages[0].content.text(), "Edited text"); assert_eq!(messages[0].content.text, "Edited text");
} }
/// Test: Редактирование устанавливает edit_date /// Test: Редактирование устанавливает edit_date
@@ -37,16 +37,16 @@ fn test_edit_message_sets_edit_date() {
// Получаем дату до редактирования // Получаем дату до редактирования
let messages_before = client.get_messages(123); let messages_before = client.get_messages(123);
let date_before = messages_before[0].date; let date_before = messages_before[0].date();
assert_eq!(messages_before[0].edit_date, 0); // Не редактировалось assert_eq!(messages_before[0].edit_date(), 0); // Не редактировалось
// Редактируем сообщение // Редактируем сообщение
client.edit_message(123, msg_id, "Edited".to_string()); client.edit_message(123, msg_id, "Edited".to_string());
// Проверяем что edit_date установлена // Проверяем что edit_date установлена
let messages_after = client.get_messages(123); let messages_after = client.get_messages(123);
assert!(messages_after[0].edit_date > 0); assert!(messages_after[0].edit_date() > 0);
assert!(messages_after[0].edit_date > date_before); // edit_date после date assert!(messages_after[0].edit_date() > date_before); // edit_date после date
} }
/// Test: Редактирование только своих сообщений (проверка через can_be_edited) /// Test: Редактирование только своих сообщений (проверка через can_be_edited)
@@ -68,8 +68,8 @@ fn test_can_only_edit_own_messages() {
// Проверяем флаги // Проверяем флаги
let messages = client.get_messages(123); let messages = client.get_messages(123);
assert_eq!(messages[0].can_be_edited, true); // Наше сообщение assert_eq!(messages[0].can_be_edited(), true); // Наше сообщение
assert_eq!(messages[1].can_be_edited, false); // Чужое сообщение assert_eq!(messages[1].can_be_edited(), false); // Чужое сообщение
} }
/// Test: Множественные редактирования одного сообщения /// Test: Множественные редактирования одного сообщения
@@ -97,7 +97,7 @@ fn test_multiple_edits_of_same_message() {
// Проверяем что сообщение содержит последнюю версию // Проверяем что сообщение содержит последнюю версию
let messages = client.get_messages(123); let messages = client.get_messages(123);
assert_eq!(messages.len(), 1); assert_eq!(messages.len(), 1);
assert_eq!(messages[0].content.text(), "Final version"); assert_eq!(messages[0].content.text, "Final version");
} }
/// Test: Редактирование несуществующего сообщения (ничего не происходит) /// Test: Редактирование несуществующего сообщения (ничего не происходит)
@@ -136,14 +136,14 @@ fn test_edit_history_tracking() {
// Проверяем что изменилось // Проверяем что изменилось
let messages_edited = client.get_messages(123); let messages_edited = client.get_messages(123);
assert_eq!(messages_edited[0].content.text(), "Edited"); assert_eq!(messages_edited[0].content.text, "Edited");
// Можем "отменить" редактирование вернув original // Можем "отменить" редактирование вернув original
client.edit_message(123, msg_id, original); client.edit_message(123, msg_id, original);
// Проверяем что вернулось // Проверяем что вернулось
let messages_restored = client.get_messages(123); let messages_restored = client.get_messages(123);
assert_eq!(messages_restored[0].content.text(), "Original"); assert_eq!(messages_restored[0].content.text, "Original");
// История показывает 2 редактирования // История показывает 2 редактирования
assert_eq!(client.edited_messages().len(), 2); assert_eq!(client.edited_messages().len(), 2);

View File

@@ -312,7 +312,7 @@ mod tests {
.selected_chat(123) .selected_chat(123)
.build(); .build();
assert_eq!(app.selected_chat_id, Some(123)); assert_eq!(app.selected_chat_id, Some(ChatId::new(123)));
} }
#[test] #[test]
@@ -323,7 +323,7 @@ mod tests {
.build(); .build();
assert!(app.is_editing()); assert!(app.is_editing());
assert_eq!(app.chat_state.selected_message_id(), Some(999)); assert_eq!(app.chat_state.selected_message_id(), Some(MessageId::new(999)));
assert_eq!(app.message_input, "Edited text"); assert_eq!(app.message_input, "Edited text");
} }

View File

@@ -2,6 +2,7 @@
use std::collections::HashMap; use std::collections::HashMap;
use tele_tui::tdlib::{ChatInfo, FolderInfo, MessageInfo, NetworkState}; use tele_tui::tdlib::{ChatInfo, FolderInfo, MessageInfo, NetworkState};
use tele_tui::types::MessageId;
/// Упрощённый mock TDLib клиента для тестов /// Упрощённый mock TDLib клиента для тестов
#[derive(Clone)] #[derive(Clone)]
@@ -124,22 +125,22 @@ impl FakeTdClient {
.push(SentMessage { chat_id, text: text.clone(), reply_to }); .push(SentMessage { chat_id, text: text.clone(), reply_to });
// Добавляем сообщение в список сообщений чата // Добавляем сообщение в список сообщений чата
let message = MessageInfo { let message = MessageInfo::new(
id: message_id, MessageId::new(message_id),
sender_name: "You".to_string(), "You".to_string(),
is_outgoing: true, true, // is_outgoing
content: text, text,
entities: vec![], vec![], // entities
date: 1640000000, 1640000000, // date
edit_date: 0, 0, // edit_date
is_read: true, true, // is_read
can_be_edited: true, true, // can_be_edited
can_be_deleted_only_for_self: true, true, // can_be_deleted_only_for_self
can_be_deleted_for_all_users: true, true, // can_be_deleted_for_all_users
reply_to: None, None, // reply_to
forward_from: None, None, // forward_from
reactions: vec![], vec![], // reactions
}; );
self.messages self.messages
.entry(chat_id) .entry(chat_id)
@@ -156,7 +157,7 @@ impl FakeTdClient {
// Обновляем сообщение в списке // Обновляем сообщение в списке
if let Some(messages) = self.messages.get_mut(&chat_id) { if let Some(messages) = self.messages.get_mut(&chat_id) {
if let Some(msg) = messages.iter_mut().find(|m| m.id() == message_id) { if let Some(msg) = messages.iter_mut().find(|m| m.id().as_i64() == message_id) {
msg.content.text = new_text; msg.content.text = new_text;
msg.metadata.edit_date = msg.metadata.date + 60; msg.metadata.edit_date = msg.metadata.date + 60;
} }
@@ -169,7 +170,7 @@ impl FakeTdClient {
// Удаляем сообщение из списка // Удаляем сообщение из списка
if let Some(messages) = self.messages.get_mut(&chat_id) { if let Some(messages) = self.messages.get_mut(&chat_id) {
messages.retain(|m| m.id != message_id); messages.retain(|m| m.id().as_i64() != message_id);
} }
} }
@@ -238,7 +239,7 @@ mod tests {
assert_eq!(client.sent_messages().len(), 1); assert_eq!(client.sent_messages().len(), 1);
assert_eq!(client.sent_messages()[0].text, "Hello"); assert_eq!(client.sent_messages()[0].text, "Hello");
assert_eq!(client.get_messages(123).len(), 1); assert_eq!(client.get_messages(123).len(), 1);
assert_eq!(client.get_messages(123)[0].id, msg_id); assert_eq!(client.get_messages(123)[0].id().as_i64(), msg_id);
} }
#[test] #[test]
@@ -248,8 +249,8 @@ mod tests {
client.edit_message(123, msg_id, "Hello World".to_string()); client.edit_message(123, msg_id, "Hello World".to_string());
assert_eq!(client.edited_messages().len(), 1); assert_eq!(client.edited_messages().len(), 1);
assert_eq!(client.get_messages(123)[0].content, "Hello World"); assert_eq!(client.get_messages(123)[0].content.text, "Hello World");
assert!(client.get_messages(123)[0].edit_date > 0); assert!(client.get_messages(123)[0].edit_date() > 0);
} }
#[test] #[test]

View File

@@ -6,6 +6,7 @@ use helpers::app_builder::TestAppBuilder;
use helpers::snapshot_utils::{buffer_to_string, render_to_buffer}; use helpers::snapshot_utils::{buffer_to_string, render_to_buffer};
use helpers::test_data::{create_test_chat, TestChatBuilder, TestMessageBuilder}; use helpers::test_data::{create_test_chat, TestChatBuilder, TestMessageBuilder};
use insta::assert_snapshot; use insta::assert_snapshot;
use tele_tui::types::{ChatId, MessageId};
#[test] #[test]
fn snapshot_empty_chat() { fn snapshot_empty_chat() {
@@ -154,8 +155,8 @@ fn snapshot_outgoing_read() {
.build(); .build();
// Set last_read_outbox to simulate message being read // Set last_read_outbox to simulate message being read
if let Some(chat) = app.chats.iter_mut().find(|c| c.id == 123) { if let Some(chat) = app.chats.iter_mut().find(|c| c.id == ChatId::new(123)) {
chat.last_read_outbox_message_id = 2; chat.last_read_outbox_message_id = MessageId::new(2);
} }
let buffer = render_to_buffer(80, 24, |f| { let buffer = render_to_buffer(80, 24, |f| {

View File

@@ -223,6 +223,6 @@ fn test_load_older_messages_on_scroll_up() {
// Теперь должно быть 15 сообщений // Теперь должно быть 15 сообщений
assert_eq!(client.get_messages(123).len(), 15); assert_eq!(client.get_messages(123).len(), 15);
assert_eq!(client.get_messages(123)[0].content.text(), "Msg 81"); assert_eq!(client.get_messages(123)[0].content.text, "Msg 81");
assert_eq!(client.get_messages(123)[14].content.text(), "Msg 100"); assert_eq!(client.get_messages(123)[14].content.text, "Msg 100");
} }

View File

@@ -5,6 +5,7 @@ mod helpers;
use helpers::fake_tdclient::FakeTdClient; use helpers::fake_tdclient::FakeTdClient;
use helpers::test_data::create_test_chat; use helpers::test_data::create_test_chat;
use tele_tui::tdlib::ProfileInfo; use tele_tui::tdlib::ProfileInfo;
use tele_tui::types::ChatId;
/// Test: Открытие профиля в личном чате (i) /// Test: Открытие профиля в личном чате (i)
#[test] #[test]
@@ -26,7 +27,7 @@ fn test_open_profile_in_private_chat() {
#[test] #[test]
fn test_profile_shows_user_info() { fn test_profile_shows_user_info() {
let profile = ProfileInfo { let profile = ProfileInfo {
chat_id: 123, chat_id: ChatId::new(123),
title: "Alice Johnson".to_string(), title: "Alice Johnson".to_string(),
username: Some("alice".to_string()), username: Some("alice".to_string()),
phone_number: Some("+1234567890".to_string()), phone_number: Some("+1234567890".to_string()),
@@ -49,7 +50,7 @@ fn test_profile_shows_user_info() {
#[test] #[test]
fn test_profile_shows_group_member_count() { fn test_profile_shows_group_member_count() {
let profile = ProfileInfo { let profile = ProfileInfo {
chat_id: 456, chat_id: ChatId::new(456),
title: "Work Team".to_string(), title: "Work Team".to_string(),
username: None, username: None,
phone_number: None, phone_number: None,
@@ -72,7 +73,7 @@ fn test_profile_shows_group_member_count() {
#[test] #[test]
fn test_profile_shows_channel_info() { fn test_profile_shows_channel_info() {
let profile = ProfileInfo { let profile = ProfileInfo {
chat_id: 789, chat_id: ChatId::new(789),
title: "News Channel".to_string(), title: "News Channel".to_string(),
username: Some("news_channel".to_string()), username: Some("news_channel".to_string()),
phone_number: None, phone_number: None,
@@ -107,7 +108,7 @@ fn test_close_profile_with_esc() {
#[test] #[test]
fn test_profile_without_optional_fields() { fn test_profile_without_optional_fields() {
let profile = ProfileInfo { let profile = ProfileInfo {
chat_id: 999, chat_id: ChatId::new(999),
title: "Anonymous User".to_string(), title: "Anonymous User".to_string(),
username: None, username: None,
phone_number: None, phone_number: None,

View File

@@ -37,8 +37,8 @@ fn test_toggle_reaction_removes_it() {
// Проверяем что реакция есть // Проверяем что реакция есть
let messages_before = client.get_messages(123); let messages_before = client.get_messages(123);
assert_eq!(messages_before[0].reactions.len(), 1); assert_eq!(messages_before[0].reactions().len(), 1);
assert_eq!(messages_before[0].reactions[0].is_chosen, true); assert_eq!(messages_before[0].reactions()[0].is_chosen, true);
// Симулируем удаление реакции (в реальном App это toggle) // Симулируем удаление реакции (в реальном App это toggle)
// FakeTdClient просто записывает что реакция была "убрана" // FakeTdClient просто записывает что реакция была "убрана"
@@ -51,7 +51,7 @@ fn test_toggle_reaction_removes_it() {
client.messages.insert(123, vec![msg_after]); client.messages.insert(123, vec![msg_after]);
let messages_after = client.get_messages(123); let messages_after = client.get_messages(123);
assert_eq!(messages_after[0].reactions.len(), 0); assert_eq!(messages_after[0].reactions().len(), 0);
} }
/// Test: Множественные реакции на одно сообщение /// Test: Множественные реакции на одно сообщение
@@ -89,7 +89,7 @@ fn test_reactions_from_multiple_users() {
client = client.with_message(123, msg); client = client.with_message(123, msg);
let messages = client.get_messages(123); let messages = client.get_messages(123);
let reaction = &messages[0].reactions[0]; let reaction = &messages[0].reactions()[0];
assert_eq!(reaction.emoji, "👍"); assert_eq!(reaction.emoji, "👍");
assert_eq!(reaction.count, 3); assert_eq!(reaction.count, 3);
@@ -109,7 +109,7 @@ fn test_own_reaction_is_chosen() {
client = client.with_message(123, msg); client = client.with_message(123, msg);
let messages = client.get_messages(123); let messages = client.get_messages(123);
let reaction = &messages[0].reactions[0]; let reaction = &messages[0].reactions()[0];
assert_eq!(reaction.is_chosen, true); assert_eq!(reaction.is_chosen, true);
// В UI это будет отображаться в рамках: [❤️] // В UI это будет отображаться в рамках: [❤️]
@@ -128,7 +128,7 @@ fn test_other_reaction_not_chosen() {
client = client.with_message(123, msg); client = client.with_message(123, msg);
let messages = client.get_messages(123); let messages = client.get_messages(123);
let reaction = &messages[0].reactions[0]; let reaction = &messages[0].reactions()[0];
assert_eq!(reaction.is_chosen, false); assert_eq!(reaction.is_chosen, false);
// В UI это будет отображаться без рамок: 😂 2 // В UI это будет отображаться без рамок: 😂 2
@@ -154,7 +154,7 @@ fn test_reaction_counter_increases() {
client.messages.insert(123, vec![msg_v2]); client.messages.insert(123, vec![msg_v2]);
let messages = client.get_messages(123); let messages = client.get_messages(123);
assert_eq!(messages[0].reactions[0].count, 5); assert_eq!(messages[0].reactions()[0].count, 5);
} }
/// Test: Обновление реакции - мы добавили свою к существующим /// Test: Обновление реакции - мы добавили свою к существующим
@@ -177,7 +177,7 @@ fn test_update_reaction_we_add_ours() {
client.messages.insert(123, vec![msg_after]); client.messages.insert(123, vec![msg_after]);
let messages = client.get_messages(123); let messages = client.get_messages(123);
let reaction = &messages[0].reactions[0]; let reaction = &messages[0].reactions()[0];
assert_eq!(reaction.count, 3); assert_eq!(reaction.count, 3);
assert_eq!(reaction.is_chosen, true); assert_eq!(reaction.is_chosen, true);
@@ -195,7 +195,7 @@ fn test_single_reaction_shows_only_emoji() {
client = client.with_message(123, msg); client = client.with_message(123, msg);
let messages = client.get_messages(123); let messages = client.get_messages(123);
let reaction = &messages[0].reactions[0]; let reaction = &messages[0].reactions()[0];
assert_eq!(reaction.count, 1); assert_eq!(reaction.count, 1);
// В UI: если count=1, показываем только emoji без цифры // В UI: если count=1, показываем только emoji без цифры
@@ -228,16 +228,16 @@ fn test_reactions_on_multiple_messages() {
let messages = client.get_messages(123); let messages = client.get_messages(123);
// Первое: 1 реакция // Первое: 1 реакция
assert_eq!(messages[0].reactions.len(), 1); assert_eq!(messages[0].reactions().len(), 1);
assert_eq!(messages[0].reactions[0].emoji, "👍"); assert_eq!(messages[0].reactions()[0].emoji, "👍");
// Второе: 1 реакция // Второе: 1 реакция
assert_eq!(messages[1].reactions.len(), 1); assert_eq!(messages[1].reactions().len(), 1);
assert_eq!(messages[1].reactions[0].emoji, "❤️"); assert_eq!(messages[1].reactions()[0].emoji, "❤️");
// Третье: 2 реакции // Третье: 2 реакции
assert_eq!(messages[2].reactions.len(), 2); assert_eq!(messages[2].reactions().len(), 2);
assert_eq!(messages[2].reactions[0].emoji, "😂"); assert_eq!(messages[2].reactions()[0].emoji, "😂");
assert_eq!(messages[2].reactions[1].emoji, "🔥"); assert_eq!(messages[2].reactions()[1].emoji, "🔥");
assert_eq!(messages[2].reactions[1].is_chosen, true); assert_eq!(messages[2].reactions()[1].is_chosen, true);
} }

View File

@@ -5,6 +5,7 @@ mod helpers;
use helpers::fake_tdclient::FakeTdClient; use helpers::fake_tdclient::FakeTdClient;
use helpers::test_data::TestMessageBuilder; use helpers::test_data::TestMessageBuilder;
use tele_tui::tdlib::{ForwardInfo, ReplyInfo}; use tele_tui::tdlib::{ForwardInfo, ReplyInfo};
use tele_tui::types::MessageId;
/// Test: Reply создаёт сообщение с reply_to /// Test: Reply создаёт сообщение с reply_to
#[test] #[test]
@@ -28,8 +29,8 @@ fn test_reply_creates_message_with_reply_to() {
// Проверяем что в списке 2 сообщения // Проверяем что в списке 2 сообщения
let messages = client.get_messages(123); let messages = client.get_messages(123);
assert_eq!(messages.len(), 2); assert_eq!(messages.len(), 2);
assert_eq!(messages[1].id, reply_id); assert_eq!(messages[1].id(), MessageId::new(reply_id));
assert_eq!(messages[1].content.text(), "Answer!"); assert_eq!(messages[1].content.text, "Answer!");
} }
/// Test: Reply отображает превью оригинального сообщения /// Test: Reply отображает превью оригинального сообщения
@@ -48,10 +49,10 @@ fn test_reply_shows_original_preview() {
// Проверяем что reply_to сохранено // Проверяем что reply_to сохранено
let messages = client.get_messages(123); let messages = client.get_messages(123);
assert_eq!(messages.len(), 1); assert_eq!(messages.len(), 1);
assert!(messages[0].reply_to.is_some()); assert!(messages[0].reply_to().is_some());
let reply = messages[0].reply_to.as_ref().unwrap(); let reply = messages[0].reply_to().unwrap();
assert_eq!(reply.message_id, 100); assert_eq!(reply.message_id, MessageId::new(100));
assert_eq!(reply.sender_name, "Alice"); assert_eq!(reply.sender_name, "Alice");
assert_eq!(reply.text, "Original"); assert_eq!(reply.text, "Original");
} }
@@ -76,7 +77,7 @@ fn test_cancel_reply_sends_without_reply_to() {
assert_eq!(client.sent_messages()[0].reply_to, None); assert_eq!(client.sent_messages()[0].reply_to, None);
let messages = client.get_messages(123); let messages = client.get_messages(123);
assert_eq!(messages[1].content.text(), "Regular message"); assert_eq!(messages[1].content.text, "Regular message");
} }
/// Test: Forward создаёт сообщение с forward_from /// Test: Forward создаёт сообщение с forward_from
@@ -94,9 +95,9 @@ fn test_forward_creates_message_with_forward_from() {
// Проверяем что forward_from сохранено // Проверяем что forward_from сохранено
let messages = client.get_messages(456); let messages = client.get_messages(456);
assert_eq!(messages.len(), 1); assert_eq!(messages.len(), 1);
assert!(messages[0].forward_from.is_some()); assert!(messages[0].forward_from().is_some());
let forward = messages[0].forward_from.as_ref().unwrap(); let forward = messages[0].forward_from().unwrap();
assert_eq!(forward.sender_name, "Bob"); assert_eq!(forward.sender_name, "Bob");
assert!(forward.date > 0); // Дата установлена assert!(forward.date > 0); // Дата установлена
} }
@@ -114,7 +115,7 @@ fn test_forward_displays_sender_name() {
client = client.with_message(789, msg); client = client.with_message(789, msg);
let messages = client.get_messages(789); let messages = client.get_messages(789);
let forward = messages[0].forward_from.as_ref().unwrap(); let forward = messages[0].forward_from().unwrap();
// В UI это будет отображаться как "↪ Переслано от Charlie" // В UI это будет отображаться как "↪ Переслано от Charlie"
assert_eq!(forward.sender_name, "Charlie"); assert_eq!(forward.sender_name, "Charlie");
@@ -144,7 +145,7 @@ fn test_forward_to_different_chat() {
// Проверяем что во втором чате тоже 1 сообщение (пересланное) // Проверяем что во втором чате тоже 1 сообщение (пересланное)
assert_eq!(client.get_messages(456).len(), 1); assert_eq!(client.get_messages(456).len(), 1);
assert!(client.get_messages(456)[0].forward_from.is_some()); assert!(client.get_messages(456)[0].forward_from().is_some());
} }
/// Test: Reply + Forward комбинация (ответ на пересланное сообщение) /// Test: Reply + Forward комбинация (ответ на пересланное сообщение)
@@ -167,7 +168,7 @@ fn test_reply_to_forwarded_message() {
let messages = client.get_messages(123); let messages = client.get_messages(123);
assert_eq!(messages.len(), 2); assert_eq!(messages.len(), 2);
assert_eq!(messages[1].id, reply_id); assert_eq!(messages[1].id(), MessageId::new(reply_id));
} }
/// Test: Forward множества сообщений (batch forward) /// Test: Forward множества сообщений (batch forward)
@@ -196,7 +197,7 @@ fn test_forward_multiple_messages() {
// Проверяем что все 3 сообщения пересланы // Проверяем что все 3 сообщения пересланы
let messages = client.get_messages(456); let messages = client.get_messages(456);
assert_eq!(messages.len(), 3); assert_eq!(messages.len(), 3);
assert!(messages[0].forward_from.is_some()); assert!(messages[0].forward_from().is_some());
assert!(messages[1].forward_from.is_some()); assert!(messages[1].forward_from().is_some());
assert!(messages[2].forward_from.is_some()); assert!(messages[2].forward_from().is_some());
} }

View File

@@ -24,9 +24,9 @@ fn test_send_text_message() {
// Проверяем что сообщение добавилось в список // Проверяем что сообщение добавилось в список
let messages = client.get_messages(123); let messages = client.get_messages(123);
assert_eq!(messages.len(), 1); assert_eq!(messages.len(), 1);
assert_eq!(messages[0].id, msg_id); assert_eq!(messages[0].id().as_i64(), msg_id);
assert_eq!(messages.text(), "Hello, Mom!"); assert_eq!(messages[0].text(), "Hello, Mom!");
assert_eq!(messages[0].is_outgoing, true); assert_eq!(messages[0].is_outgoing(), true);
} }
/// Test: Отправка нескольких сообщений обновляет список /// Test: Отправка нескольких сообщений обновляет список
@@ -49,12 +49,12 @@ fn test_send_multiple_messages_updates_list() {
// Проверяем что все сообщения в списке // Проверяем что все сообщения в списке
let messages = client.get_messages(123); let messages = client.get_messages(123);
assert_eq!(messages.len(), 3); assert_eq!(messages.len(), 3);
assert_eq!(messages[0].id, msg1_id); assert_eq!(messages[0].id().as_i64(), msg1_id);
assert_eq!(messages[1].id, msg2_id); assert_eq!(messages[1].id().as_i64(), msg2_id);
assert_eq!(messages[2].id, msg3_id); assert_eq!(messages[2].id().as_i64(), msg3_id);
assert_eq!(messages.text(), "Message 1"); assert_eq!(messages[0].text(), "Message 1");
assert_eq!(messages.text(), "Message 2"); assert_eq!(messages[1].text(), "Message 2");
assert_eq!(messages.text(), "Message 3"); assert_eq!(messages[2].text(), "Message 3");
} }
/// Test: Отправка пустого сообщения (должно быть игнорировано на уровне App) /// Test: Отправка пустого сообщения (должно быть игнорировано на уровне App)
@@ -73,8 +73,8 @@ fn test_send_empty_message_technical() {
let messages = client.get_messages(123); let messages = client.get_messages(123);
assert_eq!(messages.len(), 1); assert_eq!(messages.len(), 1);
assert_eq!(messages[0].id, msg_id); assert_eq!(messages[0].id().as_i64(), msg_id);
assert_eq!(messages.text(), ""); assert_eq!(messages[0].text(), "");
} }
/// Test: Отправка сообщения с форматированием (markdown сущности) /// Test: Отправка сообщения с форматированием (markdown сущности)
@@ -89,7 +89,7 @@ fn test_send_message_with_markdown() {
// Проверяем что текст сохранился как есть (парсинг markdown - отдельная логика) // Проверяем что текст сохранился как есть (парсинг markdown - отдельная логика)
let messages = client.get_messages(123); let messages = client.get_messages(123);
assert_eq!(messages.len(), 1); assert_eq!(messages.len(), 1);
assert_eq!(messages.text(), text); assert_eq!(messages[0].text(), text);
} }
/// Test: Отправка сообщения в разные чаты /// Test: Отправка сообщения в разные чаты
@@ -112,12 +112,12 @@ fn test_send_messages_to_different_chats() {
// Проверяем что сообщения распределены по чатам // Проверяем что сообщения распределены по чатам
let chat123_messages = client.get_messages(123); let chat123_messages = client.get_messages(123);
assert_eq!(chat123_messages.len(), 2); assert_eq!(chat123_messages.len(), 2);
assert_eq!(chat123_messages.text(), "Hello Mom"); assert_eq!(chat123_messages[0].text(), "Hello Mom");
assert_eq!(chat123_messages.text(), "How are you?"); assert_eq!(chat123_messages[1].text(), "How are you?");
let chat456_messages = client.get_messages(456); let chat456_messages = client.get_messages(456);
assert_eq!(chat456_messages.len(), 1); assert_eq!(chat456_messages.len(), 1);
assert_eq!(chat456_messages.text(), "Hello Boss"); assert_eq!(chat456_messages[0].text(), "Hello Boss");
} }
/// Test: Новое сообщение появляется в реальном времени (симуляция) /// Test: Новое сообщение появляется в реальном времени (симуляция)
@@ -139,8 +139,8 @@ fn test_receive_incoming_message() {
// Проверяем что в списке 2 сообщения // Проверяем что в списке 2 сообщения
let messages = client.get_messages(123); let messages = client.get_messages(123);
assert_eq!(messages.len(), 2); assert_eq!(messages.len(), 2);
assert_eq!(messages[0].is_outgoing, true); // Наше сообщение assert_eq!(messages[0].is_outgoing(), true); // Наше сообщение
assert_eq!(messages[1].is_outgoing, false); // Входящее assert_eq!(messages[1].is_outgoing(), false); // Входящее
assert_eq!(messages.text(), "Hey there!"); assert_eq!(messages[1].text(), "Hey there!");
assert_eq!(messages[1].sender_name, "Alice"); assert_eq!(messages[1].sender_name(), "Alice");
} }

View File

@@ -13,7 +13,7 @@ expression: output
│ │ │ │
│ ┌ Выбери реакцию ────────────────────────────────┐ │ │ ┌ Выбери реакцию ────────────────────────────────┐ │
│ │ │ │ │ │ │ │
│ │ 👍 👎 ❤️ 🔥 😊 😢 😮 🎉 │ │ │ │ 👍 👎 ❤️ 🔥 😊 😢 😮 🎉 │ │
│ │ │ │ │ │ │ │
│ └────────────────────────────────────────────────┘ │ │ └────────────────────────────────────────────────┘ │
│ │ │ │

View File

@@ -13,7 +13,7 @@ expression: output
│ │ │ │
│ ┌ Выбери реакцию ────────────────────────────────┐ │ │ ┌ Выбери реакцию ────────────────────────────────┐ │
│ │ │ │ │ │ │ │
│ │ 👍 👎 ❤️ 🔥 😊 😢 😮 🎉 │ │ │ │ 👍 👎 ❤️ 🔥 😊 😢 😮 🎉 │ │
│ │ │ │ │ │ │ │
│ └────────────────────────────────────────────────┘ │ │ └────────────────────────────────────────────────┘ │
│ │ │ │