fixes
This commit is contained in:
25
.serena/memories/code_style.md
Normal file
25
.serena/memories/code_style.md
Normal 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 для документации проекта
|
||||
28
.serena/memories/project_overview.md
Normal file
28
.serena/memories/project_overview.md
Normal 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
|
||||
37
.serena/memories/suggested_commands.md
Normal file
37
.serena/memories/suggested_commands.md
Normal 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.)
|
||||
39
.serena/memories/task_completion.md
Normal file
39
.serena/memories/task_completion.md
Normal 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. [На что обратить внимание]
|
||||
|
||||
Напиши, если что-то не работает.
|
||||
```
|
||||
|
||||
## Важно
|
||||
- Работать поэтапно (один этап = одна логическая единица)
|
||||
- После каждого этапа давать сценарий проверки
|
||||
- Не делать сразу много изменений
|
||||
59
CONTEXT.md
59
CONTEXT.md
@@ -130,9 +130,13 @@ src/
|
||||
├── lib.rs # Библиотечный интерфейс (для тестов)
|
||||
├── types.rs # Типобезопасные обёртки (ChatId, MessageId, UserId)
|
||||
├── 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/
|
||||
│ ├── mod.rs # App структура и состояние (needs_redraw флаг)
|
||||
│ └── state.rs # AppScreen enum
|
||||
│ ├── state.rs # AppScreen enum
|
||||
│ └── chat_state.rs # ChatState enum (Normal, MessageSelection, Editing, etc.)
|
||||
├── ui/
|
||||
│ ├── mod.rs # Роутинг UI по экранам, проверка минимального размера
|
||||
│ ├── loading.rs # Экран загрузки
|
||||
@@ -140,7 +144,15 @@ src/
|
||||
│ ├── main_screen.rs # Главный экран с папками
|
||||
│ ├── chat_list.rs # Список чатов (pin, mute, online, mentions)
|
||||
│ ├── 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/
|
||||
│ ├── mod.rs # Роутинг ввода
|
||||
│ ├── auth.rs # Обработка ввода на экране авторизации
|
||||
@@ -154,7 +166,7 @@ src/
|
||||
├── messages.rs # MessageManager для сообщений
|
||||
├── users.rs # UserCache с LRU кэшем
|
||||
├── reactions.rs # ReactionManager
|
||||
└── types.rs # Общие типы данных (ChatInfo, MessageInfo, etc.)
|
||||
└── types.rs # Общие типы данных (ChatInfo, MessageInfo, MessageBuilder, etc.)
|
||||
|
||||
tests/
|
||||
├── helpers/
|
||||
@@ -299,6 +311,35 @@ reaction_other = "gray"
|
||||
|
||||
## Последние обновления (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%! 🎊🎉
|
||||
|
||||
**P2.7 — MessageBuilder pattern** ✅ ФИНАЛЬНАЯ ЗАДАЧА ЗАВЕРШЕНА!
|
||||
@@ -548,12 +589,14 @@ let message = MessageBuilder::new(MessageId::new(123))
|
||||
4. ~~**MessageInfo реструктуризация**~~ ✅ — группировка полей в логические структуры (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):
|
||||
1. **UI компоненты** — выделение переиспользуемых компонентов
|
||||
2. **MessageBuilder** — упрощение создания сообщений
|
||||
3. **UI компоненты** — выделить переиспользуемые компоненты
|
||||
4. **Форматирование** — вынести markdown форматирование в отдельный модуль
|
||||
5. **Юнит-тесты** — добавить для utils и других модулей
|
||||
1. **P3.9 — Группировка сообщений** — вынести логику группировки в отдельный модуль
|
||||
2. **P3.10 — Hotkey mapping** — добавить настройку хоткеев в конфиг
|
||||
3. **Юнит-тесты** — добавить для utils и других модулей
|
||||
|
||||
## Известные проблемы
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ pub use chat_state::ChatState;
|
||||
pub use state::AppScreen;
|
||||
|
||||
use crate::tdlib::{ChatInfo, TdClient};
|
||||
use crate::types::ChatId;
|
||||
use crate::types::{ChatId, MessageId};
|
||||
use ratatui::widgets::ListState;
|
||||
|
||||
pub struct App {
|
||||
@@ -189,7 +189,7 @@ impl App {
|
||||
// Сначала извлекаем данные из сообщения
|
||||
let msg_data = self.get_selected_message().and_then(|msg| {
|
||||
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 {
|
||||
None
|
||||
}
|
||||
@@ -226,7 +226,7 @@ impl App {
|
||||
}
|
||||
|
||||
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> {
|
||||
@@ -451,7 +451,7 @@ impl App {
|
||||
|
||||
/// Получить ID текущего pinned для перехода в историю
|
||||
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 ===
|
||||
@@ -522,7 +522,7 @@ impl App {
|
||||
|
||||
/// Получить ID выбранного результата для перехода
|
||||
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>,
|
||||
) {
|
||||
self.chat_state = ChatState::ReactionPicker {
|
||||
message_id,
|
||||
message_id: MessageId::new(message_id),
|
||||
available_reactions,
|
||||
selected_index: 0,
|
||||
};
|
||||
@@ -748,6 +748,6 @@ impl App {
|
||||
}
|
||||
|
||||
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
285
src/formatting.rs
Normal 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 ¤t_style {
|
||||
Some(prev_style) if styles_equal(prev_style, style) => {
|
||||
current_text.push(*ch);
|
||||
}
|
||||
_ => {
|
||||
if !current_text.is_empty() {
|
||||
if let Some(prev_style) = ¤t_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); // Нет пересечений
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
|
||||
app.status_message = Some("Загрузка закреплённых...".to_string());
|
||||
match timeout(
|
||||
Duration::from_secs(5),
|
||||
app.td_client.get_pinned_messages(chat_id),
|
||||
app.td_client.get_pinned_messages(ChatId::new(chat_id)),
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -212,7 +212,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
|
||||
if !query.is_empty() {
|
||||
if let Ok(Ok(results)) = timeout(
|
||||
Duration::from_secs(3),
|
||||
app.td_client.search_messages(chat_id, &query),
|
||||
app.td_client.search_messages(ChatId::new(chat_id), &query),
|
||||
)
|
||||
.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 Ok(Ok(results)) = timeout(
|
||||
Duration::from_secs(3),
|
||||
app.td_client.search_messages(chat_id, &query),
|
||||
app.td_client.search_messages(ChatId::new(chat_id), &query),
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -388,7 +388,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
|
||||
match timeout(
|
||||
Duration::from_secs(5),
|
||||
app.td_client.delete_messages(
|
||||
chat_id,
|
||||
ChatId::new(chat_id),
|
||||
vec![msg_id],
|
||||
can_delete_for_all,
|
||||
),
|
||||
@@ -442,7 +442,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
|
||||
Duration::from_secs(5),
|
||||
app.td_client.forward_messages(
|
||||
to_chat_id,
|
||||
from_chat_id,
|
||||
ChatId::new(from_chat_id),
|
||||
vec![msg_id],
|
||||
),
|
||||
)
|
||||
@@ -490,7 +490,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
|
||||
app.message_scroll_offset = 0;
|
||||
match timeout(
|
||||
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
|
||||
{
|
||||
@@ -504,7 +504,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
|
||||
// Загружаем последнее закреплённое сообщение
|
||||
let _ = timeout(
|
||||
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;
|
||||
// Загружаем черновик
|
||||
@@ -572,7 +572,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
|
||||
|
||||
match timeout(
|
||||
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
|
||||
{
|
||||
@@ -622,13 +622,13 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
|
||||
|
||||
// Отменяем typing status
|
||||
app.td_client
|
||||
.send_chat_action(chat_id, ChatAction::Cancel)
|
||||
.send_chat_action(ChatId::new(chat_id), ChatAction::Cancel)
|
||||
.await;
|
||||
|
||||
match timeout(
|
||||
Duration::from_secs(5),
|
||||
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
|
||||
{
|
||||
@@ -659,7 +659,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
|
||||
app.message_scroll_offset = 0;
|
||||
match timeout(
|
||||
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
|
||||
{
|
||||
@@ -673,7 +673,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
|
||||
// Загружаем последнее закреплённое сообщение
|
||||
let _ = timeout(
|
||||
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;
|
||||
// Загружаем черновик
|
||||
@@ -795,7 +795,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
|
||||
app.status_message = None;
|
||||
app.needs_redraw = true;
|
||||
} else {
|
||||
app.enter_reaction_picker_mode(message_id, reactions);
|
||||
app.enter_reaction_picker_mode(message_id.as_i64(), reactions);
|
||||
app.status_message = None;
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
@@ -894,7 +894,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
|
||||
if should_send_typing {
|
||||
if let Some(chat_id) = app.get_selected_chat_id() {
|
||||
app.td_client
|
||||
.send_chat_action(chat_id, ChatAction::Typing)
|
||||
.send_chat_action(ChatId::new(chat_id), ChatAction::Typing)
|
||||
.await;
|
||||
app.last_typing_sent = Some(Instant::now());
|
||||
}
|
||||
@@ -943,7 +943,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
|
||||
.current_chat_messages()
|
||||
.first()
|
||||
.map(|m| m.id())
|
||||
.unwrap_or(0);
|
||||
.unwrap_or(MessageId::new(0));
|
||||
if let Some(chat_id) = app.get_selected_chat_id() {
|
||||
// Подгружаем больше сообщений если скролл близко к верху
|
||||
if app.message_scroll_offset
|
||||
@@ -952,7 +952,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
|
||||
if let Ok(Ok(older)) = timeout(
|
||||
Duration::from_secs(3),
|
||||
app.td_client
|
||||
.load_older_messages(chat_id, oldest_msg_id),
|
||||
.load_older_messages(ChatId::new(chat_id), oldest_msg_id),
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -1041,12 +1041,12 @@ fn format_message_for_clipboard(msg: &crate::tdlib::MessageInfo) -> String {
|
||||
let mut result = String::new();
|
||||
|
||||
// Добавляем forward контекст если есть
|
||||
if let Some(forward) = &msg.forward_from {
|
||||
if let Some(forward) = msg.forward_from() {
|
||||
result.push_str(&format!("↪ Переслано от {}\n", forward.sender_name));
|
||||
}
|
||||
|
||||
// Добавляем 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));
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ pub mod app;
|
||||
pub mod config;
|
||||
pub mod constants;
|
||||
pub mod error;
|
||||
pub mod formatting;
|
||||
pub mod input;
|
||||
pub mod tdlib;
|
||||
pub mod types;
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
mod app;
|
||||
mod config;
|
||||
mod constants;
|
||||
mod error;
|
||||
mod formatting;
|
||||
mod input;
|
||||
mod tdlib;
|
||||
mod types;
|
||||
mod ui;
|
||||
mod utils;
|
||||
|
||||
|
||||
@@ -292,27 +292,27 @@ impl TdClient {
|
||||
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()
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
pub fn pending_user_ids(&self) -> &[i64] {
|
||||
pub fn pending_user_ids(&self) -> &[crate::types::UserId] {
|
||||
&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
|
||||
}
|
||||
|
||||
@@ -470,8 +470,8 @@ impl TdClient {
|
||||
let chat_id = ChatId::new(new_msg.message.chat_id);
|
||||
if Some(chat_id) == self.current_chat_id() {
|
||||
let msg_info = self.convert_message(&new_msg.message, chat_id);
|
||||
let msg_id = msg_info.id;
|
||||
let is_incoming = !msg_info.is_outgoing;
|
||||
let msg_id = msg_info.id();
|
||||
let is_incoming = !msg_info.is_outgoing();
|
||||
|
||||
// Проверяем, есть ли уже сообщение с таким id
|
||||
let existing_idx = self
|
||||
@@ -488,12 +488,12 @@ impl TdClient {
|
||||
// Для исходящих: обновляем can_be_edited и другие поля,
|
||||
// но сохраняем reply_to (добавленный при отправке)
|
||||
let existing = &mut self.current_chat_messages_mut()[idx];
|
||||
existing.can_be_edited = msg_info.can_be_edited;
|
||||
existing.can_be_deleted_only_for_self =
|
||||
msg_info.can_be_deleted_only_for_self;
|
||||
existing.can_be_deleted_for_all_users =
|
||||
msg_info.can_be_deleted_for_all_users;
|
||||
existing.is_read = msg_info.is_read;
|
||||
existing.state.can_be_edited = msg_info.state.can_be_edited;
|
||||
existing.state.can_be_deleted_only_for_self =
|
||||
msg_info.state.can_be_deleted_only_for_self;
|
||||
existing.state.can_be_deleted_for_all_users =
|
||||
msg_info.state.can_be_deleted_for_all_users;
|
||||
existing.state.is_read = msg_info.state.is_read;
|
||||
}
|
||||
}
|
||||
None => {
|
||||
@@ -518,7 +518,7 @@ impl TdClient {
|
||||
// Clone chat_user_ids to avoid borrow conflict
|
||||
let chat_user_ids = self.user_cache.chat_user_ids.clone();
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -528,15 +528,15 @@ impl TdClient {
|
||||
} else {
|
||||
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 если есть
|
||||
if let Some(usernames) = user.usernames {
|
||||
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 в чатах, связанных с этим пользователем
|
||||
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)
|
||||
{
|
||||
chat.username = Some(format!("@{}", username));
|
||||
@@ -991,20 +991,20 @@ impl TdClient {
|
||||
match origin {
|
||||
MessageOrigin::User(u) => self
|
||||
.user_cache.user_names
|
||||
.peek(&u.sender_user_id)
|
||||
.peek(&UserId::new(u.sender_user_id))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| format!("User_{}", u.sender_user_id)),
|
||||
MessageOrigin::Chat(c) => self
|
||||
.chats()
|
||||
.iter()
|
||||
.find(|chat| chat.id == c.sender_chat_id)
|
||||
.find(|chat| chat.id == ChatId::new(c.sender_chat_id))
|
||||
.map(|chat| chat.title.clone())
|
||||
.unwrap_or_else(|| "Чат".to_string()),
|
||||
MessageOrigin::HiddenUser(h) => h.sender_name.clone(),
|
||||
MessageOrigin::Channel(c) => self
|
||||
.chats()
|
||||
.iter()
|
||||
.find(|chat| chat.id == c.chat_id)
|
||||
.find(|chat| chat.id == ChatId::new(c.chat_id))
|
||||
.map(|chat| chat.title.clone())
|
||||
.unwrap_or_else(|| "Канал".to_string()),
|
||||
}
|
||||
@@ -1017,15 +1017,15 @@ impl TdClient {
|
||||
let msg_data: std::collections::HashMap<i64, (String, String)> = self
|
||||
.current_chat_messages()
|
||||
.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();
|
||||
|
||||
// Обновляем reply_to для сообщений с неполными данными
|
||||
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 пустой — пробуем заполнить
|
||||
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 == "..." {
|
||||
reply.sender_name = sender.clone();
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ use tdlib_rs::enums::{ChatAction, InputMessageContent, InputMessageReplyTo, Mess
|
||||
use tdlib_rs::functions;
|
||||
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 {
|
||||
@@ -375,7 +375,8 @@ impl MessageManager {
|
||||
let batch = std::mem::take(&mut self.pending_view_messages);
|
||||
|
||||
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 {
|
||||
// Здесь можно загрузить информацию об оригинальном сообщении
|
||||
Some(ReplyInfo {
|
||||
message_id: reply_msg.message_id,
|
||||
message_id: MessageId::new(reply_msg.message_id),
|
||||
sender_name: "Unknown".to_string(),
|
||||
text: "...".to_string(),
|
||||
})
|
||||
@@ -488,22 +489,48 @@ impl MessageManager {
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
Some(MessageInfo {
|
||||
id: msg.id,
|
||||
sender_name,
|
||||
is_outgoing: msg.is_outgoing,
|
||||
content: content_text,
|
||||
entities,
|
||||
date: msg.date,
|
||||
edit_date: msg.edit_date,
|
||||
is_read: !msg.contains_unread_mention,
|
||||
can_be_edited: msg.can_be_edited,
|
||||
can_be_deleted_only_for_self: msg.can_be_deleted_only_for_self,
|
||||
can_be_deleted_for_all_users: msg.can_be_deleted_for_all_users,
|
||||
reply_to,
|
||||
forward_from,
|
||||
reactions,
|
||||
})
|
||||
let mut builder = MessageBuilder::new(MessageId::new(msg.id))
|
||||
.sender_name(sender_name)
|
||||
.text(content_text)
|
||||
.entities(entities)
|
||||
.date(msg.date)
|
||||
.edit_date(msg.edit_date);
|
||||
|
||||
if msg.is_outgoing {
|
||||
builder = builder.outgoing();
|
||||
} else {
|
||||
builder = builder.incoming();
|
||||
}
|
||||
|
||||
if !msg.contains_unread_mention {
|
||||
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 информацию для сообщений
|
||||
@@ -511,7 +538,7 @@ impl MessageManager {
|
||||
// Collect message IDs that need to be fetched
|
||||
let mut to_fetch = Vec::new();
|
||||
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" {
|
||||
to_fetch.push(reply.message_id);
|
||||
}
|
||||
@@ -522,17 +549,18 @@ impl MessageManager {
|
||||
if let Some(chat_id) = self.current_chat_id {
|
||||
for message_id in to_fetch {
|
||||
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 Some(orig_info) = self.convert_message(&original_msg).await {
|
||||
// Update the reply info
|
||||
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 {
|
||||
reply.sender_name = orig_info.sender_name.clone();
|
||||
reply.sender_name = orig_info.metadata.sender_name.clone();
|
||||
reply.text = orig_info
|
||||
.content
|
||||
.text
|
||||
.chars()
|
||||
.take(50)
|
||||
.collect::<String>();
|
||||
|
||||
@@ -68,7 +68,7 @@ pub struct MessageMetadata {
|
||||
}
|
||||
|
||||
/// Контент сообщения (текст и форматирование)
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct MessageContent {
|
||||
pub text: String,
|
||||
/// Сущности форматирования (bold, italic, code и т.д.)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -181,7 +181,7 @@ impl UserCache {
|
||||
}
|
||||
|
||||
// Берём первые N user_ids для загрузки
|
||||
let batch: Vec<i64> = self
|
||||
let batch: Vec<UserId> = self
|
||||
.pending_user_ids
|
||||
.drain(..self.pending_user_ids.len().min(LAZY_LOAD_USERS_PER_TICK))
|
||||
.collect();
|
||||
@@ -191,7 +191,7 @@ impl UserCache {
|
||||
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) => {
|
||||
self.handle_user_update(&user_enum);
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ impl fmt::Display for ChatId {
|
||||
}
|
||||
|
||||
/// 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);
|
||||
|
||||
impl MessageId {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::app::App;
|
||||
use crate::tdlib::UserOnlineStatus;
|
||||
use crate::ui::components;
|
||||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Modifier, Style},
|
||||
@@ -43,63 +44,8 @@ pub fn render(f: &mut Frame, area: Rect, app: &mut App) {
|
||||
.iter()
|
||||
.map(|chat| {
|
||||
let is_selected = app.selected_chat_id == Some(chat.id);
|
||||
let pin_icon = if chat.is_pinned { "📌 " } else { "" };
|
||||
let mute_icon = if chat.is_muted { "🔇 " } else { "" };
|
||||
|
||||
// Онлайн-статус (зелёная точка для онлайн)
|
||||
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)
|
||||
let user_status = app.td_client.get_user_status_by_chat_id(chat.id);
|
||||
components::render_chat_list_item(chat, is_selected, user_status)
|
||||
})
|
||||
.collect();
|
||||
|
||||
|
||||
78
src/ui/components/chat_list_item.rs
Normal file
78
src/ui/components/chat_list_item.rs
Normal 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)
|
||||
}
|
||||
112
src/ui/components/emoji_picker.rs
Normal file
112
src/ui/components/emoji_picker.rs
Normal 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);
|
||||
}
|
||||
53
src/ui/components/input_field.rs
Normal file
53
src/ui/components/input_field.rs
Normal 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)
|
||||
}
|
||||
26
src/ui/components/message_bubble.rs
Normal file
26
src/ui/components/message_bubble.rs
Normal 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
14
src/ui/components/mod.rs
Normal 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;
|
||||
86
src/ui/components/modal.rs
Normal file
86
src/ui/components/modal.rs
Normal 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);
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
use crate::app::App;
|
||||
use crate::formatting;
|
||||
use crate::ui::components;
|
||||
use crate::utils::{format_date, format_timestamp_with_tz, get_day};
|
||||
use ratatui::{
|
||||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||
@@ -7,188 +9,15 @@ use ratatui::{
|
||||
widgets::{Block, Borders, Paragraph},
|
||||
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 ¤t_style {
|
||||
Some(prev_style) if styles_equal(prev_style, style) => {
|
||||
current_text.push(*ch);
|
||||
}
|
||||
_ => {
|
||||
if !current_text.is_empty() {
|
||||
if let Some(prev_style) = ¤t_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(
|
||||
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)
|
||||
// Используем компонент input_field
|
||||
components::render_input_field(prefix, text, cursor_pos, color)
|
||||
}
|
||||
|
||||
/// Информация о строке после переноса: текст и позиция в оригинале
|
||||
@@ -282,43 +111,6 @@ fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
|
||||
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) {
|
||||
// Режим профиля
|
||||
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();
|
||||
|
||||
// Получаем entities для этой строки
|
||||
let line_entities = adjust_entities_for_substring(
|
||||
let line_entities = formatting::adjust_entities_for_substring(
|
||||
msg.entities(),
|
||||
wrapped.start_offset,
|
||||
line_len,
|
||||
@@ -623,7 +415,7 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
|
||||
|
||||
// Форматируем текст с entities
|
||||
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 {
|
||||
// Последняя строка — добавляем time_mark
|
||||
@@ -675,7 +467,7 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
|
||||
let line_len = wrapped.text.chars().count();
|
||||
|
||||
// Получаем entities для этой строки
|
||||
let line_entities = adjust_entities_for_substring(
|
||||
let line_entities = formatting::adjust_entities_for_substring(
|
||||
msg.entities(),
|
||||
wrapped.start_offset,
|
||||
line_len,
|
||||
@@ -683,7 +475,7 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
|
||||
|
||||
// Форматируем текст с entities
|
||||
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 {
|
||||
// Первая строка — с временем и маркером выбора
|
||||
@@ -717,7 +509,7 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
|
||||
if !msg.reactions().is_empty() {
|
||||
let mut reaction_spans = vec![];
|
||||
|
||||
for reaction in &msg.reactions() {
|
||||
for reaction in msg.reactions() {
|
||||
if !reaction_spans.is_empty() {
|
||||
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 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);
|
||||
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);
|
||||
|
||||
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
|
||||
.get_replying_to_message()
|
||||
.map(|m| {
|
||||
let sender = if m.is_outgoing {
|
||||
let sender = if m.is_outgoing() {
|
||||
"Вы"
|
||||
} else {
|
||||
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) {
|
||||
use ratatui::widgets::Clear;
|
||||
|
||||
// Размеры модалки
|
||||
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);
|
||||
components::modal::render_delete_confirm_modal(f, area);
|
||||
}
|
||||
|
||||
/// Рендерит модалку выбора реакции
|
||||
@@ -1395,88 +1138,5 @@ fn render_reaction_picker_modal(
|
||||
available_reactions: &[String],
|
||||
selected_index: usize,
|
||||
) {
|
||||
use ratatui::widgets::Clear;
|
||||
|
||||
// Размеры модалки (зависят от количества реакций)
|
||||
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);
|
||||
components::render_emoji_picker(f, area, available_reactions, selected_index);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
mod auth;
|
||||
pub mod chat_list;
|
||||
pub mod components;
|
||||
pub mod footer;
|
||||
mod loading;
|
||||
mod main_screen;
|
||||
|
||||
@@ -96,12 +96,12 @@ fn format_message_for_test(msg: &tele_tui::tdlib::MessageInfo) -> String {
|
||||
let mut result = String::new();
|
||||
|
||||
// Добавляем forward контекст если есть
|
||||
if let Some(forward) = &msg.forward_from {
|
||||
if let Some(forward) = &msg.forward_from() {
|
||||
result.push_str(&format!("↪ Переслано от {}\n", forward.sender_name));
|
||||
}
|
||||
|
||||
// Добавляем 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));
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ mod helpers;
|
||||
|
||||
use helpers::fake_tdclient::FakeTdClient;
|
||||
use helpers::test_data::TestMessageBuilder;
|
||||
use tele_tui::types::MessageId;
|
||||
|
||||
/// Test: Удаление сообщения убирает его из списка
|
||||
#[test]
|
||||
@@ -51,8 +52,8 @@ fn test_delete_multiple_messages() {
|
||||
// Проверяем что осталось только второе сообщение
|
||||
let messages = client.get_messages(123);
|
||||
assert_eq!(messages.len(), 1);
|
||||
assert_eq!(messages[0].id, msg2_id);
|
||||
assert_eq!(messages[0].content.text(), "Message 2");
|
||||
assert_eq!(messages[0].id(), MessageId::new(msg2_id));
|
||||
assert_eq!(messages[0].content.text, "Message 2");
|
||||
}
|
||||
|
||||
/// 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);
|
||||
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[0].can_be_deleted_for_all_users(), true); // Наше
|
||||
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[1].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);
|
||||
}
|
||||
|
||||
/// Test: Удаление несуществующего сообщения (ничего не происходит)
|
||||
@@ -102,7 +103,7 @@ fn test_delete_nonexistent_message() {
|
||||
// Но существующее сообщение осталось
|
||||
let messages = client.get_messages(123);
|
||||
assert_eq!(messages.len(), 1);
|
||||
assert_eq!(messages[0].id, msg_id);
|
||||
assert_eq!(messages[0].id(), MessageId::new(msg_id));
|
||||
}
|
||||
|
||||
/// Test: Подтверждение удаления (симуляция модалки)
|
||||
@@ -144,6 +145,6 @@ fn test_cancel_delete_keeps_message() {
|
||||
|
||||
// Сообщение на месте
|
||||
let messages = client.get_messages(123);
|
||||
assert_eq!(messages[0].id, msg_id);
|
||||
assert_eq!(messages[0].content.text(), "Keep me");
|
||||
assert_eq!(messages[0].id(), MessageId::new(msg_id));
|
||||
assert_eq!(messages[0].content.text, "Keep me");
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ fn test_edit_message_changes_text() {
|
||||
// Проверяем что текст сообщения изменился
|
||||
let messages = client.get_messages(123);
|
||||
assert_eq!(messages.len(), 1);
|
||||
assert_eq!(messages[0].content.text(), "Edited text");
|
||||
assert_eq!(messages[0].content.text, "Edited text");
|
||||
}
|
||||
|
||||
/// Test: Редактирование устанавливает edit_date
|
||||
@@ -37,16 +37,16 @@ fn test_edit_message_sets_edit_date() {
|
||||
|
||||
// Получаем дату до редактирования
|
||||
let messages_before = client.get_messages(123);
|
||||
let date_before = messages_before[0].date;
|
||||
assert_eq!(messages_before[0].edit_date, 0); // Не редактировалось
|
||||
let date_before = messages_before[0].date();
|
||||
assert_eq!(messages_before[0].edit_date(), 0); // Не редактировалось
|
||||
|
||||
// Редактируем сообщение
|
||||
client.edit_message(123, msg_id, "Edited".to_string());
|
||||
|
||||
// Проверяем что edit_date установлена
|
||||
let messages_after = client.get_messages(123);
|
||||
assert!(messages_after[0].edit_date > 0);
|
||||
assert!(messages_after[0].edit_date > date_before); // edit_date после date
|
||||
assert!(messages_after[0].edit_date() > 0);
|
||||
assert!(messages_after[0].edit_date() > date_before); // edit_date после date
|
||||
}
|
||||
|
||||
/// Test: Редактирование только своих сообщений (проверка через can_be_edited)
|
||||
@@ -68,8 +68,8 @@ fn test_can_only_edit_own_messages() {
|
||||
|
||||
// Проверяем флаги
|
||||
let messages = client.get_messages(123);
|
||||
assert_eq!(messages[0].can_be_edited, true); // Наше сообщение
|
||||
assert_eq!(messages[1].can_be_edited, false); // Чужое сообщение
|
||||
assert_eq!(messages[0].can_be_edited(), true); // Наше сообщение
|
||||
assert_eq!(messages[1].can_be_edited(), false); // Чужое сообщение
|
||||
}
|
||||
|
||||
/// Test: Множественные редактирования одного сообщения
|
||||
@@ -97,7 +97,7 @@ fn test_multiple_edits_of_same_message() {
|
||||
// Проверяем что сообщение содержит последнюю версию
|
||||
let messages = client.get_messages(123);
|
||||
assert_eq!(messages.len(), 1);
|
||||
assert_eq!(messages[0].content.text(), "Final version");
|
||||
assert_eq!(messages[0].content.text, "Final version");
|
||||
}
|
||||
|
||||
/// Test: Редактирование несуществующего сообщения (ничего не происходит)
|
||||
@@ -136,14 +136,14 @@ fn test_edit_history_tracking() {
|
||||
|
||||
// Проверяем что изменилось
|
||||
let messages_edited = client.get_messages(123);
|
||||
assert_eq!(messages_edited[0].content.text(), "Edited");
|
||||
assert_eq!(messages_edited[0].content.text, "Edited");
|
||||
|
||||
// Можем "отменить" редактирование вернув original
|
||||
client.edit_message(123, msg_id, original);
|
||||
|
||||
// Проверяем что вернулось
|
||||
let messages_restored = client.get_messages(123);
|
||||
assert_eq!(messages_restored[0].content.text(), "Original");
|
||||
assert_eq!(messages_restored[0].content.text, "Original");
|
||||
|
||||
// История показывает 2 редактирования
|
||||
assert_eq!(client.edited_messages().len(), 2);
|
||||
|
||||
@@ -312,7 +312,7 @@ mod tests {
|
||||
.selected_chat(123)
|
||||
.build();
|
||||
|
||||
assert_eq!(app.selected_chat_id, Some(123));
|
||||
assert_eq!(app.selected_chat_id, Some(ChatId::new(123)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -323,7 +323,7 @@ mod tests {
|
||||
.build();
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
use tele_tui::tdlib::{ChatInfo, FolderInfo, MessageInfo, NetworkState};
|
||||
use tele_tui::types::MessageId;
|
||||
|
||||
/// Упрощённый mock TDLib клиента для тестов
|
||||
#[derive(Clone)]
|
||||
@@ -124,22 +125,22 @@ impl FakeTdClient {
|
||||
.push(SentMessage { chat_id, text: text.clone(), reply_to });
|
||||
|
||||
// Добавляем сообщение в список сообщений чата
|
||||
let message = MessageInfo {
|
||||
id: message_id,
|
||||
sender_name: "You".to_string(),
|
||||
is_outgoing: true,
|
||||
content: text,
|
||||
entities: vec![],
|
||||
date: 1640000000,
|
||||
edit_date: 0,
|
||||
is_read: true,
|
||||
can_be_edited: true,
|
||||
can_be_deleted_only_for_self: true,
|
||||
can_be_deleted_for_all_users: true,
|
||||
reply_to: None,
|
||||
forward_from: None,
|
||||
reactions: vec![],
|
||||
};
|
||||
let message = MessageInfo::new(
|
||||
MessageId::new(message_id),
|
||||
"You".to_string(),
|
||||
true, // is_outgoing
|
||||
text,
|
||||
vec![], // entities
|
||||
1640000000, // date
|
||||
0, // edit_date
|
||||
true, // is_read
|
||||
true, // can_be_edited
|
||||
true, // can_be_deleted_only_for_self
|
||||
true, // can_be_deleted_for_all_users
|
||||
None, // reply_to
|
||||
None, // forward_from
|
||||
vec![], // reactions
|
||||
);
|
||||
|
||||
self.messages
|
||||
.entry(chat_id)
|
||||
@@ -156,7 +157,7 @@ impl FakeTdClient {
|
||||
|
||||
// Обновляем сообщение в списке
|
||||
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.metadata.edit_date = msg.metadata.date + 60;
|
||||
}
|
||||
@@ -169,7 +170,7 @@ impl FakeTdClient {
|
||||
|
||||
// Удаляем сообщение из списка
|
||||
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()[0].text, "Hello");
|
||||
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]
|
||||
@@ -248,8 +249,8 @@ mod tests {
|
||||
client.edit_message(123, msg_id, "Hello World".to_string());
|
||||
|
||||
assert_eq!(client.edited_messages().len(), 1);
|
||||
assert_eq!(client.get_messages(123)[0].content, "Hello World");
|
||||
assert!(client.get_messages(123)[0].edit_date > 0);
|
||||
assert_eq!(client.get_messages(123)[0].content.text, "Hello World");
|
||||
assert!(client.get_messages(123)[0].edit_date() > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -6,6 +6,7 @@ use helpers::app_builder::TestAppBuilder;
|
||||
use helpers::snapshot_utils::{buffer_to_string, render_to_buffer};
|
||||
use helpers::test_data::{create_test_chat, TestChatBuilder, TestMessageBuilder};
|
||||
use insta::assert_snapshot;
|
||||
use tele_tui::types::{ChatId, MessageId};
|
||||
|
||||
#[test]
|
||||
fn snapshot_empty_chat() {
|
||||
@@ -154,8 +155,8 @@ fn snapshot_outgoing_read() {
|
||||
.build();
|
||||
|
||||
// Set last_read_outbox to simulate message being read
|
||||
if let Some(chat) = app.chats.iter_mut().find(|c| c.id == 123) {
|
||||
chat.last_read_outbox_message_id = 2;
|
||||
if let Some(chat) = app.chats.iter_mut().find(|c| c.id == ChatId::new(123)) {
|
||||
chat.last_read_outbox_message_id = MessageId::new(2);
|
||||
}
|
||||
|
||||
let buffer = render_to_buffer(80, 24, |f| {
|
||||
|
||||
@@ -223,6 +223,6 @@ fn test_load_older_messages_on_scroll_up() {
|
||||
|
||||
// Теперь должно быть 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)[14].content.text(), "Msg 100");
|
||||
assert_eq!(client.get_messages(123)[0].content.text, "Msg 81");
|
||||
assert_eq!(client.get_messages(123)[14].content.text, "Msg 100");
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ mod helpers;
|
||||
use helpers::fake_tdclient::FakeTdClient;
|
||||
use helpers::test_data::create_test_chat;
|
||||
use tele_tui::tdlib::ProfileInfo;
|
||||
use tele_tui::types::ChatId;
|
||||
|
||||
/// Test: Открытие профиля в личном чате (i)
|
||||
#[test]
|
||||
@@ -26,7 +27,7 @@ fn test_open_profile_in_private_chat() {
|
||||
#[test]
|
||||
fn test_profile_shows_user_info() {
|
||||
let profile = ProfileInfo {
|
||||
chat_id: 123,
|
||||
chat_id: ChatId::new(123),
|
||||
title: "Alice Johnson".to_string(),
|
||||
username: Some("alice".to_string()),
|
||||
phone_number: Some("+1234567890".to_string()),
|
||||
@@ -49,7 +50,7 @@ fn test_profile_shows_user_info() {
|
||||
#[test]
|
||||
fn test_profile_shows_group_member_count() {
|
||||
let profile = ProfileInfo {
|
||||
chat_id: 456,
|
||||
chat_id: ChatId::new(456),
|
||||
title: "Work Team".to_string(),
|
||||
username: None,
|
||||
phone_number: None,
|
||||
@@ -72,7 +73,7 @@ fn test_profile_shows_group_member_count() {
|
||||
#[test]
|
||||
fn test_profile_shows_channel_info() {
|
||||
let profile = ProfileInfo {
|
||||
chat_id: 789,
|
||||
chat_id: ChatId::new(789),
|
||||
title: "News Channel".to_string(),
|
||||
username: Some("news_channel".to_string()),
|
||||
phone_number: None,
|
||||
@@ -107,7 +108,7 @@ fn test_close_profile_with_esc() {
|
||||
#[test]
|
||||
fn test_profile_without_optional_fields() {
|
||||
let profile = ProfileInfo {
|
||||
chat_id: 999,
|
||||
chat_id: ChatId::new(999),
|
||||
title: "Anonymous User".to_string(),
|
||||
username: None,
|
||||
phone_number: None,
|
||||
|
||||
@@ -37,8 +37,8 @@ fn test_toggle_reaction_removes_it() {
|
||||
|
||||
// Проверяем что реакция есть
|
||||
let messages_before = client.get_messages(123);
|
||||
assert_eq!(messages_before[0].reactions.len(), 1);
|
||||
assert_eq!(messages_before[0].reactions[0].is_chosen, true);
|
||||
assert_eq!(messages_before[0].reactions().len(), 1);
|
||||
assert_eq!(messages_before[0].reactions()[0].is_chosen, true);
|
||||
|
||||
// Симулируем удаление реакции (в реальном App это toggle)
|
||||
// FakeTdClient просто записывает что реакция была "убрана"
|
||||
@@ -51,7 +51,7 @@ fn test_toggle_reaction_removes_it() {
|
||||
client.messages.insert(123, vec![msg_after]);
|
||||
|
||||
let messages_after = client.get_messages(123);
|
||||
assert_eq!(messages_after[0].reactions.len(), 0);
|
||||
assert_eq!(messages_after[0].reactions().len(), 0);
|
||||
}
|
||||
|
||||
/// Test: Множественные реакции на одно сообщение
|
||||
@@ -89,7 +89,7 @@ fn test_reactions_from_multiple_users() {
|
||||
client = client.with_message(123, msg);
|
||||
|
||||
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.count, 3);
|
||||
@@ -109,7 +109,7 @@ fn test_own_reaction_is_chosen() {
|
||||
client = client.with_message(123, msg);
|
||||
|
||||
let messages = client.get_messages(123);
|
||||
let reaction = &messages[0].reactions[0];
|
||||
let reaction = &messages[0].reactions()[0];
|
||||
|
||||
assert_eq!(reaction.is_chosen, true);
|
||||
// В UI это будет отображаться в рамках: [❤️]
|
||||
@@ -128,7 +128,7 @@ fn test_other_reaction_not_chosen() {
|
||||
client = client.with_message(123, msg);
|
||||
|
||||
let messages = client.get_messages(123);
|
||||
let reaction = &messages[0].reactions[0];
|
||||
let reaction = &messages[0].reactions()[0];
|
||||
|
||||
assert_eq!(reaction.is_chosen, false);
|
||||
// В UI это будет отображаться без рамок: 😂 2
|
||||
@@ -154,7 +154,7 @@ fn test_reaction_counter_increases() {
|
||||
client.messages.insert(123, vec![msg_v2]);
|
||||
|
||||
let messages = client.get_messages(123);
|
||||
assert_eq!(messages[0].reactions[0].count, 5);
|
||||
assert_eq!(messages[0].reactions()[0].count, 5);
|
||||
}
|
||||
|
||||
/// Test: Обновление реакции - мы добавили свою к существующим
|
||||
@@ -177,7 +177,7 @@ fn test_update_reaction_we_add_ours() {
|
||||
client.messages.insert(123, vec![msg_after]);
|
||||
|
||||
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.is_chosen, true);
|
||||
@@ -195,7 +195,7 @@ fn test_single_reaction_shows_only_emoji() {
|
||||
client = client.with_message(123, msg);
|
||||
|
||||
let messages = client.get_messages(123);
|
||||
let reaction = &messages[0].reactions[0];
|
||||
let reaction = &messages[0].reactions()[0];
|
||||
|
||||
assert_eq!(reaction.count, 1);
|
||||
// В UI: если count=1, показываем только emoji без цифры
|
||||
@@ -228,16 +228,16 @@ fn test_reactions_on_multiple_messages() {
|
||||
let messages = client.get_messages(123);
|
||||
|
||||
// Первое: 1 реакция
|
||||
assert_eq!(messages[0].reactions.len(), 1);
|
||||
assert_eq!(messages[0].reactions[0].emoji, "👍");
|
||||
assert_eq!(messages[0].reactions().len(), 1);
|
||||
assert_eq!(messages[0].reactions()[0].emoji, "👍");
|
||||
|
||||
// Второе: 1 реакция
|
||||
assert_eq!(messages[1].reactions.len(), 1);
|
||||
assert_eq!(messages[1].reactions[0].emoji, "❤️");
|
||||
assert_eq!(messages[1].reactions().len(), 1);
|
||||
assert_eq!(messages[1].reactions()[0].emoji, "❤️");
|
||||
|
||||
// Третье: 2 реакции
|
||||
assert_eq!(messages[2].reactions.len(), 2);
|
||||
assert_eq!(messages[2].reactions[0].emoji, "😂");
|
||||
assert_eq!(messages[2].reactions[1].emoji, "🔥");
|
||||
assert_eq!(messages[2].reactions[1].is_chosen, true);
|
||||
assert_eq!(messages[2].reactions().len(), 2);
|
||||
assert_eq!(messages[2].reactions()[0].emoji, "😂");
|
||||
assert_eq!(messages[2].reactions()[1].emoji, "🔥");
|
||||
assert_eq!(messages[2].reactions()[1].is_chosen, true);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ mod helpers;
|
||||
use helpers::fake_tdclient::FakeTdClient;
|
||||
use helpers::test_data::TestMessageBuilder;
|
||||
use tele_tui::tdlib::{ForwardInfo, ReplyInfo};
|
||||
use tele_tui::types::MessageId;
|
||||
|
||||
/// Test: Reply создаёт сообщение с reply_to
|
||||
#[test]
|
||||
@@ -28,8 +29,8 @@ fn test_reply_creates_message_with_reply_to() {
|
||||
// Проверяем что в списке 2 сообщения
|
||||
let messages = client.get_messages(123);
|
||||
assert_eq!(messages.len(), 2);
|
||||
assert_eq!(messages[1].id, reply_id);
|
||||
assert_eq!(messages[1].content.text(), "Answer!");
|
||||
assert_eq!(messages[1].id(), MessageId::new(reply_id));
|
||||
assert_eq!(messages[1].content.text, "Answer!");
|
||||
}
|
||||
|
||||
/// Test: Reply отображает превью оригинального сообщения
|
||||
@@ -48,10 +49,10 @@ fn test_reply_shows_original_preview() {
|
||||
// Проверяем что reply_to сохранено
|
||||
let messages = client.get_messages(123);
|
||||
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();
|
||||
assert_eq!(reply.message_id, 100);
|
||||
let reply = messages[0].reply_to().unwrap();
|
||||
assert_eq!(reply.message_id, MessageId::new(100));
|
||||
assert_eq!(reply.sender_name, "Alice");
|
||||
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);
|
||||
|
||||
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
|
||||
@@ -94,9 +95,9 @@ fn test_forward_creates_message_with_forward_from() {
|
||||
// Проверяем что forward_from сохранено
|
||||
let messages = client.get_messages(456);
|
||||
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!(forward.date > 0); // Дата установлена
|
||||
}
|
||||
@@ -114,7 +115,7 @@ fn test_forward_displays_sender_name() {
|
||||
client = client.with_message(789, msg);
|
||||
|
||||
let messages = client.get_messages(789);
|
||||
let forward = messages[0].forward_from.as_ref().unwrap();
|
||||
let forward = messages[0].forward_from().unwrap();
|
||||
|
||||
// В UI это будет отображаться как "↪ Переслано от Charlie"
|
||||
assert_eq!(forward.sender_name, "Charlie");
|
||||
@@ -144,7 +145,7 @@ fn test_forward_to_different_chat() {
|
||||
|
||||
// Проверяем что во втором чате тоже 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 комбинация (ответ на пересланное сообщение)
|
||||
@@ -167,7 +168,7 @@ fn test_reply_to_forwarded_message() {
|
||||
|
||||
let messages = client.get_messages(123);
|
||||
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)
|
||||
@@ -196,7 +197,7 @@ fn test_forward_multiple_messages() {
|
||||
// Проверяем что все 3 сообщения пересланы
|
||||
let messages = client.get_messages(456);
|
||||
assert_eq!(messages.len(), 3);
|
||||
assert!(messages[0].forward_from.is_some());
|
||||
assert!(messages[1].forward_from.is_some());
|
||||
assert!(messages[2].forward_from.is_some());
|
||||
assert!(messages[0].forward_from().is_some());
|
||||
assert!(messages[1].forward_from().is_some());
|
||||
assert!(messages[2].forward_from().is_some());
|
||||
}
|
||||
|
||||
@@ -24,9 +24,9 @@ fn test_send_text_message() {
|
||||
// Проверяем что сообщение добавилось в список
|
||||
let messages = client.get_messages(123);
|
||||
assert_eq!(messages.len(), 1);
|
||||
assert_eq!(messages[0].id, msg_id);
|
||||
assert_eq!(messages.text(), "Hello, Mom!");
|
||||
assert_eq!(messages[0].is_outgoing, true);
|
||||
assert_eq!(messages[0].id().as_i64(), msg_id);
|
||||
assert_eq!(messages[0].text(), "Hello, Mom!");
|
||||
assert_eq!(messages[0].is_outgoing(), true);
|
||||
}
|
||||
|
||||
/// Test: Отправка нескольких сообщений обновляет список
|
||||
@@ -49,12 +49,12 @@ fn test_send_multiple_messages_updates_list() {
|
||||
// Проверяем что все сообщения в списке
|
||||
let messages = client.get_messages(123);
|
||||
assert_eq!(messages.len(), 3);
|
||||
assert_eq!(messages[0].id, msg1_id);
|
||||
assert_eq!(messages[1].id, msg2_id);
|
||||
assert_eq!(messages[2].id, msg3_id);
|
||||
assert_eq!(messages.text(), "Message 1");
|
||||
assert_eq!(messages.text(), "Message 2");
|
||||
assert_eq!(messages.text(), "Message 3");
|
||||
assert_eq!(messages[0].id().as_i64(), msg1_id);
|
||||
assert_eq!(messages[1].id().as_i64(), msg2_id);
|
||||
assert_eq!(messages[2].id().as_i64(), msg3_id);
|
||||
assert_eq!(messages[0].text(), "Message 1");
|
||||
assert_eq!(messages[1].text(), "Message 2");
|
||||
assert_eq!(messages[2].text(), "Message 3");
|
||||
}
|
||||
|
||||
/// Test: Отправка пустого сообщения (должно быть игнорировано на уровне App)
|
||||
@@ -73,8 +73,8 @@ fn test_send_empty_message_technical() {
|
||||
|
||||
let messages = client.get_messages(123);
|
||||
assert_eq!(messages.len(), 1);
|
||||
assert_eq!(messages[0].id, msg_id);
|
||||
assert_eq!(messages.text(), "");
|
||||
assert_eq!(messages[0].id().as_i64(), msg_id);
|
||||
assert_eq!(messages[0].text(), "");
|
||||
}
|
||||
|
||||
/// Test: Отправка сообщения с форматированием (markdown сущности)
|
||||
@@ -89,7 +89,7 @@ fn test_send_message_with_markdown() {
|
||||
// Проверяем что текст сохранился как есть (парсинг markdown - отдельная логика)
|
||||
let messages = client.get_messages(123);
|
||||
assert_eq!(messages.len(), 1);
|
||||
assert_eq!(messages.text(), text);
|
||||
assert_eq!(messages[0].text(), text);
|
||||
}
|
||||
|
||||
/// Test: Отправка сообщения в разные чаты
|
||||
@@ -112,12 +112,12 @@ fn test_send_messages_to_different_chats() {
|
||||
// Проверяем что сообщения распределены по чатам
|
||||
let chat123_messages = client.get_messages(123);
|
||||
assert_eq!(chat123_messages.len(), 2);
|
||||
assert_eq!(chat123_messages.text(), "Hello Mom");
|
||||
assert_eq!(chat123_messages.text(), "How are you?");
|
||||
assert_eq!(chat123_messages[0].text(), "Hello Mom");
|
||||
assert_eq!(chat123_messages[1].text(), "How are you?");
|
||||
|
||||
let chat456_messages = client.get_messages(456);
|
||||
assert_eq!(chat456_messages.len(), 1);
|
||||
assert_eq!(chat456_messages.text(), "Hello Boss");
|
||||
assert_eq!(chat456_messages[0].text(), "Hello Boss");
|
||||
}
|
||||
|
||||
/// Test: Новое сообщение появляется в реальном времени (симуляция)
|
||||
@@ -139,8 +139,8 @@ fn test_receive_incoming_message() {
|
||||
// Проверяем что в списке 2 сообщения
|
||||
let messages = client.get_messages(123);
|
||||
assert_eq!(messages.len(), 2);
|
||||
assert_eq!(messages[0].is_outgoing, true); // Наше сообщение
|
||||
assert_eq!(messages[1].is_outgoing, false); // Входящее
|
||||
assert_eq!(messages.text(), "Hey there!");
|
||||
assert_eq!(messages[1].sender_name, "Alice");
|
||||
assert_eq!(messages[0].is_outgoing(), true); // Наше сообщение
|
||||
assert_eq!(messages[1].is_outgoing(), false); // Входящее
|
||||
assert_eq!(messages[1].text(), "Hey there!");
|
||||
assert_eq!(messages[1].sender_name(), "Alice");
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ expression: output
|
||||
│ │
|
||||
│ ┌ Выбери реакцию ────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ 👍 👎 ❤️ 🔥 😊 😢 😮 🎉 │ │
|
||||
│ │ 👍 👎 ❤️ 🔥 😊 😢 😮 🎉 │ │
|
||||
│ │ │ │
|
||||
│ └────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
|
||||
@@ -13,7 +13,7 @@ expression: output
|
||||
│ │
|
||||
│ ┌ Выбери реакцию ────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ 👍 👎 ❤️ 🔥 😊 😢 😮 🎉 │ │
|
||||
│ │ 👍 👎 ❤️ 🔥 😊 😢 😮 🎉 │ │
|
||||
│ │ │ │
|
||||
│ └────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
|
||||
Reference in New Issue
Block a user